The trigger buys telemetry and only telemetry, so a charger that has been read a hundred times and commanded none reports amps, volts and nothing else: no schedule, no balancing, no Modbus server, not even its firmware. The message that asks for that half is 0040, and the reference keeps it commented out because the app sends its timestamp without a value type. The app is what the charger answers, so the oddity is reproduced rather than corrected — sent when the settings half is missing or older than ten minutes, waited four seconds for, and after three unanswered requests still sent but no longer waited on. The three settings the panel has and the writer did not — swipe up, swipe down, smart touch — are writable now, which is all eleven of the 0100 commands. Nothing else in the map was missing: every named field of every message was already decoded, and the raw keys the card shows are fields the reference does not name either. Both cards drop a row with nothing in it, which turned a charger that reports only its ceiling into a charger that reports no current range at all. Half a range is still a bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2761 lines
105 KiB
Dart
2761 lines
105 KiB
Dart
import "package:flutter/material.dart";
|
||
import "package:shared_preferences/shared_preferences.dart";
|
||
|
||
import "../format.dart";
|
||
import "../i18n.dart";
|
||
import "../main.dart";
|
||
import "../models.dart";
|
||
import "../theme.dart";
|
||
import "car_view_sheet.dart" show arrangeKeys;
|
||
import "charger_import_sheet.dart";
|
||
|
||
/// Charging & map screen, mirroring the web Charging.vue. Two tabs split the
|
||
/// public charging network (a presentational discovery map + demo session +
|
||
/// nearby public stations) from the user's own chargers and their real control.
|
||
///
|
||
/// There is no live public-charging API yet — the session and station lists are
|
||
/// placeholders, exactly as on the web. The home tab is not: those are chargers
|
||
/// the user imported from a service they connected, and the cards beside them
|
||
/// drive a real charger through the Anker Solix control endpoints once a control
|
||
/// mode is picked in Settings → Integrations.
|
||
|
||
/// The tabs this page can show, in the order they appear when the account has no
|
||
/// arrangement of its own. Mirrors ALL_CHARGER_TABS in the web Charging.vue; the
|
||
/// server rejects nothing here, but a key outside this set is dropped on read.
|
||
const List<String> kChargerTabKeys = ["public", "home"];
|
||
|
||
/// The home tab's cards, in their default order. Controls first, because acting
|
||
/// on the charger is what the page is opened for; the connection below it,
|
||
/// because it is touched once and then left alone.
|
||
const List<String> kChargerCardKeys = ["control", "connection", "readings", "info"];
|
||
|
||
/// Which cards are folded away, kept on the device rather than on the profile:
|
||
/// it is a per-device reading habit, not an account setting. Keyed by card, so a
|
||
/// fold outlives switching charger.
|
||
const String _kCollapsedKey = "cc_chargingCollapsed";
|
||
|
||
/// The serial the control cards are pointed at. Remembered so the page comes
|
||
/// back up on the charger it was left on, the way the web does.
|
||
const String _kSerialKey = "cc_ctlSerial";
|
||
|
||
class ChargingScreen extends StatefulWidget {
|
||
const ChargingScreen({super.key});
|
||
@override
|
||
State<ChargingScreen> createState() => _ChargingScreenState();
|
||
}
|
||
|
||
/// A public charging station, tone-coded by availability. Presentational demo
|
||
/// data — there is no public-network API to fill it from yet.
|
||
class _Station {
|
||
final String id;
|
||
final String name;
|
||
final String dist;
|
||
final int kw;
|
||
final String conn;
|
||
final int avail;
|
||
final int total;
|
||
final String price;
|
||
final String tone; // "good" | "due" | "fault"
|
||
const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail,
|
||
this.total, this.price, this.tone);
|
||
}
|
||
|
||
class _ChargingScreenState extends State<ChargingScreen> {
|
||
/// The tab bar's arrangement, and the home cards'. Both live on the profile —
|
||
/// they are layout choices that should follow the account, the way the garage
|
||
/// order does — so they arrive with /me and are written back with it.
|
||
List<String> _tabKeys = List.of(kChargerTabKeys);
|
||
List<String> _cardKeys = List.of(kChargerCardKeys);
|
||
String? _orderError;
|
||
|
||
int _tab = 0;
|
||
String _selectedStation = "sc";
|
||
|
||
static const List<_Station> _stations = [
|
||
_Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"),
|
||
_Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"),
|
||
_Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"),
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadArrangement();
|
||
}
|
||
|
||
/// The arrangement arrives with the profile. A failure is silent: the page is
|
||
/// perfectly usable in its default order, and an error banner about it would
|
||
/// be about nothing the user asked for.
|
||
Future<void> _loadArrangement() async {
|
||
try {
|
||
final me = await apiClient.getMe();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder);
|
||
_cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder);
|
||
});
|
||
} catch (_) {
|
||
// Keep the defaults.
|
||
}
|
||
}
|
||
|
||
Future<void> _arrange() async {
|
||
final result = await showChargingArrangeSheet(
|
||
context,
|
||
tabs: _tabKeys,
|
||
cards: _cardKeys,
|
||
);
|
||
if (result == null || !mounted) return;
|
||
|
||
// The tab in view is followed through the rearrangement rather than left at
|
||
// its index: moving a tab should not switch which one you are reading.
|
||
final current = _tabKeys[_tab.clamp(0, _tabKeys.length - 1)];
|
||
setState(() {
|
||
_tabKeys = result.tabs;
|
||
_cardKeys = result.cards;
|
||
_tab = result.tabs.indexOf(current).clamp(0, result.tabs.length - 1);
|
||
_orderError = null;
|
||
});
|
||
try {
|
||
await apiClient.updateMe({
|
||
"chargerTabOrder": result.tabs,
|
||
"chargerCardOrder": result.cards,
|
||
});
|
||
} catch (e) {
|
||
// It didn't stick; say so and put the stored arrangement back rather than
|
||
// leaving the page showing an order the server does not have.
|
||
if (!mounted) return;
|
||
setState(() => _orderError = "$e");
|
||
_loadArrangement();
|
||
}
|
||
}
|
||
|
||
Color _toneColor(String tone) {
|
||
switch (tone) {
|
||
case "due":
|
||
return DriverVault.warning;
|
||
case "fault":
|
||
return DriverVault.danger;
|
||
default:
|
||
return DriverVault.success;
|
||
}
|
||
}
|
||
|
||
String _stationStatus(_Station s) => s.avail == 0
|
||
? t("charging.stations.full")
|
||
: t("charging.stations.free", params: {"avail": s.avail, "total": s.total});
|
||
|
||
Widget _tabBody(String key, {required bool active}) {
|
||
switch (key) {
|
||
case "home":
|
||
return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active);
|
||
default:
|
||
return _PublicTab(
|
||
key: const ValueKey("public"),
|
||
stations: _stations,
|
||
selected: _selectedStation,
|
||
onSelect: (id) => setState(() => _selectedStation = id),
|
||
toneColor: _toneColor,
|
||
statusFor: _stationStatus,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final index = _tab.clamp(0, _tabKeys.length - 1);
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(t("charging.title")),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.swap_vert),
|
||
tooltip: t("charging.arrange.title"),
|
||
onPressed: _arrange,
|
||
),
|
||
],
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(12),
|
||
children: [
|
||
// Eyebrow + title header, matching the web layout.
|
||
Text(t("charging.eyebrow"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
|
||
.copyWith(letterSpacing: 2.2)),
|
||
const SizedBox(height: 2),
|
||
Text(t("charging.title"),
|
||
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||
const SizedBox(height: 16),
|
||
|
||
if (_orderError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: Text(_orderError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
||
),
|
||
|
||
// Tabs: public network vs. the user's own chargers.
|
||
_TabBar(
|
||
tabs: [for (final key in _tabKeys) t("charging.tabs.$key")],
|
||
index: index,
|
||
onChanged: (i) => setState(() => _tab = i),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// IndexedStack keeps each tab's state alive across switches (v-show parity).
|
||
// Keyed, so rearranging the bar moves the tabs rather than rebuilding
|
||
// whichever widget now sits at that index from scratch.
|
||
IndexedStack(
|
||
index: index,
|
||
children: [
|
||
for (var i = 0; i < _tabKeys.length; i++)
|
||
_tabBody(_tabKeys[i], active: i == index),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Arranging the page ------------------------------------------------------
|
||
|
||
/// What the arrange sheet returns: the two arrangements, saved together.
|
||
class ChargingArrangement {
|
||
final List<String> tabs;
|
||
final List<String> cards;
|
||
const ChargingArrangement(this.tabs, this.cards);
|
||
}
|
||
|
||
/// Rearranges the charging tabs and the home tab's cards.
|
||
///
|
||
/// The web drags the tab bar and the card headings themselves; on a touch screen
|
||
/// the tab bar owns that gesture and a card heading is a fold toggle, so both
|
||
/// arrangements are made here instead, with a handle — exactly the trade the
|
||
/// car's view picker makes.
|
||
Future<ChargingArrangement?> showChargingArrangeSheet(
|
||
BuildContext context, {
|
||
required List<String> tabs,
|
||
required List<String> cards,
|
||
}) {
|
||
return showModalBottomSheet<ChargingArrangement>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (_) => _ChargingArrangeSheet(tabs: tabs, cards: cards),
|
||
);
|
||
}
|
||
|
||
class _ChargingArrangeSheet extends StatefulWidget {
|
||
final List<String> tabs;
|
||
final List<String> cards;
|
||
const _ChargingArrangeSheet({required this.tabs, required this.cards});
|
||
@override
|
||
State<_ChargingArrangeSheet> createState() => _ChargingArrangeSheetState();
|
||
}
|
||
|
||
class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> {
|
||
late final List<String> _tabs = List.of(widget.tabs);
|
||
late final List<String> _cards = List.of(widget.cards);
|
||
|
||
/// The card's own heading, so the sheet names each row the way the page does.
|
||
String _cardLabel(String key) => switch (key) {
|
||
"control" => t("charging.control.title"),
|
||
"connection" => t("charging.control.connectionTitle"),
|
||
"readings" => t("charging.modbus.title"),
|
||
_ => t("charging.info.title"),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
top: 16,
|
||
bottom: DriverVault.sheetBottomInset(context),
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.arrange.title"),
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 4),
|
||
Text(t("car.viewPicker.reorderHint"), style: TextStyle(fontSize: 12, color: muted)),
|
||
const SizedBox(height: 16),
|
||
_section(t("charging.arrange.tabs")),
|
||
_reorder(_tabs, (k) => t("charging.tabs.$k"),
|
||
(from, to) => setState(() => _tabs.insert(to, _tabs.removeAt(from)))),
|
||
const SizedBox(height: 16),
|
||
_section(t("charging.arrange.cards")),
|
||
_reorder(_cards, _cardLabel,
|
||
(from, to) => setState(() => _cards.insert(to, _cards.removeAt(from)))),
|
||
const SizedBox(height: 16),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(t("common.cancel")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: () => Navigator.pop(context, ChargingArrangement(_tabs, _cards)),
|
||
child: Text(t("common.save")),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _section(String label) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
);
|
||
|
||
/// A short reorderable list of labelled rows, each dragged by its own handle.
|
||
/// shrinkWrap because the sheet scrolls, not the list.
|
||
Widget _reorder(List<String> keys, String Function(String) label,
|
||
void Function(int, int) onMove) {
|
||
return ReorderableListView(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
buildDefaultDragHandles: false,
|
||
onReorderItem: onMove,
|
||
children: [
|
||
for (var i = 0; i < keys.length; i++)
|
||
ListTile(
|
||
key: ValueKey(keys[i]),
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text(label(keys[i]), style: const TextStyle(fontSize: 14)),
|
||
trailing: ReorderableDragStartListener(
|
||
index: i,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Icon(Icons.drag_handle, color: DriverVault.muted(context)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Shared: underline tab bar (matches Settings tabs) ----------------------
|
||
|
||
class _TabBar extends StatelessWidget {
|
||
final List<String> tabs;
|
||
final int index;
|
||
final ValueChanged<int> onChanged;
|
||
const _TabBar({required this.tabs, required this.index, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final accent = Theme.of(context).colorScheme.primary;
|
||
final muted = DriverVault.muted(context);
|
||
final border = DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100;
|
||
return Container(
|
||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: border))),
|
||
child: Row(
|
||
children: [
|
||
for (var i = 0; i < tabs.length; i++)
|
||
GestureDetector(
|
||
onTap: () => onChanged(i),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
padding: const EdgeInsets.only(right: 20, bottom: 10, top: 2),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: i == index ? accent : Colors.transparent,
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
child: Text(
|
||
tabs[i],
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: i == index ? (DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900) : muted,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A titled bordered card, matching the web's dh-card.
|
||
class _Card extends StatelessWidget {
|
||
final EdgeInsetsGeometry padding;
|
||
final Widget child;
|
||
const _Card({this.padding = const EdgeInsets.all(16), required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
elevation: 0,
|
||
margin: EdgeInsets.zero,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
side: BorderSide(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Padding(padding: padding, child: child),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A card whose heading folds it away. The column runs long once the readings
|
||
/// are in it, and which cards are folded is remembered per device.
|
||
class _FoldCard extends StatelessWidget {
|
||
final String title;
|
||
final bool open;
|
||
final VoidCallback onToggle;
|
||
|
||
/// Rides in the header, so it is still readable with the card folded — the
|
||
/// connection badge is the one thing worth seeing either way.
|
||
final Widget? badge;
|
||
|
||
/// Sits beside the heading rather than inside the fold, so it can be pressed
|
||
/// without unfolding anything.
|
||
final Widget? action;
|
||
|
||
final List<Widget> children;
|
||
|
||
const _FoldCard({
|
||
required this.title,
|
||
required this.open,
|
||
required this.onToggle,
|
||
required this.children,
|
||
this.badge,
|
||
this.action,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onToggle,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(title,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
),
|
||
if (badge != null) ...[badge!, const SizedBox(width: 8)],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (action != null) action!,
|
||
// The fold arrow belongs hard against the right edge, past the
|
||
// action, so a card with a header button is not the one whose
|
||
// arrow sits somewhere else. It folds the card like the heading.
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onToggle,
|
||
child: Icon(open ? Icons.expand_more : Icons.chevron_right,
|
||
size: 20, color: DriverVault.muted(context)),
|
||
),
|
||
],
|
||
),
|
||
if (open) ...children,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The green/amber "Connected"/"Not connected" style pill.
|
||
class _StatusBadge extends StatelessWidget {
|
||
final String label;
|
||
final bool ok;
|
||
const _StatusBadge({required this.label, required this.ok});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dark = DriverVault.isDark(context);
|
||
final fg = ok ? DriverVault.success : DriverVault.warning;
|
||
final bg = ok
|
||
? (dark ? DriverVault.successSoftDark : DriverVault.successSoft)
|
||
: (dark ? DriverVault.warningSoftDark : DriverVault.warningSoft);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(999)),
|
||
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A neutral pill, for a fact rather than a state — which service a charger came
|
||
/// from, say.
|
||
class _NeutralBadge extends StatelessWidget {
|
||
final String label;
|
||
const _NeutralBadge({required this.label});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dark = DriverVault.isDark(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: dark ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(label,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: DriverVault.muted(context))),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- A station row, shared by both station lists -----------------------------
|
||
|
||
class _StationTile extends StatelessWidget {
|
||
final _Station station;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
final Color tone;
|
||
final String status;
|
||
const _StationTile({
|
||
required this.station,
|
||
required this.selected,
|
||
required this.onTap,
|
||
required this.tone,
|
||
required this.status,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: selected ? DriverVault.brandTint(context) : null,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)),
|
||
child: Icon(Icons.bolt, size: 18, color: tone),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(station.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 2),
|
||
Text("${station.dist} · ${station.kw} kW · ${station.conn}",
|
||
style: DriverVault.mono(context, size: 11, color: muted)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(status, style: DriverVault.mono(context, size: 11, weight: FontWeight.w500, color: tone)),
|
||
const SizedBox(height: 2),
|
||
Text(station.price, style: DriverVault.mono(context, size: 11, color: muted)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A section wrapper with an eyebrow header and a list of public stations.
|
||
class _StationList extends StatelessWidget {
|
||
final String heading;
|
||
final List<_Station> stations;
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
final String Function(_Station) statusFor;
|
||
const _StationList({
|
||
required this.heading,
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
required this.statusFor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return _Card(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(6, 6, 6, 8),
|
||
child: Text(
|
||
"$heading · ${t("charging.stations.count", params: {"n": stations.length}, n: stations.length)}",
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
|
||
.copyWith(letterSpacing: 1.4),
|
||
),
|
||
),
|
||
for (final s in stations)
|
||
_StationTile(
|
||
station: s,
|
||
selected: selected == s.id,
|
||
onTap: () => onSelect(s.id),
|
||
tone: toneColor(s.tone),
|
||
status: statusFor(s),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Public tab: discovery map + demo session + public station list ----------
|
||
|
||
class _PublicTab extends StatefulWidget {
|
||
final List<_Station> stations;
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
final String Function(_Station) statusFor;
|
||
const _PublicTab({
|
||
super.key,
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
required this.statusFor,
|
||
});
|
||
|
||
@override
|
||
State<_PublicTab> createState() => _PublicTabState();
|
||
}
|
||
|
||
class _PublicTabState extends State<_PublicTab> {
|
||
// The demo session card is presentational, so its running/stopped state is
|
||
// purely local.
|
||
bool _charging = true;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: [
|
||
_MapPanel(
|
||
stations: widget.stations,
|
||
selected: widget.selected,
|
||
onSelect: widget.onSelect,
|
||
toneColor: widget.toneColor,
|
||
),
|
||
const SizedBox(height: 16),
|
||
_SessionCard(charging: _charging, onStop: () => setState(() => _charging = false)),
|
||
const SizedBox(height: 16),
|
||
_StationList(
|
||
heading: t("charging.stations.heading"),
|
||
stations: widget.stations,
|
||
selected: widget.selected,
|
||
onSelect: widget.onSelect,
|
||
toneColor: widget.toneColor,
|
||
statusFor: widget.statusFor,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A stylized discovery map with a "you are here" marker and charger pins,
|
||
/// standing in for a real map the way the web's CSS grid panel does.
|
||
class _MapPanel extends StatelessWidget {
|
||
final List<_Station> stations;
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
const _MapPanel({
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
});
|
||
|
||
// Pin positions as fractions of the panel, mirroring the web's x/y percents.
|
||
static const _pos = {
|
||
"sc": Offset(0.47, 0.34),
|
||
"evgo": Offset(0.26, 0.60),
|
||
"cp": Offset(0.70, 0.64),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dark = DriverVault.isDark(context);
|
||
final base = dark ? DriverVault.darkSunken : DriverVault.ink25;
|
||
final grid = dark ? Colors.white.withValues(alpha: 0.04) : const Color(0x0B0F1E3D);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
child: Container(
|
||
height: 320,
|
||
decoration: BoxDecoration(
|
||
color: base,
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, c) {
|
||
final w = c.maxWidth, h = c.maxHeight;
|
||
return Stack(
|
||
children: [
|
||
// Grid lines.
|
||
CustomPaint(size: Size(w, h), painter: _GridPainter(grid)),
|
||
// Roads.
|
||
Positioned(left: 0, right: 0, top: h * 0.52, child: Container(height: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
|
||
Positioned(top: 0, bottom: 0, left: w * 0.58, child: Container(width: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
|
||
// Legend chip.
|
||
Positioned(
|
||
left: 12,
|
||
top: 12,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).cardColor.withValues(alpha: 0.9),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
const Icon(Icons.map_outlined, size: 13, color: DriverVault.brand600),
|
||
const SizedBox(width: 5),
|
||
Text(t("charging.liveMap"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
),
|
||
),
|
||
// You are here.
|
||
Positioned(
|
||
left: w * 0.5 - 20,
|
||
top: h * 0.5 - 20,
|
||
child: Tooltip(
|
||
message: t("charging.youAreHere"),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(color: DriverVault.brand600.withValues(alpha: 0.16), shape: BoxShape.circle),
|
||
child: Center(
|
||
child: Container(
|
||
width: 14,
|
||
height: 14,
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.brand600,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Charger pins.
|
||
for (final s in stations)
|
||
if (_pos[s.id] != null)
|
||
Positioned(
|
||
left: w * _pos[s.id]!.dx - (selected == s.id ? 18 : 14),
|
||
top: h * _pos[s.id]!.dy - (selected == s.id ? 36 : 28),
|
||
child: _Pin(
|
||
selected: selected == s.id,
|
||
color: toneColor(s.tone),
|
||
label: "${s.avail}/${s.total} · ${s.kw} kW",
|
||
onTap: () => onSelect(s.id),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Pin extends StatelessWidget {
|
||
final bool selected;
|
||
final Color color;
|
||
final String label;
|
||
final VoidCallback onTap;
|
||
const _Pin({required this.selected, required this.color, required this.label, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final size = selected ? 36.0 : 28.0;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (selected)
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).cardColor,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600)),
|
||
),
|
||
Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(999),
|
||
topRight: Radius.circular(999),
|
||
bottomRight: Radius.circular(999),
|
||
bottomLeft: Radius.circular(3),
|
||
),
|
||
),
|
||
child: Icon(Icons.bolt, color: Colors.white, size: selected ? 18 : 14),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Faint grid lines behind the map, echoing the web's repeating gradients.
|
||
class _GridPainter extends CustomPainter {
|
||
final Color color;
|
||
_GridPainter(this.color);
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final p = Paint()
|
||
..color = color
|
||
..strokeWidth = 1;
|
||
const step = 44.0;
|
||
for (double x = 0; x < size.width; x += step) {
|
||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||
}
|
||
for (double y = 0; y < size.height; y += step) {
|
||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _GridPainter old) => old.color != color;
|
||
}
|
||
|
||
/// The presentational "Charging now" session card (dark brand background).
|
||
class _SessionCard extends StatelessWidget {
|
||
final bool charging;
|
||
final VoidCallback onStop;
|
||
const _SessionCard({required this.charging, required this.onStop});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const from = 62, to = 80;
|
||
final metrics = [
|
||
(t("charging.session.rate"), "142 kW"),
|
||
(t("charging.session.added"), "+29 km"),
|
||
(t("charging.session.cost"), "5,80 €"),
|
||
(t("charging.session.done"), "~18 min"),
|
||
];
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.brand900,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
),
|
||
child: charging
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(width: 6, height: 6, decoration: const BoxDecoration(color: DriverVault.success, shape: BoxShape.circle)),
|
||
const SizedBox(width: 8),
|
||
Text("${t("charging.session.chargingNow")} · Model Y",
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
RichText(
|
||
text: TextSpan(
|
||
style: DriverVault.mono(context, size: 34, weight: FontWeight.w500, color: Colors.white),
|
||
children: [
|
||
const TextSpan(text: "$from"),
|
||
TextSpan(
|
||
text: " % → $to%",
|
||
style: DriverVault.mono(context, size: 15, weight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.7)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: LinearProgressIndicator(
|
||
value: from / 100,
|
||
minHeight: 8,
|
||
backgroundColor: Colors.white.withValues(alpha: 0.15),
|
||
valueColor: const AlwaysStoppedAnimation(DriverVault.brand400),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
for (final m in metrics)
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(m.$1,
|
||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.2)),
|
||
const SizedBox(height: 2),
|
||
Text(m.$2, style: DriverVault.mono(context, size: 14, weight: FontWeight.w500, color: Colors.white)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: onStop,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: Colors.white,
|
||
backgroundColor: Colors.white.withValues(alpha: 0.10),
|
||
side: BorderSide(color: Colors.white.withValues(alpha: 0.20)),
|
||
),
|
||
child: Text(t("charging.session.stop")),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.4), shape: BoxShape.circle)),
|
||
const SizedBox(width: 8),
|
||
Text(t("charging.session.idle"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Text(t("charging.session.idleHint"),
|
||
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Home tab: the user's own chargers + real control ------------------------
|
||
|
||
/// The home half of the page: the cards that act on one charger, in whatever
|
||
/// order the account arranged them, and below them the list of chargers the user
|
||
/// has imported.
|
||
///
|
||
/// One State for the lot, mirroring the web view: the cards all read the same
|
||
/// charger, and picking one in the list is what points the control cards at it.
|
||
class _HomeTab extends StatefulWidget {
|
||
final List<String> cardKeys;
|
||
|
||
/// Whether this is the tab being read. Reachability costs a round trip to each
|
||
/// connected service, so it is asked for when the question is actually being
|
||
/// asked — not because an IndexedStack built both tabs.
|
||
final bool active;
|
||
|
||
const _HomeTab({super.key, required this.cardKeys, required this.active});
|
||
@override
|
||
State<_HomeTab> createState() => _HomeTabState();
|
||
}
|
||
|
||
class _HomeTabState extends State<_HomeTab> {
|
||
// --- control ---
|
||
final _serial = TextEditingController();
|
||
final _host = TextEditingController();
|
||
final _port = TextEditingController(text: "502");
|
||
final _resetPassword = TextEditingController();
|
||
|
||
/// The chargers on the linked Anker account. With them the serial is a pick
|
||
/// from a list; without them (account not linked, or the cloud unreachable)
|
||
/// the field stays a plain text box so a serial can still be typed by hand.
|
||
List<AnkerCharger> _accountChargers = const [];
|
||
|
||
/// Typing the serial rather than picking it. A dropdown can only show a serial
|
||
/// it has an option for, so a remembered serial the account does not report —
|
||
/// a charger imported before the account was linked, one the cloud is quiet
|
||
/// about today — would render as a blank field with no way to read or fix it.
|
||
/// Falling back to the text box shows the serial that is actually in force.
|
||
bool _manualSerial = false;
|
||
|
||
String _mode = "off"; // effective control mode (off | mqtt | modbus | own | proxy)
|
||
AnkerControl? _ctl;
|
||
String? _ctlError;
|
||
String _busy = ""; // action name currently in flight
|
||
double _limitAmps = 16;
|
||
bool _resetPrompt = false;
|
||
bool _savingAddress = false;
|
||
|
||
// --- the user's own chargers ---
|
||
List<HomeCharger> _homeChargers = const [];
|
||
String? _homeError;
|
||
|
||
/// The charger services this user could import from. Importing only makes
|
||
/// sense once one is connected, so the button appears only then — the same
|
||
/// rule the garage's import follows — and the list doubles as the id → label
|
||
/// map the information card names a charger's origin with.
|
||
List<ChargerProvider> _providers = const [];
|
||
|
||
/// Live reachability, keyed by the provider's own id for the charger. The
|
||
/// record says what a charger is; only the service it came from knows whether
|
||
/// it is reachable right now, so that half is asked for separately and held
|
||
/// beside the records rather than in them.
|
||
Map<String, ProviderCharger> _live = const {};
|
||
bool _liveLoading = false;
|
||
bool _liveLoaded = false;
|
||
|
||
String _selected = ""; // the charger the cards are about
|
||
String _removing = "";
|
||
|
||
/// The account's per-charger views, kept by serial: four endpoints answer only
|
||
/// when a serial is named, so they are asked for the charger being looked at
|
||
/// and remembered, rather than asked again on every glance.
|
||
final Map<String, ChargerDetails> _details = {};
|
||
String _detailsLoading = "";
|
||
|
||
/// Which cards are folded, read once and written on every toggle.
|
||
Set<String> _collapsed = <String>{};
|
||
|
||
bool get _active => _mode != "off";
|
||
bool get _connected => _ctl?.connected == true;
|
||
bool get _isModbus => _mode == "modbus";
|
||
|
||
/// The Anker cloud path: both ends meet at the broker the charger already
|
||
/// talks to, so there is nothing to address and nothing to install — only an
|
||
/// account to be signed in to. It is the mode for a charger somewhere else.
|
||
bool get _isCloud => _mode == "mqtt";
|
||
|
||
/// Those two read the charger itself and answer with its own snapshot, where
|
||
/// OCPP answers with the session our CSMS holds. Anything both can report is
|
||
/// named the same in both, so one set of readouts serves them; what each can
|
||
/// be told still differs, which is what the buttons below branch on.
|
||
bool get _readsDevice => _isModbus || _isCloud;
|
||
|
||
/// The charger's snapshot, whichever transport read it.
|
||
ChargerStatus? get _dev => _ctl?.device;
|
||
bool get _canImport => _providers.any((p) => p.connected);
|
||
|
||
bool get _serialInList =>
|
||
_accountChargers.any((c) => c.sn == _serial.text.trim());
|
||
|
||
/// A dropdown can only show a serial it has an option for, so one the account
|
||
/// does not report — a charger picked from the list below, imported before the
|
||
/// account was linked — falls back to the text box rather than rendering as a
|
||
/// blank field with no way to read or fix it.
|
||
bool get _pickingFromList =>
|
||
_accountChargers.isNotEmpty && !_manualSerial && (_serial.text.trim().isEmpty || _serialInList);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_init();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_HomeTab old) {
|
||
super.didUpdateWidget(old);
|
||
// Switching to this tab is the moment reachability is being asked about.
|
||
if (widget.active && !old.active) _loadLive();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_serial.dispose();
|
||
_host.dispose();
|
||
_port.dispose();
|
||
_resetPassword.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (!mounted) return;
|
||
_serial.text = prefs.getString(_kSerialKey) ?? "";
|
||
setState(() => _collapsed = (prefs.getStringList(_kCollapsedKey) ?? const []).toSet());
|
||
|
||
await _loadHomeChargers();
|
||
await _loadProviders();
|
||
if (widget.active) await _loadLive();
|
||
await _loadMode();
|
||
if (_active) await _loadAccountChargers();
|
||
await _refresh();
|
||
}
|
||
|
||
// --- folding ---
|
||
|
||
bool _isOpen(String id) => !_collapsed.contains(id);
|
||
|
||
Future<void> _toggleCard(String id) async {
|
||
setState(() {
|
||
if (_collapsed.contains(id)) {
|
||
_collapsed.remove(id);
|
||
} else {
|
||
_collapsed.add(id);
|
||
}
|
||
});
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setStringList(_kCollapsedKey, _collapsed.toList());
|
||
}
|
||
|
||
// --- loading ---
|
||
|
||
Future<void> _loadMode() async {
|
||
try {
|
||
final v = await apiClient.getAnkerSolix();
|
||
if (mounted) setState(() => _mode = v.controlMode);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _mode = "off");
|
||
}
|
||
}
|
||
|
||
Future<void> _loadAccountChargers() async {
|
||
try {
|
||
final res = await apiClient.listAnkerChargers();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_accountChargers = res.chargers;
|
||
// Nothing chosen yet: start on the first charger the account reports.
|
||
if (_serial.text.trim().isEmpty && _accountChargers.isNotEmpty) {
|
||
_serial.text = _accountChargers.first.sn;
|
||
}
|
||
// Decided when the list arrives rather than on every keystroke:
|
||
// recomputing it as the serial is typed would swap the text box for a
|
||
// dropdown mid-word, the moment what had been typed happened to match.
|
||
_manualSerial = _accountChargers.isNotEmpty && !_serialInList;
|
||
});
|
||
} catch (_) {
|
||
if (mounted) setState(() => _accountChargers = const []);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadHomeChargers() async {
|
||
try {
|
||
final list = await apiClient.listHomeChargers();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_homeChargers = list;
|
||
_homeError = null;
|
||
if (_selected.isEmpty && list.isNotEmpty) _selected = list.first.id;
|
||
});
|
||
} catch (e) {
|
||
if (mounted) setState(() => _homeError = "$e");
|
||
}
|
||
}
|
||
|
||
Future<void> _loadProviders() async {
|
||
try {
|
||
final list = await apiClient.listChargerProviders();
|
||
if (mounted) setState(() => _providers = list);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _providers = const []);
|
||
}
|
||
}
|
||
|
||
/// Asking costs a round trip to each connected service, so it happens when the
|
||
/// home tab is first built — the moment the question is being asked — and on
|
||
/// demand after that.
|
||
Future<void> _loadLive({bool force = false}) async {
|
||
final connected = _providers.where((p) => p.connected).toList();
|
||
if (_liveLoading || connected.isEmpty || !mounted) return;
|
||
if (_liveLoaded && !force) return; // switching tabs is not a new question
|
||
setState(() => _liveLoading = true);
|
||
final live = <String, ProviderCharger>{};
|
||
await Future.wait(connected.map((p) async {
|
||
try {
|
||
final res = await apiClient.listProviderChargers(p.id);
|
||
for (final c in res.chargers) {
|
||
live[c.id] = c;
|
||
}
|
||
} catch (_) {
|
||
// A service that will not answer leaves its chargers unknown rather
|
||
// than offline — this page cannot tell those two apart.
|
||
}
|
||
}));
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_live = live;
|
||
_liveLoaded = true;
|
||
_liveLoading = false;
|
||
});
|
||
// The card's other half: the views that answer per charger rather than per
|
||
// account. Refreshing the card refreshes both.
|
||
_loadDetails(force: force);
|
||
}
|
||
|
||
/// The per-charger views for the charger on screen. A view the account cannot
|
||
/// read is not an error to put on the page — the rows above still say
|
||
/// everything the inventory knew.
|
||
Future<void> _loadDetails({bool force = false}) async {
|
||
final c = _selectedCharger;
|
||
final sn = c == null
|
||
? ""
|
||
: (c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial);
|
||
if (sn.isEmpty || c!.provider != "anker-solix") return;
|
||
if (_detailsLoading == sn || (_details.containsKey(sn) && !force)) return;
|
||
_detailsLoading = sn;
|
||
try {
|
||
final res = await apiClient.getAnkerChargerDetails(sn);
|
||
if (mounted) setState(() => _details[sn] = res);
|
||
} catch (_) {
|
||
// Left absent rather than shown as a failure.
|
||
} finally {
|
||
_detailsLoading = "";
|
||
}
|
||
}
|
||
|
||
/// The views to draw for the charger on screen, in the order the account
|
||
/// answered them.
|
||
List<ChargerDetailView> _detailViews(HomeCharger c) {
|
||
final sn = c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial;
|
||
return _details[sn]?.views ?? const [];
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
if (!mounted) return;
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) {
|
||
setState(() => _ctl = null);
|
||
return;
|
||
}
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(_kSerialKey, sn);
|
||
if (!mounted) return;
|
||
setState(() => _ctlError = null);
|
||
try {
|
||
final c = await apiClient.getAnkerControl(sn);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_ctl = c;
|
||
// The saved address belongs to the charger, not to the form: switching
|
||
// chargers has to bring its own address along rather than leave the
|
||
// previous one sitting in the field, where saving would move it to the
|
||
// wrong charger.
|
||
_host.text = c.modbusHost;
|
||
_port.text = "${c.modbusPort}";
|
||
});
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_ctlError = "$e";
|
||
_ctl = null;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- acting on the charger ---
|
||
|
||
Future<void> _action(String action, [Map<String, dynamic> body = const {}]) async {
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) return;
|
||
setState(() {
|
||
_busy = action;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlAction(sn, action, body);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _busy = "");
|
||
}
|
||
}
|
||
|
||
Future<void> _confirmReset() async {
|
||
if (_resetPassword.text.isEmpty) return;
|
||
setState(() => _resetPrompt = false);
|
||
await _action("reset", {"hard": false, "confirm": true, "password": _resetPassword.text});
|
||
_resetPassword.clear();
|
||
}
|
||
|
||
/// One control for both directions, so a serial that is not on the account is
|
||
/// never a dead end and the list is never the only option.
|
||
void _toggleSerialEntry() {
|
||
final toList = !_pickingFromList;
|
||
setState(() => _manualSerial = !toList);
|
||
// Returning to a list that does not hold this serial would blank the
|
||
// dropdown again, which is the thing being fixed; land on one it does hold.
|
||
if (toList && !_serialInList && _accountChargers.isNotEmpty) {
|
||
_serial.text = _accountChargers.first.sn;
|
||
_refresh();
|
||
}
|
||
}
|
||
|
||
Future<void> _saveAddress() async {
|
||
final sn = _serial.text.trim();
|
||
final host = _host.text.trim();
|
||
if (sn.isEmpty || host.isEmpty) return;
|
||
setState(() {
|
||
_savingAddress = true;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlAddress(sn, host, int.tryParse(_port.text.trim()) ?? 502);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _savingAddress = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _forgetAddress() async {
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) return;
|
||
if (!await _confirm(t("charging.control.forgetAddressConfirm"))) return;
|
||
setState(() {
|
||
_savingAddress = true;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlForgetAddress(sn);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _savingAddress = false);
|
||
}
|
||
}
|
||
|
||
Future<bool> _confirm(String message) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
content: Text(message),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(t("common.confirm")),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
return ok == true;
|
||
}
|
||
|
||
// --- the user's own chargers ---
|
||
|
||
/// The live half for one imported charger, or null when the service it came
|
||
/// from says nothing about it (disconnected since the import, or a charger
|
||
/// added by hand). Both services we speak to id a charger by its serial, so
|
||
/// the serial is a sound fallback for a record imported before the provider
|
||
/// link was stored.
|
||
ProviderCharger? _liveFor(HomeCharger c) =>
|
||
_live[c.providerChargerId] ?? _live[c.serial];
|
||
|
||
/// The card shows one charger: whichever is picked in the list below it. Until
|
||
/// something is picked that is the first one — a card that says nothing until
|
||
/// tapped would be a worse first impression than the charger most people have
|
||
/// only one of.
|
||
HomeCharger? get _selectedCharger {
|
||
for (final c in _homeChargers) {
|
||
if (c.id == _selected) return c;
|
||
}
|
||
return _homeChargers.isEmpty ? null : _homeChargers.first;
|
||
}
|
||
|
||
/// A charger picked here becomes the one the control cards drive.
|
||
void _select(HomeCharger c) {
|
||
setState(() => _selected = c.id);
|
||
_loadDetails();
|
||
if (c.serial.isEmpty) return;
|
||
_serial.text = c.serial;
|
||
_refresh();
|
||
}
|
||
|
||
Future<void> _import() async {
|
||
final charger = await showChargerImportSheet(context);
|
||
if (charger == null || !mounted) return;
|
||
setState(() => _homeChargers = [..._homeChargers, charger]);
|
||
_select(charger);
|
||
_loadLive(force: true);
|
||
}
|
||
|
||
Future<void> _remove(HomeCharger c) async {
|
||
if (!await _confirm(t("charging.home.removeConfirm", params: {"name": c.name}))) return;
|
||
setState(() {
|
||
_removing = c.id;
|
||
_homeError = null;
|
||
});
|
||
try {
|
||
await apiClient.deleteHomeCharger(c.id);
|
||
if (mounted) {
|
||
setState(() => _homeChargers = _homeChargers.where((x) => x.id != c.id).toList());
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() => _homeError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _removing = "");
|
||
}
|
||
}
|
||
|
||
String _providerLabel(String id) {
|
||
for (final p in _providers) {
|
||
if (p.id == id) return p.label;
|
||
}
|
||
return id;
|
||
}
|
||
|
||
/// The cloud's own slug for what the charger is doing (charging, standby, …),
|
||
/// translated. The vocabulary is the integration's, so the wording lives with
|
||
/// it in Settings rather than being said twice.
|
||
String _stateLabel(String slug) {
|
||
if (slug.isEmpty) return "";
|
||
final key = "settings.integrations.states.$slug";
|
||
final label = t(key);
|
||
return label == key ? slug.replaceAll("_", " ") : label;
|
||
}
|
||
|
||
/// How the charger is registered on the account, in the service's own terms.
|
||
String _sourcesLabel(List<String> sources) => sources.map((src) {
|
||
final key = "charging.info.sourceNames.$src";
|
||
final label = t(key);
|
||
return label == key ? src : label;
|
||
}).join(" · ");
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cards = <String, Widget?>{
|
||
"control": _active && _connected ? _controlCard(context) : null,
|
||
"connection": _active ? _connectionCard(context) : null,
|
||
"readings": _readsDevice && _connected ? _readingsCard(context) : null,
|
||
"info": _infoCard(context),
|
||
};
|
||
|
||
return Column(
|
||
children: [
|
||
for (final key in widget.cardKeys)
|
||
if (cards[key] != null) ...[cards[key]!, const SizedBox(height: 16)],
|
||
_chargerListCard(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
// --- the cards -------------------------------------------------------------
|
||
|
||
/// Acting on the charger. It appears only once there is a connection to act
|
||
/// over, which the card below it is where you set up.
|
||
Widget _controlCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return _FoldCard(
|
||
title: t("charging.control.title"),
|
||
open: _isOpen("control"),
|
||
onToggle: () => _toggleCard("control"),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh",
|
||
label: t("charging.control.meter"),
|
||
),
|
||
),
|
||
]),
|
||
// A charger told to start can sit in "preparing" for a good while, and
|
||
// these say why: it is waiting for a plug, or counting down a start
|
||
// delay. Only the cloud transport can see them.
|
||
if (_countdown("plugCountdownSeconds") != null ||
|
||
_countdown("startCountdownSeconds") != null) ...[
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
if (_countdown("plugCountdownSeconds") != null)
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: _countdown("plugCountdownSeconds")!,
|
||
label: t("charging.modbus.plugCountdown"),
|
||
),
|
||
),
|
||
if (_countdown("plugCountdownSeconds") != null &&
|
||
_countdown("startCountdownSeconds") != null)
|
||
const SizedBox(width: 8),
|
||
if (_countdown("startCountdownSeconds") != null)
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: _countdown("startCountdownSeconds")!,
|
||
label: t("charging.modbus.startCountdown"),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: _busy == "start" ? null : () => _action("start"),
|
||
child: Text(t("charging.control.start")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "stop" ? null : () => _action("stop"),
|
||
child: Text(t("charging.control.stop")),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)),
|
||
const Spacer(),
|
||
Text("${_limitAmps.round()} A",
|
||
style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)),
|
||
]),
|
||
Slider(
|
||
value: _limitAmps,
|
||
min: 6,
|
||
max: 32,
|
||
divisions: 26,
|
||
label: "${_limitAmps.round()} A",
|
||
onChanged: (v) => setState(() => _limitAmps = v),
|
||
),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}),
|
||
child: Text(t("charging.control.applyLimit")),
|
||
),
|
||
),
|
||
// Clearing a limit is an OCPP command. Both of the transports that
|
||
// talk to the charger itself take an explicit ceiling, so there is
|
||
// nothing for them to clear to.
|
||
if (!_readsDevice) ...[
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"),
|
||
child: Text(t("charging.control.clearLimit")),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
|
||
// Boost lasts for the current session only, and is a command the
|
||
// charger itself takes — over the register map or over the cloud, but
|
||
// never over OCPP.
|
||
if (_readsDevice) ...[
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "boost" ? null : () => _action("boost", {"on": true}),
|
||
child: Text(t("charging.control.boost")),
|
||
),
|
||
),
|
||
],
|
||
|
||
// Skipping a start delay is only offered while one is running, and only
|
||
// the cloud transport knows that it is.
|
||
if (_dev?.modeOptions.contains("skip_delay") ?? false) ...[
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "skip-delay" ? null : () => _action("skip-delay"),
|
||
child: Text(t("charging.control.skipDelay")),
|
||
),
|
||
),
|
||
],
|
||
|
||
// Reset reboots the charger over OCPP; neither the register map nor the
|
||
// cloud has an equivalent, so the button is not offered there.
|
||
if (!_readsDevice) ...[
|
||
const SizedBox(height: 12),
|
||
if (!_resetPrompt)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "reset"
|
||
? null
|
||
: () => setState(() {
|
||
_ctlError = null;
|
||
_resetPassword.clear();
|
||
_resetPrompt = true;
|
||
}),
|
||
child: Text(t("charging.control.reset")),
|
||
),
|
||
)
|
||
else
|
||
// Step-up: a destructive reset requires re-entering the password.
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.resetConfirm"),
|
||
style: const TextStyle(
|
||
fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _resetPassword,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.resetPassword"),
|
||
),
|
||
onSubmitted: (_) => _confirmReset(),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => setState(() {
|
||
_resetPrompt = false;
|
||
_resetPassword.clear();
|
||
}),
|
||
child: Text(t("common.cancel")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||
onPressed: _busy == "reset" ? null : _confirmReset,
|
||
child: Text(t("charging.control.reset")),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Which charger, and how to reach it. Below the controls: it is touched once
|
||
/// and then left alone.
|
||
Widget _connectionCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final sn = _serial.text.trim();
|
||
return _FoldCard(
|
||
title: t("charging.control.connectionTitle"),
|
||
open: _isOpen("connection"),
|
||
onToggle: () => _toggleCard("connection"),
|
||
// Whether the charger is reachable is the one thing worth seeing with the
|
||
// card folded, so the badge rides in the header.
|
||
badge: _StatusBadge(
|
||
label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"),
|
||
ok: _connected,
|
||
),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(
|
||
child: _pickingFromList
|
||
? DropdownButtonFormField<String>(
|
||
// initialValue is read once, so the field is rebuilt when the
|
||
// serial changes from elsewhere — picking a charger below.
|
||
key: ValueKey(sn),
|
||
initialValue: _serialInList ? sn : null,
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||
items: [
|
||
for (final c in _accountChargers)
|
||
DropdownMenuItem(
|
||
value: c.sn,
|
||
child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn,
|
||
overflow: TextOverflow.ellipsis),
|
||
),
|
||
],
|
||
onChanged: (picked) {
|
||
if (picked == null) return;
|
||
setState(() => _serial.text = picked);
|
||
_refresh();
|
||
},
|
||
)
|
||
: TextField(
|
||
controller: _serial,
|
||
autocorrect: false,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.serialPlaceholder"),
|
||
),
|
||
onSubmitted: (_) => _refresh(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))),
|
||
]),
|
||
|
||
// Only worth offering when there is a list to switch to or from.
|
||
if (_accountChargers.isNotEmpty)
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton(
|
||
onPressed: _toggleSerialEntry,
|
||
child: Text(
|
||
_pickingFromList
|
||
? t("charging.control.enterSerial")
|
||
: t("charging.control.pickSerial"),
|
||
style: TextStyle(fontSize: 12, color: muted),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Modbus mode dials the charger, so it needs the charger's address on
|
||
// this network rather than a token installed into the charger.
|
||
if (_isModbus) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.address"),
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _host,
|
||
autocorrect: false,
|
||
keyboardType: TextInputType.url,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.addressPlaceholder"),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
onSubmitted: (_) => _saveAddress(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 92,
|
||
child: TextField(
|
||
controller: _port,
|
||
keyboardType: TextInputType.number,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
labelText: t("charging.control.addressPort"),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Text(t("charging.control.addressHint"),
|
||
style: TextStyle(fontSize: 11, color: muted)),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: _host.text.trim().isEmpty || !_addressChanged || _savingAddress
|
||
? null
|
||
: _saveAddress,
|
||
child: Text(t("charging.control.saveAddress")),
|
||
),
|
||
),
|
||
if ((_ctl?.modbusHost ?? "").isNotEmpty) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton(
|
||
onPressed: _savingAddress ? null : _forgetAddress,
|
||
child: Text(t("charging.control.forgetAddress")),
|
||
),
|
||
],
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// The cloud path has nothing to set up: the account is the credential,
|
||
// and it is entered in Settings. What it does have to say is what the
|
||
// charger reports about its own local side, which is the address the
|
||
// Modbus mode would otherwise have to be told.
|
||
if (_isCloud) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.cloudNote"), style: TextStyle(fontSize: 11, color: muted)),
|
||
if (_localAccess != null) ...[
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
t("charging.control.cloudLocalFound", params: {"address": _localAccess!}),
|
||
style: DriverVault.mono(context, size: 11),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// Why there is nothing to control yet. Reading the charger directly, the
|
||
// server has already tried and says what it found, which beats a generic
|
||
// hint.
|
||
if (!_connected)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Text(
|
||
(_ctl?.detail.isNotEmpty ?? false) ? _ctl!.detail : t(_hintKey),
|
||
style: TextStyle(fontSize: 12, color: muted),
|
||
),
|
||
),
|
||
if (_ctlError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(_ctlError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Saving is only worth offering when there is something to save that is not
|
||
/// already saved, so the button goes quiet once the form matches the server.
|
||
bool get _addressChanged =>
|
||
_host.text.trim() != (_ctl?.modbusHost ?? "") ||
|
||
(int.tryParse(_port.text.trim()) ?? 502) != (_ctl?.modbusPort ?? 502);
|
||
|
||
/// A countdown the charger is running, as minutes and seconds. Only shown
|
||
/// while it is actually running: zero is not a countdown, it is the absence
|
||
/// of one.
|
||
String? _countdown(String key) {
|
||
final v = _dev?.integer(key);
|
||
if (v == null || v <= 0) return null;
|
||
final m = v ~/ 60;
|
||
return m > 0 ? "$m min ${v % 60} s" : "$v s";
|
||
}
|
||
|
||
/// The charger's own local address, when the cloud snapshot carries it: the
|
||
/// address the Modbus mode has to be given by hand, discovered instead.
|
||
String? get _localAccess {
|
||
final local = _dev?.local ?? const {};
|
||
final host = local["host"];
|
||
if (local["modbusEnabled"] != true || host is! String || host.isEmpty) return null;
|
||
final port = local["port"];
|
||
return port is num && port != 502 ? "$host:${port.toInt()}" : host;
|
||
}
|
||
|
||
/// Why nothing is connected yet, in the terms of the transport in force.
|
||
String get _hintKey {
|
||
if (_isModbus) return "charging.control.modbusHint";
|
||
if (_isCloud) return "charging.control.cloudHint";
|
||
return "charging.control.connectHint";
|
||
}
|
||
|
||
/// What the charger reports about itself, over whichever transport reads it.
|
||
/// Its own card rather than a tail on the control one: control is for acting
|
||
/// on the charger, and this is a long read that pushed the buttons off screen.
|
||
Widget _readingsCard(BuildContext context) {
|
||
final s = _dev;
|
||
return _FoldCard(
|
||
title: t("charging.modbus.title"),
|
||
open: _isOpen("readings"),
|
||
onToggle: () => _toggleCard("readings"),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
if (s != null) ..._readingSections(context, s),
|
||
],
|
||
);
|
||
}
|
||
|
||
List<Widget> _readingSections(BuildContext context, ChargerStatus s) {
|
||
final phases = _phaseRows(s);
|
||
final live = _liveRows(s);
|
||
final settings = _settingRows(s);
|
||
final local = _localRows(s);
|
||
final device = _deviceRows(s);
|
||
final extra = _extraRows(s);
|
||
final alarms = _alarmWords(s);
|
||
|
||
return [
|
||
if (phases.isNotEmpty) ...[
|
||
_ReadingSection(
|
||
heading: t("charging.modbus.phases"),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Three phases against five measurements is a matrix, not a list;
|
||
// a table says that. It scrolls sideways rather than wrapping,
|
||
// which would break the columns apart.
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Table(
|
||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||
children: [
|
||
TableRow(children: [
|
||
_th(context, t("charging.modbus.phase"), left: true),
|
||
_th(context, t("charging.modbus.voltage")),
|
||
_th(context, t("charging.modbus.current")),
|
||
_th(context, t("charging.modbus.activePower")),
|
||
if (_phasesHaveVA(s)) _th(context, t("charging.modbus.reactivePower")),
|
||
if (_phasesHaveVA(s)) _th(context, t("charging.modbus.apparentPower")),
|
||
if (_phasesHaveSessionWh(s)) _th(context, t("charging.modbus.sessionEnergy")),
|
||
]),
|
||
for (final row in phases)
|
||
TableRow(children: [
|
||
_td(context, row[0], left: true),
|
||
for (var i = 1; i < row.length; i++) _td(context, row[i]),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
if (_lineVoltages(s).isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
"${t("charging.modbus.lineToLine")}: ${_lineVoltages(s).join(" · ")}",
|
||
style: DriverVault.mono(context, size: 11, color: DriverVault.muted(context)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (live.isNotEmpty) ...[
|
||
_ReadingSection(heading: t("charging.modbus.live"), child: _PairList(rows: live)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (settings.isNotEmpty) ...[
|
||
_ReadingSection(heading: t("charging.modbus.settings"), child: _PairList(rows: settings)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
// The charger's own LAN side, which only the cloud transport can report:
|
||
// whether its Modbus server is on, and where.
|
||
if (local.isNotEmpty) ...[
|
||
_ReadingSection(heading: t("charging.modbus.localTitle"), child: _PairList(rows: local)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (device.isNotEmpty) ...[
|
||
_ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
// What the charger sends beyond what we model. It appears only when there
|
||
// is something in it, so a transport that reports nothing unnamed draws no
|
||
// empty block.
|
||
if (extra.isNotEmpty) ...[
|
||
_ReadingSection(
|
||
heading: t("charging.modbus.extra"),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_PairList(rows: extra, breakLong: true),
|
||
const SizedBox(height: 6),
|
||
Text(t("charging.modbus.extraHint"),
|
||
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
// Alarms, when any word is non-zero. Which register is set is reportable
|
||
// even though the bit list is not published.
|
||
if (alarms.isNotEmpty)
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
border: Border.all(color: DriverVault.warning.withValues(alpha: 0.4)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.modbus.alarms"),
|
||
style: DriverVault.mono(context,
|
||
size: 10, weight: FontWeight.w600, color: DriverVault.warning)
|
||
.copyWith(letterSpacing: 1.4)),
|
||
const SizedBox(height: 6),
|
||
Text(alarms.join(" "), style: DriverVault.mono(context, size: 12)),
|
||
const SizedBox(height: 4),
|
||
Text(t("charging.modbus.alarmsHint"),
|
||
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
Widget _th(BuildContext context, String label, {bool left = false}) => Padding(
|
||
padding: const EdgeInsets.fromLTRB(0, 2, 12, 4),
|
||
child: Text(label,
|
||
textAlign: left ? TextAlign.left : TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: 11, fontWeight: FontWeight.w500, color: DriverVault.muted(context))),
|
||
);
|
||
|
||
Widget _td(BuildContext context, String value, {bool left = false}) => Padding(
|
||
padding: const EdgeInsets.fromLTRB(0, 3, 12, 3),
|
||
child: Text(value,
|
||
textAlign: left ? TextAlign.left : TextAlign.right,
|
||
style: DriverVault.mono(context,
|
||
size: 12, color: left ? DriverVault.muted(context) : null)),
|
||
);
|
||
|
||
// --- reading the snapshot ---
|
||
//
|
||
// The local path reports far more than the OCPP one: one poll carries
|
||
// metering, the control settings and the charger's identity. Shown as a flat
|
||
// list that would be a wall of forty numbers, so it is sorted the way it gets
|
||
// asked about — what the charger is doing, what it is set to, and what it is.
|
||
//
|
||
// Pairs with no value drop out: a charger on older firmware, or one that
|
||
// refused the control block, should show a shorter list rather than a column
|
||
// of dashes.
|
||
|
||
String? _unit(num? v, int digits, String unit) =>
|
||
v == null ? null : "${v.toStringAsFixed(digits)} $unit";
|
||
|
||
String? _yesNo(bool? v) => v == null ? null : (v ? t("common.yes") : t("common.no"));
|
||
|
||
/// An enum the charger reports as a number, named through the catalogue so it
|
||
/// translates; an unlisted value falls back to the number rather than a blank.
|
||
String? _enumLabel(String prefix, int? v) {
|
||
if (v == null) return null;
|
||
final key = "charging.modbus.$prefix$v";
|
||
final label = t(key);
|
||
return label == key ? "$v" : label;
|
||
}
|
||
|
||
/// The operational mode the charger is in, in the integration's own
|
||
/// vocabulary. Only the cloud transport derives it.
|
||
String? _modeLabel(String slug) {
|
||
if (slug.isEmpty) return null;
|
||
final key = "settings.integrations.modes.$slug";
|
||
final label = t(key);
|
||
return label == key ? slug.replaceAll("_", " ") : label;
|
||
}
|
||
|
||
/// The charging window the charger's schedule allows, when it reports one.
|
||
String? _window(ChargerStatus s) {
|
||
final from = s.settings["weekStart"];
|
||
final to = s.settings["weekEnd"];
|
||
if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null;
|
||
return "$from–$to";
|
||
}
|
||
|
||
String? _sessionLength(int? seconds) {
|
||
if (seconds == null) return null;
|
||
final h = seconds ~/ 3600;
|
||
final m = (seconds % 3600) ~/ 60;
|
||
return h > 0 ? "$h h $m min" : "$m min";
|
||
}
|
||
|
||
List<(String, String)> _rows(List<(String, String?)> pairs) => [
|
||
for (final (key, value) in pairs)
|
||
if (value != null && value.isNotEmpty) (t("charging.modbus.$key"), value),
|
||
];
|
||
|
||
List<(String, String)> _liveRows(ChargerStatus s) {
|
||
final r1 = s.number("relay1TempC");
|
||
final r2 = s.number("relay2TempC");
|
||
return _rows([
|
||
("mode", _modeLabel(s.text("mode"))),
|
||
("power", _unit(s.number("powerTotal"), 0, "W")),
|
||
("sessionDuration", _sessionLength(s.integer("sessionSeconds"))),
|
||
("plugCountdown", _countdown("plugCountdownSeconds")),
|
||
("startCountdown", _countdown("startCountdownSeconds")),
|
||
("cpSignal", s.text("cpSignalDesc")),
|
||
("cpVoltage", _unit(s.number("cpVoltage"), 2, "V")),
|
||
("phaseMode", _enumLabel("phaseMode", s.integer("phaseMode"))),
|
||
(
|
||
"relayTemps",
|
||
r1 != null && r2 != null
|
||
? "${r1.toStringAsFixed(1)} / ${r2.toStringAsFixed(1)} °C"
|
||
: _unit(r1, 1, "°C")
|
||
),
|
||
("pwm", _yesNo(s.flag("pwmEnabled"))),
|
||
("plugged", _yesNo(s.flag("plugged"))),
|
||
// Where the charge is coming from. The reference marks this reading
|
||
// uncertain, and an unlisted value falls back to its number rather than
|
||
// borrowing the name of a neighbouring one.
|
||
("chargingSource", _enumLabel("chargingSource", s.integer("chargingSource"))),
|
||
("chargingWindow", _sessionLength(s.integer("chargingWindowSeconds"))),
|
||
("sessionStarted", _unixTime(s.number("sessionStartedAt"))),
|
||
("orderId", _plain(s.integer("orderId"))),
|
||
// Two streams, two clocks: telemetry flows only inside a trigger window,
|
||
// the settings arrive with a command. A reading is worth as much as its
|
||
// age, so each half says when it last spoke.
|
||
("liveStream", _yesNo(s.flag("live"))),
|
||
("telemetryAt", _stamp(s.text("telemetryAt"))),
|
||
("settingsAt", _stamp(s.text("settingsAt"))),
|
||
]);
|
||
}
|
||
|
||
String? _plain(int? v) => v == null ? null : "$v";
|
||
|
||
/// A cloud timestamp, as the charger sends it: an ISO instant on the two
|
||
/// stream clocks, whole unix seconds on the session's start.
|
||
String? _stamp(String iso) =>
|
||
iso.isEmpty ? null : formatDateTime(DateTime.tryParse(iso)?.toLocal());
|
||
|
||
String? _unixTime(double? seconds) => seconds == null || seconds <= 0
|
||
? null
|
||
: formatDateTime(DateTime.fromMillisecondsSinceEpoch((seconds * 1000).round()));
|
||
|
||
List<(String, String)> _settingRows(ChargerStatus s) {
|
||
final timeout = s.settingInt("timeoutSeconds");
|
||
final led = s.integer("ledBrightness");
|
||
return _rows([
|
||
("maxCurrentSet", _unit(s.setting("maxCurrentA"), 1, "A")),
|
||
("timeout", timeout == null ? null : "$timeout s"),
|
||
("phaseSetting", _enumLabel("phaseSet", s.settingInt("phaseSetting"))),
|
||
// The transports name the same thing differently: the control block has a
|
||
// boost register that was written, the cloud reports a boost that is
|
||
// running. Either answers "is it boosting".
|
||
("boostSet", _yesNo(s.settingFlag("boost") ?? s.flag("boostMode"))),
|
||
("autoStart", _yesNo(s.settingFlag("autoStart"))),
|
||
("scheduleWindow", _window(s)),
|
||
("lastCommand", _enumLabel("command", s.settingInt("lastCommand"))),
|
||
("chargingMode", _enumLabel("chargingMode", s.integer("chargingMode"))),
|
||
("loadBalancing", _yesNo(s.flag("loadBalancing"))),
|
||
("solarBalancing", _yesNo(s.flag("solarBalancing"))),
|
||
("ledBrightness", led == null ? null : "$led %"),
|
||
// The rest of the settings group, which only the cloud transport reports:
|
||
// the register map has no address for any of them.
|
||
("plugLock", _yesNo(s.settingFlag("plugLock"))),
|
||
("autoRestart", _yesNo(s.settingFlag("autoRestart"))),
|
||
("randomDelay", _yesNo(s.settingFlag("randomDelay"))),
|
||
("scheduleEnabled", _yesNo(s.settingFlag("scheduleEnabled"))),
|
||
("scheduleMode", _enumLabel("scheduleMode", s.settingInt("scheduleMode"))),
|
||
("weekendWindow", _clockWindow(s, "weekendStart", "weekendEnd")),
|
||
("weekendMode", _enumLabel("weekendMode", s.settingInt("weekendMode"))),
|
||
("lightOff", _yesNo(s.settingFlag("lightOffSchedule"))),
|
||
("lightOffWindow", _clockWindow(s, "lightOffStart", "lightOffEnd")),
|
||
("mainBreakerLimit", _unit(s.setting("mainBreakerLimitA"), 0, "A")),
|
||
("solarChargeMode", _enumLabel("solarMode", s.settingInt("solarChargeMode"))),
|
||
("solarMinCurrent", _unit(s.setting("solarMinCurrentA"), 0, "A")),
|
||
("autoPhaseSwitching", _yesNo(s.settingFlag("autoPhaseSwitching"))),
|
||
("swipeUp", _enumLabel("gesture", s.integer("swipeUpMode"))),
|
||
("swipeDown", _enumLabel("gesture", s.integer("swipeDownMode"))),
|
||
("smartTouch", _enumLabel("touch", s.integer("smartTouchMode"))),
|
||
// What the two balancing features watch. The reference has not pinned
|
||
// down what the two modes and the flag select, so they are shown as the
|
||
// numbers they are rather than under names that would imply we knew.
|
||
("loadBalanceMeter", s.text("loadBalanceMonitorSN")),
|
||
("loadBalanceMonitorMode", _plain(s.integer("loadBalanceMonitorMode"))),
|
||
("loadBalanceMeterFlag", _plain(s.integer("loadBalanceMeterFlag"))),
|
||
("solarMonitor", s.text("solarMonitorSN")),
|
||
("solarMonitoringMode", _plain(s.integer("solarMonitoringMode"))),
|
||
]);
|
||
}
|
||
|
||
/// One of the charger's four time windows, when both of its ends arrived.
|
||
String? _clockWindow(ChargerStatus s, String fromKey, String toKey) {
|
||
final from = s.settings[fromKey];
|
||
final to = s.settings[toKey];
|
||
if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null;
|
||
return "$from–$to";
|
||
}
|
||
|
||
/// What the charger says about its own LAN side. The cloud transport is the
|
||
/// only one that can answer it — a charger whose Modbus server is off is a
|
||
/// charger the Modbus transport cannot ask.
|
||
List<(String, String)> _localRows(ChargerStatus s) {
|
||
final local = s.local;
|
||
final enabled = local["modbusEnabled"];
|
||
final host = local["host"];
|
||
final port = local["port"];
|
||
final timeout = local["timeoutSeconds"];
|
||
return _rows([
|
||
("modbusServer", _yesNo(enabled is bool ? enabled : null)),
|
||
("modbusAddress", host is String ? host : null),
|
||
("modbusPort", port == null ? null : "$port"),
|
||
("modbusTimeout", timeout == null ? null : "$timeout s"),
|
||
]);
|
||
}
|
||
|
||
/// The current range as the charger reports it: both bounds when it sends
|
||
/// both, and the one it does send otherwise — "up to 32 A" is a fact, and
|
||
/// dropping the row because the other half is missing hides it.
|
||
String? _currentRange(int? min, int? max) {
|
||
if (min != null && max != null) return "$min–$max A";
|
||
if (max != null) return "${t("charging.modbus.upTo")} $max A";
|
||
if (min != null) return "${t("charging.modbus.from")} $min A";
|
||
return null;
|
||
}
|
||
|
||
List<(String, String)> _deviceRows(ChargerStatus s) {
|
||
final product = s.integer("productNumber");
|
||
final min = s.integer("minCurrentA");
|
||
final max = s.integer("maxCurrentA");
|
||
return _rows([
|
||
("model", s.text("model")),
|
||
("serial", s.text("serial")),
|
||
("firmware", s.text("firmware")),
|
||
("controllerVersion", s.text("controllerVersion")),
|
||
("hardware", s.text("hardware")),
|
||
("productNumber", product == null ? null : "$product"),
|
||
("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")),
|
||
// Either bound on its own is still a bound worth reading; only a charger
|
||
// that reports neither has nothing to say here.
|
||
("currentRange", _currentRange(min, max)),
|
||
("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))),
|
||
("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))),
|
||
]);
|
||
}
|
||
|
||
/// Everything the charger sends that none of the blocks above has a name for.
|
||
/// Over the cloud a message carries more fields than this integration models,
|
||
/// and some of them no published map names at all — those arrive keyed by the
|
||
/// message and the field byte they came in ("0410.c9"). Shown raw: no unit, no
|
||
/// scaling, no translation, because a factor and a label are part of a meaning
|
||
/// Anker does not publish. Named leftovers sort first, the byte-keyed ones
|
||
/// after, so the readable half is not buried under hex.
|
||
List<(String, String)> _extraRows(ChargerStatus s) {
|
||
final extra = s.extra;
|
||
final keys = extra.keys.toList()
|
||
..sort((a, b) {
|
||
final raw = (a.contains(".") ? 1 : 0) - (b.contains(".") ? 1 : 0);
|
||
return raw != 0 ? raw : a.compareTo(b);
|
||
});
|
||
return [for (final key in keys) (key, "${extra[key]}")];
|
||
}
|
||
|
||
/// Reactive and apparent power are registers of their own, and the cloud has
|
||
/// no message carrying either — so on that transport the two columns could
|
||
/// only ever be three dashes each. They appear when the charger actually
|
||
/// reports them, which also covers a Modbus charger that leaves them out.
|
||
bool _phasesHaveVA(ChargerStatus s) => [1, 2, 3]
|
||
.any((n) => s.raw["reactiveL$n"] != null || s.raw["apparentL$n"] != null);
|
||
|
||
/// The per-phase matrix, or nothing at all when the charger reported none of
|
||
/// it. A cell with no reading is a dash, so the columns still line up.
|
||
List<List<String>> _phaseRows(ChargerStatus s) {
|
||
String cell(double? v, int digits, String unit) =>
|
||
v == null ? "—" : "${v.toStringAsFixed(digits)} $unit";
|
||
final any = ["voltageL1", "currentL1", "powerL1"].any((k) => s.raw[k] != null);
|
||
if (!any) return const [];
|
||
final va = _phasesHaveVA(s);
|
||
final wh = _phasesHaveSessionWh(s);
|
||
return [
|
||
for (final n in [1, 2, 3])
|
||
[
|
||
"L$n",
|
||
cell(s.number("voltageL$n"), 1, "V"),
|
||
cell(s.number("currentL$n"), 2, "A"),
|
||
cell(s.number("powerL$n"), 0, "W"),
|
||
if (va) cell(s.number("reactiveL$n"), 0, "var"),
|
||
if (va) cell(s.number("apparentL$n"), 0, "VA"),
|
||
if (wh) cell(s.number("sessionWhL$n"), 0, "Wh"),
|
||
],
|
||
];
|
||
}
|
||
|
||
/// The session's energy per phase is a cloud reading — a session is a cloud
|
||
/// idea, and no register counts one — so the column joins the matrix when the
|
||
/// charger reports it rather than standing as three dashes on Modbus.
|
||
bool _phasesHaveSessionWh(ChargerStatus s) =>
|
||
[1, 2, 3].any((n) => s.raw["sessionWhL$n"] != null);
|
||
|
||
/// Line-to-line voltages only mean anything on a three-phase supply, so they
|
||
/// are shown when the charger reports one rather than as three more zeroes.
|
||
List<String> _lineVoltages(ChargerStatus s) {
|
||
final pairs = [
|
||
("L1–L2", s.number("voltageL1L2")),
|
||
("L2–L3", s.number("voltageL2L3")),
|
||
("L3–L1", s.number("voltageL3L1")),
|
||
];
|
||
return [
|
||
for (final (name, v) in pairs)
|
||
if (v != null && v > 10) "$name ${v.toStringAsFixed(1)} V",
|
||
];
|
||
}
|
||
|
||
List<String> _alarmWords(ChargerStatus s) {
|
||
if (!s.alarm) return const [];
|
||
final words = s.alarms;
|
||
return [
|
||
for (var i = 0; i < words.length; i++)
|
||
if (words[i] != 0)
|
||
"${t("charging.modbus.alarmWord", params: {"n": i + 1})} "
|
||
"0x${words[i].toRadixString(16).toUpperCase().padLeft(4, "0")}",
|
||
];
|
||
}
|
||
|
||
/// Charger information: everything the record holds about the picked charger.
|
||
/// It stands on its own — control needs a control mode, but what the charger
|
||
/// *is* is known either way, so with control off this card is what fills the
|
||
/// column instead of a bare hint.
|
||
Widget _infoCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final charger = _selectedCharger;
|
||
return _FoldCard(
|
||
title: t("charging.info.title"),
|
||
open: _isOpen("info"),
|
||
onToggle: () => _toggleCard("info"),
|
||
action: _homeChargers.isEmpty
|
||
? null
|
||
: TextButton(
|
||
onPressed: _liveLoading ? null : () => _loadLive(force: true),
|
||
child: Text(_liveLoading ? t("common.loading") : t("charging.info.refresh")),
|
||
),
|
||
children: [
|
||
if (charger != null) ...[
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
// The product shot the app shows for this model, when the account
|
||
// sent one. Decorative: the name beside it says everything the
|
||
// picture does.
|
||
if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[
|
||
Image.network(_liveFor(charger)!.imageUrl,
|
||
width: 28,
|
||
height: 28,
|
||
fit: BoxFit.contain,
|
||
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
||
const SizedBox(width: 8),
|
||
],
|
||
Expanded(
|
||
child: Text(charger.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
),
|
||
// Reachability, said either way. A charger the service says nothing
|
||
// about stays silent: unknown is not offline.
|
||
if (_liveFor(charger)?.online == true) ...[
|
||
_StatusBadge(label: t("charging.info.online"), ok: true),
|
||
const SizedBox(width: 6),
|
||
] else if (_liveFor(charger)?.online == false) ...[
|
||
_StatusBadge(label: t("charging.info.offline"), ok: false),
|
||
const SizedBox(width: 6),
|
||
],
|
||
if (charger.provider.isNotEmpty)
|
||
_NeutralBadge(label: _providerLabel(charger.provider)),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
// A box per group, the way the readings card draws its own: one list
|
||
// of everything the service knows is harder to find a field in than
|
||
// several short ones under headings.
|
||
for (final (heading, rows) in _infoGroups(charger)) ...[
|
||
_ReadingSection(heading: heading, child: _PairList(rows: rows, breakLong: true)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
// The rest of what the service knows, in the service's own words. It
|
||
// appears only when there is something in it, so a charger the cloud
|
||
// says nothing more about stays quiet.
|
||
if (_attrRows(charger).isNotEmpty) ...[
|
||
_ReadingSection(
|
||
heading: t("charging.info.rawTitle"),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_PairList(rows: _attrRows(charger), breakLong: true),
|
||
const SizedBox(height: 6),
|
||
Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
// The views that answer per charger rather than per account. Each says
|
||
// what it knows, why it could not be read — an account that is not the
|
||
// charger's owner cannot read the cards, which is a fact about the
|
||
// account rather than a failure — or that it answered with nothing,
|
||
// which is equally an answer: a standalone charger has no station
|
||
// record and no site.
|
||
for (final view in _detailViews(charger)) ...[
|
||
_ReadingSection(
|
||
heading: t("charging.info.views.${view.id}"),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (view.error.isNotEmpty)
|
||
Text(view.error, style: TextStyle(fontSize: 11, color: muted))
|
||
else if (view.rows.isEmpty)
|
||
Text(t("charging.info.viewEmpty"), style: TextStyle(fontSize: 11, color: muted))
|
||
else
|
||
_PairList(rows: view.rows, breakLong: true),
|
||
if (view.note.isNotEmpty) ...[
|
||
const SizedBox(height: 6),
|
||
Text(view.note,
|
||
style: DriverVault.mono(context, size: 11, color: muted)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
],
|
||
if (_homeChargers.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(t("charging.info.empty"), style: TextStyle(fontSize: 12, color: muted)),
|
||
),
|
||
// Control mode off: say why the card above is missing, here, where there
|
||
// is now something to read it against.
|
||
if (!_active)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Text(t("charging.stations.noControlHint"),
|
||
style: TextStyle(fontSize: 12, color: muted)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Everything the card can say about one charger, in groups: what the box is,
|
||
/// what it is doing, how it is connected, and how it sits on the account. Each
|
||
/// group gets a box of its own, the way the readings card draws its groups.
|
||
/// Every row is drawn every time, a field nothing supplied included: which
|
||
/// fields a charger has an answer for is itself worth seeing, and a row that
|
||
/// comes and goes with the data makes two chargers impossible to read against
|
||
/// each other. Nothing to say is said with a dash.
|
||
List<(String, List<(String, String)>)> _infoGroups(HomeCharger c) {
|
||
final live = _liveFor(c);
|
||
final groups = <(String, List<(String, String)>)>[
|
||
("device", [
|
||
("vendor", c.vendor),
|
||
("model", c.model),
|
||
("firmware", live?.firmware ?? ""),
|
||
("serial", c.serial),
|
||
("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""),
|
||
("connector", c.connector),
|
||
]),
|
||
("status", [
|
||
("state", _stateLabel(live?.status ?? "")),
|
||
// Relayed as the service words it — the unit is upstream's, so putting
|
||
// one on it here would be inventing it.
|
||
("chargePower", live?.power ?? ""),
|
||
("ocpp", live?.ocppLabel ?? ""),
|
||
]),
|
||
// The box on the wall, as opposed to the charging: the networks it is on.
|
||
("network", [
|
||
("wifiName", live?.wifiName ?? ""),
|
||
("wifiMac", live?.wifiMac ?? ""),
|
||
("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"),
|
||
("bleMac", live?.bleMac ?? ""),
|
||
("relatedBy", (live?.relatedBy ?? const []).join(" · ")),
|
||
]),
|
||
// Where it thinks it is, and how the account came to know it.
|
||
("account", [
|
||
("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")),
|
||
("siteId", live?.siteId ?? ""),
|
||
("sources", _sourcesLabel(live?.sources ?? const [])),
|
||
("timeZone", live?.timeZone ?? ""),
|
||
(
|
||
"linked",
|
||
live?.linkedAt == null
|
||
? ""
|
||
: formatDateTime(
|
||
DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round()))
|
||
),
|
||
("providerId", c.providerChargerId),
|
||
("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())),
|
||
]),
|
||
];
|
||
return [
|
||
for (final (id, pairs) in groups)
|
||
(
|
||
t("charging.info.groups.$id"),
|
||
[
|
||
for (final (key, value) in pairs)
|
||
(t("charging.info.$key"), value.isEmpty ? "—" : value),
|
||
],
|
||
),
|
||
];
|
||
}
|
||
|
||
/// Everything else the service said about this charger, under its own field
|
||
/// names. The rows above are the ones DriverVault has a name for; these are
|
||
/// the remainder — the service documents none of them, so its own key is the
|
||
/// only honest label, and renaming one here would be inventing a meaning for
|
||
/// it. Sorted so the same charger reads the same way on every refresh.
|
||
List<(String, String)> _attrRows(HomeCharger c) {
|
||
final attrs = _liveFor(c)?.attrs ?? const <String, String>{};
|
||
final keys = attrs.keys.toList()..sort();
|
||
return [for (final key in keys) (key, attrs[key]!)];
|
||
}
|
||
|
||
/// The user's own chargers, and the import that fills the list.
|
||
Widget _chargerListCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return _Card(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(6, 6, 6, 4),
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: Text(
|
||
"${t("charging.stations.homeHeading")} · "
|
||
"${t("charging.home.count", params: {"n": _homeChargers.length}, n: _homeChargers.length)}",
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
|
||
.copyWith(letterSpacing: 1.4),
|
||
),
|
||
),
|
||
if (_canImport)
|
||
TextButton(onPressed: _import, child: Text(t("charging.home.import"))),
|
||
]),
|
||
),
|
||
for (final c in _homeChargers) _chargerRow(context, c),
|
||
|
||
// Nothing imported yet: say what this list is for and offer the import.
|
||
if (_homeChargers.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(6, 4, 6, 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.home.empty"), style: TextStyle(fontSize: 13, color: muted)),
|
||
const SizedBox(height: 10),
|
||
if (_canImport)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: FilledButton(
|
||
onPressed: _import,
|
||
child: Text(t("charging.home.import")),
|
||
),
|
||
)
|
||
else
|
||
Text(t("charging.home.connectFirst"),
|
||
style: TextStyle(fontSize: 12, color: muted)),
|
||
],
|
||
),
|
||
),
|
||
|
||
if (_homeError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(6, 4, 6, 8),
|
||
child: Text(_homeError!,
|
||
style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _chargerRow(BuildContext context, HomeCharger c) {
|
||
final muted = DriverVault.muted(context);
|
||
final selected = _selectedCharger?.id == c.id;
|
||
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
|
||
// The colour of the list icon: the same green and amber the badges in the
|
||
// information card use, so the list can be read at a glance without opening
|
||
// anything. A charger the service says nothing about stays muted — unknown
|
||
// is not offline, and a green bolt for it would be a claim.
|
||
final online = _liveFor(c)?.online;
|
||
final tone = online == true
|
||
? DriverVault.success
|
||
: online == false
|
||
? DriverVault.warning
|
||
: muted;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: () => _select(c),
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: selected ? DriverVault.brandTint(context) : null,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Row(children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration:
|
||
BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)),
|
||
child: Icon(Icons.bolt, size: 18, color: tone),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(c.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
if (c.subtitle.isNotEmpty) ...[
|
||
const SizedBox(height: 2),
|
||
Text(c.subtitle,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: DriverVault.mono(context, size: 11, color: muted)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 18),
|
||
color: muted,
|
||
tooltip: t("charging.home.remove"),
|
||
onPressed: _removing == c.id ? null : () => _remove(c),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One block of the readings card: an eyebrow heading over a sunken panel.
|
||
class _ReadingSection extends StatelessWidget {
|
||
final String heading;
|
||
final Widget child;
|
||
const _ReadingSection({required this.heading, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(heading,
|
||
style: DriverVault.mono(context,
|
||
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||
.copyWith(letterSpacing: 1.4)),
|
||
const SizedBox(height: 8),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Label/value rows: the label muted on the left, the value right-aligned in the
|
||
/// mono face the rest of the app reads numbers in.
|
||
class _PairList extends StatelessWidget {
|
||
final List<(String, String)> rows;
|
||
|
||
/// Let a long value wrap rather than clip. Serials and site ids are long
|
||
/// enough to need it; a reading never is.
|
||
final bool breakLong;
|
||
|
||
const _PairList({required this.rows, this.breakLong = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return Column(
|
||
children: [
|
||
for (final (label, value) in rows)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 3),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
flex: 4,
|
||
child: Text(label, style: TextStyle(fontSize: 11, color: muted)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
flex: 5,
|
||
child: Text(
|
||
value,
|
||
textAlign: TextAlign.right,
|
||
maxLines: breakLong ? 3 : 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: DriverVault.mono(context, size: 11),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A small sunken metric tile (connector status / energy).
|
||
class _MetricTile extends StatelessWidget {
|
||
final String value;
|
||
final String label;
|
||
const _MetricTile({required this.value, required this.label});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(DriverVault.radiusControl)),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(value, style: DriverVault.mono(context, size: 14, weight: FontWeight.w600)),
|
||
const SizedBox(height: 2),
|
||
Text(label, style: TextStyle(fontSize: 11, color: muted)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|