Files
DriverVault/Phone App/lib/screens/charging_screen.dart
T
tajniak81andClaude Opus 5 181f55a849 The phone catches up with the month the web had
Twenty-eight commits landed on the web app and the API since the phone was
last touched, and the phone's own README opens by claiming full feature
parity. It was not a small drift: a whole tab, two whole cards, and the two
settings that decide how a time is read.

The scheduler arrives as the third charging tab. One list of tasks covering
every charger the account owns, where the charger's own cloud schedule is one
window inside one box. A task is a flow — start at 23:00, cap to 10 A at
01:00, stop at 06:30 — on the days and the chargers it names, and naming no
charger means all of them, including the ones imported later. The clock is the
server's, so the tab only writes tasks and reads back how each one last went,
and any step can be fired now to find out whether it will reach the charger
before the night it matters.

The RFID card comes with it: the list the account holds, a card added by its
number or by holding it against the charger's own reader, and the charger's
own list read back from the device. Both halves are written by every add and
remove and they can still come apart, so when they disagree the card says
which list each card is missing from — nothing else on the page would.

The charger settings card the phone never had at all goes in whole rather than
only its new half. Over Modbus that is the four writable registers; over the
cloud it is the charger's whole settings group in sections, drawn from the same
block table the web reads, one write per section because the charger takes a
command whole and a schedule carrying only its switch is a schedule whose times
have just been set to midnight.

The clock and the week become settings. format.dart grows formatTime, the
weekday order and the short names, with "auto" asking intl's own hour pattern
and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both
questions beneath the date. Flutter's own picker renders on the device locale,
which nothing in this app steers, so TimeField types four digits on whichever
clock is in force and keeps the meridiem as its own control — a box reading
13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to
end.

The smaller ones travel too. The control card says which charger its buttons
drive, picture and name, because it follows a serial and not the highlighted
row; its two tiles take the names of the readings they actually hold; and the
limit slider leaves it wherever a settings card now owns that value. The list's
reachability re-asks every thirty seconds while the tab is in front, merged
rather than replaced — "we could not ask" is not an answer, and it certainly is
not "unknown". A settings frame that answers half a minute late is chased at
widening gaps and then given up on. The information card names the fields the
service sent under its own names and groups list records under their own, so
list[0].* stops being read as one alphabetical run. An inherited integration
field shows what it inherited rather than an example. The sign-in fields say
nothing until you type.

One gap stays open, and deliberately. The task form sends the phone's zone only
when Dart reports an IANA name; Android usually answers with an abbreviation
like CEST, which is not a zone, so it sends nothing and the server falls back to
its own clock. A name the server would misread is worse than no name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:11:19 +02:00

5071 lines
196 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "dart:async";
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 "../widgets/time_field.dart";
import "car_view_sheet.dart" show arrangeKeys;
import "charger_import_sheet.dart";
import "charging_task_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.
/// The scheduler comes last by default: it acts on the chargers the tab before
/// it lists, so it reads as the thing you set up once the chargers are there.
const List<String> kChargerTabKeys = ["public", "home", "scheduler"];
/// The home tab's cards, in their default order. Controls first, because acting
/// on the charger is what the page is opened for; the cards that may start a
/// charge without a phone directly under them, because it is the same question
/// asked of a person rather than of a button; the connection below those,
/// because it is touched once and then left alone.
const List<String> kChargerCardKeys = [
"control",
"rfid",
"settings",
"connection",
"readings",
"info",
];
/// A card's place when the account arranged its cards before that card existed.
///
/// [arrangeKeys] puts an unknown key at the end, which is right for a tab — a
/// new page belongs after the ones already there. It is wrong for a card added
/// between two others: somebody who has arranged their cards once should not
/// have to go looking for the new one at the bottom of the column. So a card the
/// stored order does not mention goes where the default order puts it, behind
/// the nearest neighbour that *is* mentioned.
List<String> arrangeCards(List<String> catalogue, List<String> order) {
final arranged = <String>[];
for (final key in order) {
if (catalogue.contains(key) && !arranged.contains(key)) arranged.add(key);
}
for (final key in catalogue) {
if (arranged.contains(key)) continue;
var at = 0;
for (final earlier in catalogue.sublist(0, catalogue.indexOf(key)).reversed) {
if (arranged.contains(earlier)) {
at = arranged.indexOf(earlier) + 1;
break;
}
}
arranged.insert(at, key);
}
return arranged;
}
/// 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 = arrangeCards(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);
case "scheduler":
return _SchedulerTab(key: const ValueKey("scheduler"), 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"),
"rfid" => t("charging.rfid.title"),
"settings" => t("charging.modbus.settingsTitle"),
"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.
// --- The scheduler -----------------------------------------------------------
//
// The charger's own cloud schedule is one window inside one box: charge between
// these hours, every day, and that is the whole vocabulary. This is a list —
// each line an action, a time, the days it repeats on and the chargers it acts
// on — and one list covers the whole account rather than each charger hiding its
// own.
//
// The clock is the server's, not this app's. A schedule that only fires while
// the app is open would be a reminder; the tab writes tasks and reads back how
// each one last went.
class _SchedulerTab extends StatefulWidget {
/// Whether this is the tab being read. Its own tasks and the chargers they act
/// on are asked for when the question is actually being asked, not because an
/// IndexedStack built every tab.
final bool active;
const _SchedulerTab({super.key, required this.active});
@override
State<_SchedulerTab> createState() => _SchedulerTabState();
}
class _SchedulerTabState extends State<_SchedulerTab> {
List<ChargingTask> _tasks = const [];
List<HomeCharger> _chargers = const [];
bool _loaded = false;
String? _error;
String _running = ""; // "<task id>:<step index>" of the step being fired
String _toggling = "";
@override
void initState() {
super.initState();
if (widget.active) _load();
}
@override
void didUpdateWidget(_SchedulerTab old) {
super.didUpdateWidget(old);
if (widget.active && !old.active && !_loaded) _load();
}
Future<void> _load() async {
try {
// The tasks act on the chargers the tab before this one lists, so the
// schedule can be read against the boxes it acts on without switching back.
final chargers = await apiClient.listHomeChargers();
final tasks = await apiClient.listChargingTasks();
if (!mounted) return;
setState(() {
_chargers = chargers;
_tasks = tasks;
_loaded = true;
_error = null;
});
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
/// A saved task replaces its old self in place rather than the list being
/// fetched again: the row that was just edited should not move under the
/// finger that edited it, and a new one belongs where its time puts it.
void _onSaved(ChargingTask task) {
setState(() {
final i = _tasks.indexWhere((x) => x.id == task.id);
if (i >= 0) {
final next = [..._tasks];
next[i] = task;
_tasks = next;
} else {
// Ordered by the time each task begins, which is what the server sends
// back and what the day runs them in.
_tasks = [..._tasks, task]..sort((a, b) => a.firstTime.compareTo(b.firstTime));
}
});
}
Future<void> _newTask() async {
final task = await showChargingTaskSheet(context, chargers: _chargers);
if (task != null && mounted) _onSaved(task);
}
Future<void> _editTask(ChargingTask task) async {
final saved = await showChargingTaskSheet(context, task: task, chargers: _chargers);
if (saved != null && mounted) _onSaved(saved);
}
/// The switch in the row. Written straight through rather than optimistically:
/// a schedule that says it is on when the server thinks otherwise is the one
/// mistake this list must not make.
Future<void> _toggle(ChargingTask task) async {
setState(() {
_toggling = task.id;
_error = null;
});
try {
final saved = await apiClient.updateChargingTask(task.id, {"enabled": !task.enabled});
if (mounted) _onSaved(saved);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _toggling = "");
}
}
/// Fire one step now, without waiting for its time — the only way to find out
/// whether it will actually reach the charger before the night it matters. One
/// step rather than the whole flow: running a start and the stop that closes
/// it back to back would leave the charger where it began and prove nothing.
///
/// The server takes the same path the clock takes, so what comes back is what
/// will happen then, errors included.
Future<void> _runStep(ChargingTask task, int index) async {
setState(() {
_running = "${task.id}:$index";
_error = null;
});
try {
final summary = await apiClient.runChargingStep(task.id, index);
// The row's own "last run" line is what reports this, so the answer is
// folded into the record rather than announced somewhere else.
if (mounted) _onSaved(task.copyWith(lastRun: DateTime.now(), lastResult: summary));
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _running = "");
}
}
Future<void> _remove(ChargingTask task) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(t("charging.scheduler.removeConfirm", params: {"name": task.name})),
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")),
),
],
),
);
if (ok != true) return;
setState(() => _error = null);
try {
await apiClient.deleteChargingTask(task.id);
if (mounted) setState(() => _tasks = _tasks.where((x) => x.id != task.id).toList());
} catch (e) {
if (mounted) setState(() => _error = "$e");
}
}
/// What one step of a flow does. The ceiling is part of the sentence for the
/// one action that has one — "Set current limit" alone does not say to what.
String _stepLabel(ChargingStep s) {
final label = t("charging.scheduler.actions.${s.action}");
return s.action == "limit" ? "$label · ${s.amps.round()} A" : label;
}
String _chargersLabel(ChargingTask task) {
if (task.chargers.isEmpty) return t("charging.scheduler.allChargers");
final names = <String>[];
for (final id in task.chargers) {
for (final c in _chargers) {
if (c.id == id) names.add(c.name);
}
}
// A task can outlive a charger it names — the server skips the missing one
// rather than failing, and the row says as much instead of showing a gap.
if (names.isEmpty) return t("charging.scheduler.missingChargers");
return names.join(", ");
}
String _daysLabel(ChargingTask task) {
if (task.days.isEmpty) return t("charging.scheduler.everyDay");
// Listed in the order this account reads a week in, so "Mon Fri" and the
// picker that wrote it agree about which end of the week comes first.
return sortWeekdays(task.days).map(weekdayShortName).join(" ");
}
/// Whether the last firing went through, so the row can colour it. Unknown
/// until it has fired once — a task written this afternoon has nothing to
/// report.
Color _runTone(BuildContext context, ChargingTask task) {
final ok = task.lastRunOk;
if (ok == null) return DriverVault.muted(context);
return ok ? DriverVault.success : DriverVault.warning;
}
/// The tasks that will act on one charger — the ones that name it, plus every
/// task that names none, since those act on all of them.
int _taskCount(HomeCharger c) =>
_tasks.where((task) => task.chargers.isEmpty || task.chargers.contains(c.id)).length;
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
return Column(
children: [
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("charging.scheduler.title"),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(t("charging.scheduler.subtitle"),
style: TextStyle(fontSize: 12, color: muted)),
],
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _chargers.isEmpty ? null : _newTask,
child: Text(t("charging.scheduler.add")),
),
],
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_error!,
style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
// Nothing to act on yet: a task with no charger behind it would
// only ever report that it could not send anything.
if (_chargers.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(t("charging.scheduler.needCharger"),
style: TextStyle(fontSize: 13, color: muted)),
)
else if (_tasks.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(t("charging.scheduler.empty"),
style: TextStyle(fontSize: 13, color: muted)),
)
else
for (final task in _tasks) _taskRow(context, task, muted),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(t("charging.scheduler.serverHint"),
style: TextStyle(fontSize: 12, color: muted)),
),
],
),
),
const SizedBox(height: 16),
// The same chargers the tab before this one lists, so the schedule can
// be read against the boxes it acts on. Each says how many tasks touch
// it, which is the question this list is here to answer. Read-only:
// this tab is about the schedule.
_Card(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(6, 6, 6, 4),
child: Text(
"${t("charging.stations.homeHeading")} · "
"${t("charging.home.count", params: {"n": _chargers.length}, n: _chargers.length)}",
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
.copyWith(letterSpacing: 1.4),
),
),
for (final c in _chargers)
Padding(
padding: const EdgeInsets.all(10),
child: Row(children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: DriverVault.isDark(context)
? DriverVault.darkSunken
: DriverVault.ink50,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.bolt, size: 18, color: muted),
),
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)
Text(c.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11, color: muted)),
],
),
),
const SizedBox(width: 8),
Text(
t("charging.scheduler.taskCount",
params: {"n": _taskCount(c)}, n: _taskCount(c)),
style: TextStyle(fontSize: 11, color: muted),
),
]),
),
if (_chargers.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 8),
child: Text(t("charging.home.empty"),
style: TextStyle(fontSize: 13, color: muted)),
),
],
),
),
],
);
}
/// One row per task: what it does and when, which chargers, which days, and
/// how the last firing went.
Widget _taskRow(BuildContext context, ChargingTask task, Color muted) {
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
return Opacity(
opacity: task.enabled ? 1 : 0.6,
child: Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: sunken,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(task.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text("${_chargersLabel(task)} · ${_daysLabel(task)}",
style: TextStyle(fontSize: 12, color: muted)),
],
),
),
// The switch. It governs the whole flow: the task is one
// intention and is switched off as one.
Switch(
value: task.enabled,
onChanged: _toggling == task.id ? null : (_) => _toggle(task),
),
],
),
// The flow, a line per step. Read down, they are the night — which
// is the whole reason a task holds more than one.
for (var i = 0; i < task.steps.length; i++)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
SizedBox(
width: 82,
child: Text(formatClock(task.steps[i].time),
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
),
Expanded(
child: Text(_stepLabel(task.steps[i]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
// Per step, because a flow is not a thing that can happen at
// once: firing a start and the stop that closes it back to
// back would leave the charger where it began.
TextButton(
onPressed: _running == "${task.id}:$i" ? null : () => _runStep(task, i),
child: Text(
_running == "${task.id}:$i"
? t("charging.scheduler.running")
: t("charging.scheduler.runNow"),
style: TextStyle(fontSize: 11, color: muted),
),
),
]),
),
if (task.lastRun != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
"${t("charging.scheduler.lastRun", params: {"when": formatDateTime(task.lastRun)})} · "
"${task.lastResult.isEmpty ? t("charging.scheduler.noResult") : task.lastResult}",
style: TextStyle(fontSize: 11, color: _runTone(context, task)),
),
),
Row(children: [
TextButton(onPressed: () => _editTask(task), child: Text(t("common.edit"))),
TextButton(
onPressed: () => _remove(task),
child: Text(t("common.remove"), style: const TextStyle(color: DriverVault.danger)),
),
]),
],
),
),
);
}
}
// --- What the cloud transport can be told ------------------------------------
//
// Modbus has four writable registers. The cloud has the charger's whole settings
// group: everything the Anker app can set on it short of the card list. They are
// the same names a settings write takes and the same ones the snapshot reports
// them under, so every control there is seeded from the charger, edited, and
// sent back by name.
//
// A table rather than two dozen hand-written controls, because the charger's own
// commands own *sets* of fields: a command is taken whole, and a schedule that
// arrives carrying only its switch is a schedule whose times have just been set
// to midnight. A block is one write, and that stays true as fields are added to
// it only if the blocks are data.
/// The floor a current limit can be set to. Below it the charger pauses rather
/// than charging slowly, which is a different thing from a slow charge.
const int _kLimitFloor = 6;
/// The shape of one control in a settings block.
enum _SetKind { slider, toggle, option, number, window }
/// One setting, or for a window the two ends of one.
///
/// [at] is where the value is read back from in the snapshot — the settings
/// object for most, the top level for the ones the charger reports outside it,
/// and `local` for the Modbus server switch. A null [max] means the ceiling is
/// the charger's own rating rather than a constant.
class _SetField {
final _SetKind kind;
final String key;
final String at;
final String label;
final num min;
final num? max;
final num step;
final String unit;
final String hint;
final String enumPrefix;
final List<int> values;
final String from;
final String to;
final String atFrom;
final String atTo;
const _SetField({
required this.kind,
required this.label,
this.key = "",
this.at = "",
this.min = 0,
this.max,
this.step = 1,
this.unit = "",
this.hint = "",
this.enumPrefix = "",
this.values = const [],
this.from = "",
this.to = "",
this.atFrom = "",
this.atTo = "",
});
/// The draft keys this field owns — two for a window, one for everything else.
List<String> get keys => kind == _SetKind.window ? [from, to] : [key];
/// Each of those, with the snapshot path it is read back from.
List<(String, String)> get entries =>
kind == _SetKind.window ? [(from, atFrom), (to, atTo)] : [(key, at)];
}
class _SetBlock {
final String id;
final String title;
/// Said next to the switch rather than after it has been thrown.
final String warning;
final List<_SetField> fields;
const _SetBlock({
required this.id,
required this.title,
required this.fields,
this.warning = "",
});
}
const List<_SetBlock> _kMqttSettingBlocks = [
_SetBlock(id: "charging", title: "blockCharging", fields: [
// A slider rather than a box, like the control card had and the Modbus
// settings card still has: it is the same value they set, and a ceiling is a
// thing you slide between two known ends rather than type.
_SetField(
kind: _SetKind.slider,
key: "maxCurrentA",
at: "settings.maxCurrentA",
label: "maxCurrentSet",
min: _kLimitFloor,
unit: "A",
hint: "limitFloorHint",
),
_SetField(kind: _SetKind.toggle, key: "autoStart", at: "settings.autoStart", label: "autoStart"),
_SetField(
kind: _SetKind.toggle, key: "randomDelay", at: "settings.randomDelay", label: "randomDelay"),
_SetField(kind: _SetKind.toggle, key: "plugLock", at: "settings.plugLock", label: "plugLock"),
_SetField(
kind: _SetKind.toggle, key: "autoRestart", at: "settings.autoRestart", label: "autoRestart"),
]),
_SetBlock(id: "schedule", title: "blockSchedule", fields: [
_SetField(
kind: _SetKind.toggle,
key: "scheduleEnabled",
at: "settings.scheduleEnabled",
label: "scheduleEnabled"),
_SetField(
kind: _SetKind.option,
key: "scheduleMode",
at: "settings.scheduleMode",
label: "scheduleMode",
enumPrefix: "scheduleMode",
values: [0, 1],
),
_SetField(
kind: _SetKind.window,
label: "scheduleWindow",
from: "weekStart",
to: "weekEnd",
atFrom: "settings.weekStart",
atTo: "settings.weekEnd",
),
_SetField(
kind: _SetKind.option,
key: "weekendMode",
at: "settings.weekendMode",
label: "weekendMode",
enumPrefix: "weekendMode",
values: [1, 2],
),
_SetField(
kind: _SetKind.window,
label: "weekendWindow",
from: "weekendStart",
to: "weekendEnd",
atFrom: "settings.weekendStart",
atTo: "settings.weekendEnd",
),
]),
_SetBlock(id: "balancing", title: "blockBalancing", fields: [
_SetField(
kind: _SetKind.toggle, key: "loadBalancing", at: "loadBalancing", label: "loadBalancing"),
_SetField(
kind: _SetKind.number,
key: "mainBreakerLimitA",
at: "settings.mainBreakerLimitA",
label: "mainBreakerLimit",
min: 10,
max: 500,
unit: "A",
),
]),
_SetBlock(id: "solar", title: "blockSolar", fields: [
_SetField(
kind: _SetKind.toggle, key: "solarBalancing", at: "solarBalancing", label: "solarBalancing"),
_SetField(
kind: _SetKind.option,
key: "solarChargeMode",
at: "settings.solarChargeMode",
label: "solarChargeMode",
enumPrefix: "solarMode",
values: [0, 1],
),
// A slider, like the current limit it shares a floor with. No floor note
// under it though: this is the least a solar charge will draw, not a
// ceiling, so the limit slider's hint would be saying the wrong thing.
_SetField(
kind: _SetKind.slider,
key: "solarMinCurrentA",
at: "settings.solarMinCurrentA",
label: "solarMinCurrent",
min: _kLimitFloor,
max: 32,
unit: "A",
),
// This command offers automatic and single-phase only. The three-phase
// setting is a Modbus register, and offering it here would be offering a
// write that comes back refused.
_SetField(
kind: _SetKind.option,
key: "phaseMode",
at: "phaseMode",
label: "phaseSetting",
enumPrefix: "phaseSet",
values: [0, 1],
),
_SetField(
kind: _SetKind.toggle,
key: "autoPhaseSwitching",
at: "settings.autoPhaseSwitching",
label: "autoPhaseSwitching"),
]),
_SetBlock(id: "panel", title: "blockPanel", fields: [
// A slider, like the current limit: a brightness is a place on a range, and
// typing 70 into a box that only takes tens is a worse way to say it.
_SetField(
kind: _SetKind.slider,
key: "ledBrightness",
at: "ledBrightness",
label: "ledBrightness",
max: 100,
step: 10,
unit: "%",
),
_SetField(
kind: _SetKind.toggle,
key: "lightOffSchedule",
at: "settings.lightOffSchedule",
label: "lightOff"),
_SetField(
kind: _SetKind.window,
label: "lightOffWindow",
from: "lightOffStart",
to: "lightOffEnd",
atFrom: "settings.lightOffStart",
atTo: "settings.lightOffEnd",
),
_SetField(
kind: _SetKind.option,
key: "swipeUpMode",
at: "swipeUpMode",
label: "swipeUp",
enumPrefix: "gesture",
values: [0, 1, 2, 3],
),
_SetField(
kind: _SetKind.option,
key: "swipeDownMode",
at: "swipeDownMode",
label: "swipeDown",
enumPrefix: "gesture",
values: [0, 1, 2, 3],
),
_SetField(
kind: _SetKind.option,
key: "smartTouchMode",
at: "smartTouchMode",
label: "smartTouch",
enumPrefix: "touch",
values: [0, 1],
),
]),
// The one setting here that can cost you a control mode: with the server off
// the charger stops answering on the LAN, and Modbus mode has nothing left to
// dial.
_SetBlock(
id: "local",
title: "blockLocal",
warning: "modbusOffWarning",
fields: [
_SetField(
kind: _SetKind.toggle,
key: "modbusEnabled",
at: "local.modbusEnabled",
label: "modbusServer"),
],
),
];
// --- The service's own field names, given the names the card uses ------------
//
// Anker documents none of these, so only the fields whose meaning is plain from
// the value are named here — each says which group it belongs in and what to
// call it. A field whose meaning would be a guess stays out and keeps its own
// key in the box below, where the key is the only honest label it has.
class _NamedAttr {
final String group;
final String label;
/// A flag the service sends as true/false or 1/0, read out in words.
final bool isBool;
/// A row the card already draws, written another way: the row is dropped when
/// the two say the same thing.
final String sameAs;
const _NamedAttr(this.group, this.label, {this.isBool = false, this.sameAs = ""});
}
const Map<String, _NamedAttr> _kNamedAttrs = {
"alias_name": _NamedAttr("device", "nickname"),
"product_code": _NamedAttr("device", "productCode"),
"ms_device_type": _NamedAttr("device", "deviceType"),
"charge": _NamedAttr("status", "charging", isBool: true),
"chargerStatus": _NamedAttr("status", "statusCode"),
"ocpp_connect_status": _NamedAttr("status", "ocppLink"),
// The box on the wall is on two networks, and the service says more about both
// than the typed fields carry.
"wifi_online": _NamedAttr("network", "wifiOnline", isBool: true),
"bt_ble_id": _NamedAttr("network", "bleId", sameAs: "bleMac"),
"blue_password": _NamedAttr("network", "blePassword"),
"owner_user_id": _NamedAttr("account", "ownerId"),
};
/// Fields that repeat, in the service's own words, a row the card already draws:
/// the same serial, the same firmware, the same Wi-Fi, the same picture. Shown
/// twice they make the card longer without making it say more, so they are
/// dropped instead.
const Set<String> _kEchoedAttrs = {
"deviceName",
"device_name",
"deviceSn",
"device_sn",
"device_sw_version",
"img_url",
"link_time",
"rssi",
"time_zone",
"wifi_mac",
"wifi_name",
"bt_ble_mac",
};
/// relate_type arrives indexed — relate_type[0], relate_type[1] — and is the
/// list the "Reachable by" row is built from.
bool _isEchoedAttr(String key) =>
_kEchoedAttrs.contains(key) || key.startsWith("relate_type[");
// --- The keys the per-charger views answer with, in the card's words ---------
//
// Anker documents none of these payloads either, but a field like page_size or
// create_time says what it is once its key is read out, and those are named here
// rather than left as keys. Keyed by the field with any list index taken out, so
// list[0].name and list[3].name are one field asked about two records. Anything
// absent from this table keeps its own key, for the same reason the box above
// does: a name invented here would be a meaning invented here.
class _ViewField {
final String label;
/// A unix second the cloud sent as a bare number, read as a date.
final bool time;
const _ViewField(this.label, {this.time = false});
}
const Map<String, _ViewField> _kViewFields = {
// What the account has counted for this charger.
"total_stats.charge_count": _ViewField("sessions"),
"total_stats.charge_time": _ViewField("chargeTime"),
"total_stats.charge_total": _ViewField("energy"),
"total_stats.co2_saving": _ViewField("co2Saved"),
"total_stats.cost": _ViewField("cost"),
"total_stats.cost_saving": _ViewField("costSaved"),
"total_stats.cost_unit": _ViewField("currency"),
"total_stats.mile_age": _ViewField("mileage"),
// How much of a list the view answered with — a page of a history that is
// empty is still the answer "there is nothing to page through".
"page": _ViewField("page"),
"page_num": _ViewField("page"),
"page_size": _ViewField("perPage"),
"total": _ViewField("records"),
"total_count": _ViewField("records"),
"start_use_time": _ViewField("from", time: true),
// The backend the charger is pointed at, and when it last said so.
"source": _ViewField("source"),
"time_zone": _ViewField("timeZone"),
"timestamp": _ViewField("updated", time: true),
"create_time": _ViewField("added", time: true),
// The records a list view answers with: one OCPP endpoint, one RFID card.
"list[].address": _ViewField("address"),
"list[].name": _ViewField("name"),
"list[].source": _ViewField("source"),
"list[].alias_name": _ViewField("cardName"),
"list[].card_number": _ViewField("cardNumber"),
"list[].create_time": _ViewField("added", time: true),
// Who else the charger is shared with, one person per record.
"email": _ViewField("email"),
"device_sn": _ViewField("serial"),
"member_id": _ViewField("memberId"),
"member_type": _ViewField("memberType"),
"user_id": _ViewField("userId"),
"status": _ViewField("status"),
"max_invite_members_count": _ViewField("inviteLimit"),
};
/// The name a record carries for itself, in the order the views use one: an RFID
/// card is its alias, an endpoint its name, a person their address.
const List<String> _kViewItemTitles = ["alias_name", "name", "email"];
/// list[0].card_number split into the list, which record, and which field.
final RegExp _kViewListKey = RegExp(r"^([A-Za-z0-9_.]+)\[(\d+)\]\.(.+)$");
/// One row of a view: what to call it, what it says, and whether the label is a
/// name of ours or the cloud's own key — the two are never mistaken for each
/// other because the key keeps the typeface keys are read in.
class _ViewRow {
final String label;
final String value;
final bool named;
const _ViewRow(this.label, this.value, this.named);
}
/// One record a list view answered with, under the name it carries for itself.
class _ViewItem {
final String label;
final List<_ViewRow> rows;
const _ViewItem(this.label, this.rows);
}
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> with WidgetsBindingObserver {
// --- 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>{};
/// Keeping the list's reachability current.
///
/// Who is reachable changes on its own, so asking once when the tab opens left
/// the bolts saying whatever was true when it did. This re-asks while the tab
/// is actually being looked at: not while the app is in the background, not on
/// the public tab, and not the per-charger views — those are a dozen cloud
/// endpoints per charger, where reachability is one call per service.
static const Duration _kLivePoll = Duration(seconds: 30);
Timer? _livePoll;
/// Waiting for the half of the snapshot that answers late.
///
/// A snapshot has two halves on two messages. The telemetry comes from the
/// trigger and is there by the time the read returns; the settings come when
/// the charger gets round to answering the request for them, which on this
/// A5191 was measured at around half a minute — long after the read that asked
/// has gone. The frame is not lost: it lands in the server's state and the next
/// read carries it. But nothing here took a next read, so a settings card that
/// came up empty stayed empty until something else happened to refresh it.
///
/// So a refresh that comes back without them queues another look, at widening
/// gaps, and then stops. Stopping matters: the message the server asks with is
/// one the reference reads as carrying an Anker bug, so a charger that never
/// answers it is a real possibility, and a page left open all day must not poll
/// one for ever.
static const List<int> _kSettingsRetryMs = [6000, 12000, 24000, 45000];
Timer? _settingsRetry;
int _settingsRetryAt = 0;
String _settingsRetryFor = "";
// --- the charger's own settings, as it reports them back ---
//
// Modbus has four writable registers; the cloud has the charger's whole
// settings group. Both are seeded from the charger, edited, and sent back.
int _draftAmps = 16;
int _draftSeconds = 120;
int _draftPhase = 0;
/// What the cloud's controls hold, and what the charger last said, so a block
/// can tell whether it has anything to send and can be put back if it has not
/// sent it.
Map<String, dynamic> _mqttDraft = {};
Map<String, dynamic> _mqttBase = {};
String _mqttBusy = "";
/// The settings the charger has reported, kept per serial across reads.
///
/// The two halves of a snapshot arrive on different messages, and a read can
/// land with the telemetry and not the settings — most often the first read
/// after a reconnect. Seeding the controls from that answer alone emptied the
/// card of everything the charger had already told us, which is not the state
/// of the charger; it is the state of one message. So a value the charger has
/// reported stays until it reports another.
final Map<String, Map<String, dynamic>> _mqttSeen = {};
// --- the cards that open the charger ---
final _newCardNumber = TextEditingController();
final _newCardName = TextEditingController();
/// The card list as the last write read back, per serial. Anker's write
/// endpoints are undocumented, so a 200 proves nothing on its own — it is the
/// list that says what happened, and a refresh drops this copy so the account's
/// own answer is what wins in the end.
final Map<String, List<RfidCard>> _rfidWritten = {};
/// The numbers the device itself answered with, per serial. Every add and
/// remove writes both halves and they can still come apart, and no other view
/// on this page would say so.
final Map<String, List<String>> _chargerCards = {};
String _rfidBusy = ""; // a card number, "new", "scan", "tapSave" or "charger"
int _rfidCountdown = 0; // seconds left of the reader's window, while it is open
Timer? _rfidTick;
String? _rfidError;
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();
WidgetsBinding.instance.addObserver(this);
_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();
_startLivePoll();
}
/// Coming back to an app that has been in the background asks straight away
/// rather than waiting out the rest of an interval that was never going to
/// fire — and a backgrounded app asks nothing at all.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) {
_stopLivePoll();
return;
}
_loadLive(force: true, withDetails: false);
_startLivePoll();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_stopLivePoll();
_stopSettingsRetry();
_rfidTick?.cancel();
_serial.dispose();
_host.dispose();
_port.dispose();
_resetPassword.dispose();
_newCardNumber.dispose();
_newCardName.dispose();
super.dispose();
}
void _stopLivePoll() {
_livePoll?.cancel();
_livePoll = null;
}
void _startLivePoll() {
_stopLivePoll();
if (!widget.active) return;
_livePoll = Timer.periodic(
_kLivePoll,
(_) => _loadLive(force: true, withDetails: false),
);
}
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();
_startLivePoll();
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, bool withDetails = true}) 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>{};
var answered = false;
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;
}
answered = true;
} 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(() {
// Merged rather than replaced, and only when something actually answered.
// A service that could not be reached used to take every charger it knows
// about grey with it and then mark the question asked, so the list sat
// colourless until somebody pressed Refresh. "We could not ask" is not an
// answer, and it is certainly not "unknown" — the last thing the service
// did say still stands, and a provider that did answer overwrites its own
// entries here.
if (answered) {
_live = {..._live, ...live};
_liveLoaded = true;
}
_liveLoading = false;
});
// The card's other half: the views that answer per charger rather than per
// account. Skipped by the poll above — those are a dozen cloud endpoints per
// charger, where reachability is one call per service.
if (withDetails) _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;
// Held in state rather than beside it: the RFID card's Refresh reads it to
// know it is already asking, and a field nothing rebuilds on would leave
// that button live through the whole read.
setState(() => _detailsLoading = sn);
try {
final res = await apiClient.getAnkerChargerDetails(sn);
if (mounted) {
setState(() {
_details[sn] = res;
// The account has just been asked; whatever a write read back is now
// the older answer of the two.
_rfidWritten.remove(sn);
// The charger's own list was read against the account list that has
// just been replaced. Comparing it against the new one would be
// comparing two answers from different moments, so it is dropped and
// asked for again.
_chargerCards.remove(sn);
});
}
} catch (_) {
// Left absent rather than shown as a failure.
} finally {
if (mounted) setState(() => _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}";
_syncSettingsDraft();
});
_chaseSettings();
} catch (e) {
if (mounted) {
setState(() {
_ctlError = "$e";
_ctl = null;
});
_stopSettingsRetry();
}
}
}
void _stopSettingsRetry() {
_settingsRetry?.cancel();
_settingsRetry = null;
}
void _chaseSettings() {
final sn = _serial.text.trim();
// A different charger is a different question, and gets its own patience.
if (sn != _settingsRetryFor) {
_settingsRetryFor = sn;
_settingsRetryAt = 0;
}
_stopSettingsRetry();
if (sn.isEmpty || !_readsDevice || (_dev?.settings.isNotEmpty ?? false)) {
_settingsRetryAt = 0;
return;
}
if (_settingsRetryAt >= _kSettingsRetryMs.length) return; // the Refresh button remains
final wait = _kSettingsRetryMs[_settingsRetryAt++];
_settingsRetry = Timer(Duration(milliseconds: wait), _refresh);
}
// --- 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(" · ");
// --- what the charger is set to, and what can be written back --------------
/// The charger's own ceiling, which is what a limit slider runs up to.
int get _limitCeiling => (_dev?.number("maxCurrentA") ?? 32).round();
/// Seeds the Modbus card's three controls from the charger's last word, so a
/// value it clamped or refused shows as what it took rather than what was
/// asked for. Called inside the setState of a refresh.
void _syncSettingsDraft() {
final set = _dev?.settings ?? const {};
final amps = set["maxCurrentA"];
final seconds = set["timeoutSeconds"];
final phase = set["phaseSetting"];
if (amps is num) _draftAmps = amps.round();
if (seconds is num) _draftSeconds = seconds.toInt();
if (phase is num) _draftPhase = phase.toInt();
_syncMqttSettings();
}
/// One dotted path into the charger's snapshot.
dynamic _snapshotValue(String path) {
dynamic node = _dev?.raw;
for (final part in path.split(".")) {
if (node is! Map) return null;
node = node[part];
}
return node;
}
void _syncMqttSettings() {
final sn = _serial.text.trim();
final seen = {...?_mqttSeen[sn]};
for (final block in _kMqttSettingBlocks) {
for (final f in block.fields) {
for (final (key, at) in f.entries) {
final v = _snapshotValue(at);
if (v != null && v != "") seen[key] = v;
}
}
}
_mqttSeen[sn] = seen;
_mqttDraft = {...seen};
_mqttBase = {...seen};
}
/// Only the settings the charger has actually reported get a control. A value
/// it has not sent is one nothing here could seed a control from, and a blank
/// box that writes whatever it was left at is worse than no box: several of
/// these travel as siblings on one command, where an invented value is not
/// ignored but applied.
List<_SetBlock> get _mqttBlocks => [
for (final b in _kMqttSettingBlocks)
if (b.fields.any((f) => f.keys.every(_mqttBase.containsKey)))
_SetBlock(
id: b.id,
title: b.title,
warning: b.warning,
fields:
b.fields.where((f) => f.keys.every(_mqttBase.containsKey)).toList(),
),
];
bool _blockDirty(_SetBlock block) =>
block.fields.any((f) => f.keys.any((k) => _mqttDraft[k] != _mqttBase[k]));
/// An empty number box holds null, which would travel as null and come back as
/// a parse error from the server. The block simply cannot be applied until it
/// holds a number again.
bool _blockValid(_SetBlock block) =>
block.fields.every((f) => f.keys.every((k) => _mqttDraft[k] != null));
/// The options a select offers: the ones this command accepts, plus whatever
/// the charger actually reported if that is not among them. The phase field is
/// why — the charger reports the phase it is *running* on, which can be the
/// three-phase setting the solar command has no value for. Showing the
/// reported value keeps the select from silently reading as something the
/// charger did not say; sending it earns a refusal from the server, which is
/// the honest outcome for a value this command cannot carry.
List<int> _fieldOptions(_SetField f) {
final reported = _mqttBase[f.key];
if (reported is! num || f.values.contains(reported.toInt())) return f.values;
return [...f.values, reported.toInt()];
}
void _resetMqttBlock(_SetBlock block) {
setState(() {
for (final f in block.fields) {
for (final k in f.keys) {
_mqttDraft[k] = _mqttBase[k];
}
}
});
}
/// Applying one block. The whole block goes, not only what changed: several of
/// these are one command on the wire, and the charger takes a command as the
/// new truth for every field it carries, so the siblings travel back with the
/// change. The server would refill them from the charger's last report anyway;
/// sending what is on screen means what is on screen is what gets written.
Future<void> _applyMqttBlock(_SetBlock block) async {
final sn = _serial.text.trim();
if (sn.isEmpty || _mqttBusy.isNotEmpty) return;
final settings = <String, dynamic>{};
for (final f in block.fields) {
for (final k in f.keys) {
final v = _mqttDraft[k];
if (v != null && v != "") settings[k] = v;
}
}
if (settings.isEmpty) return;
setState(() {
_mqttBusy = block.id;
_ctlError = null;
});
try {
await apiClient.ankerControlAction(sn, "settings", {"settings": settings});
// A refresh reseeds every control from the charger, so a value it clamped
// or refused shows as what it took rather than as what was asked for.
await _refresh();
} catch (e) {
if (mounted) setState(() => _ctlError = "$e");
} finally {
if (mounted) setState(() => _mqttBusy = "");
}
}
/// What the cloud reports in the settings group but has no command to write:
/// the meter and the monitor the two balancing features watch. The reference
/// has not pinned down what the modes and the flag select, which is also why
/// there is no control for them — a control would imply knowing what they mean.
List<(String, String)> _mqttSettingsReported(ChargerStatus s) => _rows([
("loadBalanceMeter", s.text("loadBalanceMonitorSN")),
("loadBalanceMonitorMode", _plain(s.integer("loadBalanceMonitorMode"))),
("loadBalanceMeterFlag", _plain(s.integer("loadBalanceMeterFlag"))),
("solarMonitor", s.text("solarMonitorSN")),
("solarMonitoringMode", _plain(s.integer("solarMonitoringMode"))),
]);
/// The rest of the Modbus settings block: the charger reports these, but the
/// register map has nothing to write them with.
List<(String, String)> _modbusSettingsReported(ChargerStatus s) {
final led = s.integer("ledBrightness");
return _rows([
("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 cards that open the charger ---------------------------------------
/// The serial the per-charger views were asked under, which is the charger the
/// card list belongs to.
String get _detailSn {
final c = _selectedCharger;
if (c == null) return "";
return c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial;
}
/// The view that answers with the account's card list, when the account could
/// read it at all.
ChargerDetailView? get _rfidView {
for (final v in _details[_detailSn]?.views ?? const <ChargerDetailView>[]) {
if (v.id == "rfid") return v;
}
return null;
}
/// What the card draws: the list a write last read back when there is one, and
/// the account's own view of it otherwise. A refresh drops the write's copy, so
/// the server's answer is always what wins in the end.
List<RfidCard> get _rfidCards {
final written = _rfidWritten[_detailSn];
if (written != null) return written;
return _rfidCardsFrom(_rfidView?.attrs ?? const {});
}
/// The cards out of the flattened keys the view answers with: this card needs a
/// number to delete by, and a row of text is not a number.
List<RfidCard> _rfidCardsFrom(Map<String, String> attrs) {
final by = <int, Map<String, dynamic>>{};
final pattern = RegExp(r"^list\[(\d+)\]\.(alias_name|card_number|create_time)$");
for (final entry in attrs.entries) {
final m = pattern.firstMatch(entry.key);
if (m == null) continue;
by.putIfAbsent(int.parse(m.group(1)!), () => {})[m.group(2)!] = entry.value;
}
final indices = by.keys.toList()..sort();
return [
for (final i in indices)
if ((by[i]!["card_number"] ?? "").toString().trim().isNotEmpty)
RfidCard.fromJson(by[i]!),
];
}
/// Two numbers are the same card when they are the same hex; people and
/// services write them with spaces, dashes or colons, and the charger writes
/// them with none. The server normalizes what it stores, so this only has to
/// agree with it.
String _cardKey(String number) =>
number.replaceAll(RegExp(r"[^0-9A-Za-z]"), "").toUpperCase();
List<String>? get _chargerCardList => _chargerCards[_detailSn];
/// What the two lists disagree about, once the device has answered. Named from
/// the list each card is missing from, because that is what has to be fixed: a
/// card only on the charger opens it without the account knowing, and a card
/// only on the account is one the charger will not open for.
List<String> get _cardsOnlyOnCharger {
final held = _chargerCardList;
if (held == null) return const [];
final account = _rfidCards.map((c) => _cardKey(c.number)).toSet();
return held.where((n) => !account.contains(n)).toList();
}
List<String> get _cardsOnlyOnAccount {
final held = _chargerCardList;
if (held == null) return const [];
return [
for (final c in _rfidCards)
if (!held.contains(_cardKey(c.number))) c.number,
];
}
/// Writing one card and then reading the list back, which is the only thing
/// that says whether the write landed. Shared by both ways of adding one, so
/// the tap and the typed number cannot end up judging their answers
/// differently.
///
/// The name is whatever is in the box, and an empty box is not a missing name:
/// it is the server's own convention — "RFID" and the card's last four digits
/// — and leaving it to the server is what keeps the two ways of adding a card
/// from drifting into two naming conventions.
Future<void> _writeCard(String sn, String number) async {
final res = await apiClient.saveAnkerRfidCard(sn, number, _newCardName.text.trim());
if (!mounted) return;
setState(() {
_rfidWritten[sn] = res.cards;
if (!res.present) {
_rfidError = t("charging.rfid.notAdded");
} else {
_newCardNumber.clear();
_newCardName.clear();
}
});
}
/// Adding a card, and then believing the list rather than the answer: Anker's
/// write endpoint is undocumented, so a 200 from it proves nothing on its own.
Future<void> _addRfidCard() async {
final sn = _detailSn;
final number = _newCardNumber.text.trim();
if (sn.isEmpty || number.isEmpty || _rfidBusy.isNotEmpty) return;
setState(() {
_rfidBusy = "new";
_rfidError = null;
});
try {
await _writeCard(sn, number);
} catch (e) {
if (mounted) setState(() => _rfidError = "$e");
} finally {
if (mounted) setState(() => _rfidBusy = "");
}
}
/// The reader's own twenty seconds, as a value rather than as a side effect on
/// the form: one caller wants the number in the box, the other wants to write
/// it. The caller owns [_rfidBusy], so the tap-and-save button can hold it
/// across the write that follows and nothing re-enables between the two.
Future<String> _readCardAtCharger(String sn) async {
setState(() => _rfidCountdown = 20);
_rfidTick?.cancel();
_rfidTick = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _rfidCountdown = (_rfidCountdown - 1).clamp(0, 20));
});
try {
final res = await apiClient.scanAnkerRfidCard(sn);
return res.tapped ? res.card : "";
} finally {
_rfidTick?.cancel();
_rfidTick = null;
if (mounted) setState(() => _rfidCountdown = 0);
}
}
/// Asking the charger to read a card, which is what the Anker app's second way
/// of adding one does: the reader opens for twenty seconds, and whatever is
/// held against it comes back as a number. Nothing is written by this — the
/// card lands in the form, and adding it is still a decision.
Future<void> _scanRfidCard() async {
final sn = _detailSn;
if (sn.isEmpty || _rfidBusy.isNotEmpty) return;
setState(() {
_rfidBusy = "scan";
_rfidError = null;
});
try {
final card = await _readCardAtCharger(sn);
if (!mounted) return;
setState(() {
if (card.isNotEmpty) {
_newCardNumber.text = card;
} else {
_rfidError = t("charging.rfid.tapNone");
}
});
} catch (e) {
if (mounted) setState(() => _rfidError = "$e");
} finally {
if (mounted) setState(() => _rfidBusy = "");
}
}
/// The same tap, carried through to the end: the reader opens, and whatever is
/// held against it is written without a second press. Enrolling a card happens
/// at the charger with the card in your hand — the walk back to the keyboard to
/// press Add was the whole cost of the two-step version.
///
/// The number lands in the box on the way past, so a write that fails leaves
/// something to look at and retry rather than a card nobody can name.
Future<void> _tapAndSaveRfidCard() async {
final sn = _detailSn;
if (sn.isEmpty || _rfidBusy.isNotEmpty) return;
setState(() {
_rfidBusy = "tapSave";
_rfidError = null;
});
try {
final card = await _readCardAtCharger(sn);
if (!mounted) return;
if (card.isEmpty) {
setState(() => _rfidError = t("charging.rfid.tapNone"));
return;
}
setState(() => _newCardNumber.text = card);
await _writeCard(sn, card);
} catch (e) {
if (mounted) setState(() => _rfidError = "$e");
} finally {
if (mounted) setState(() => _rfidBusy = "");
}
}
/// The charger's own list, asked of the device rather than of the account. A
/// card the account has forgotten still opens the charger until the device is
/// told otherwise, and no other view on this page would say so.
Future<void> _readChargerCards() async {
final sn = _detailSn;
if (sn.isEmpty || _rfidBusy.isNotEmpty) return;
setState(() {
_rfidBusy = "charger";
_rfidError = null;
});
try {
final cards = await apiClient.getAnkerChargerCards(sn);
// An empty answer is an answer — a charger with no cards on it — so the
// list is stored either way, and the card draws it rather than the button.
if (mounted) setState(() => _chargerCards[sn] = cards.map(_cardKey).toList());
} catch (e) {
if (mounted) setState(() => _rfidError = "$e");
} finally {
if (mounted) setState(() => _rfidBusy = "");
}
}
/// Removing one asks first — a card that is gone can only be put back by
/// whoever still has it in their hand.
Future<void> _removeRfidCard(RfidCard card) async {
final sn = _detailSn;
if (sn.isEmpty || card.number.isEmpty || _rfidBusy.isNotEmpty) return;
if (!await _confirm(t("charging.rfid.removeConfirm", params: {"name": card.name}))) return;
setState(() {
_rfidBusy = card.number;
_rfidError = null;
});
try {
final res = await apiClient.deleteAnkerRfidCard(sn, card.number);
if (mounted) {
setState(() {
_rfidWritten[sn] = res.cards;
if (res.present) _rfidError = t("charging.rfid.notRemoved");
});
}
} catch (e) {
if (mounted) setState(() => _rfidError = "$e");
} finally {
if (mounted) setState(() => _rfidBusy = "");
}
}
@override
Widget build(BuildContext context) {
final dev = _dev;
// Both transports that read the charger can also be told things, so both get
// a settings card — but they can be told very different things. Modbus has
// four registers; the cloud has the charger's whole settings group, which is
// why that half is drawn from a table.
final hasSettings = _connected &&
((_isModbus && dev != null && _settingRows(dev).isNotEmpty) ||
(_isCloud && _mqttBlocks.isNotEmpty));
final cards = <String, Widget?>{
"control": _active && _connected ? _controlCard(context) : null,
"rfid": _selectedCharger == null ? null : _rfidCard(context),
"settings": hasSettings ? _settingsCard(context, dev) : 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: [
// Which charger these buttons act on. The card said nothing about that
// before: the name is two cards further down, and the picture is the
// fastest way to tell two chargers on one account apart.
if (_ctlImageUrl.isNotEmpty || _ctlChargerName.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Row(children: [
// A product shot is a URL from the service, and a URL can 404. The
// picture simply stops being drawn rather than leaving a hole.
if (_ctlImageUrl.isNotEmpty) ...[
Image.network(_ctlImageUrl,
width: 44,
height: 44,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
const SizedBox(width: 10),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_ctlChargerName.isNotEmpty)
Text(_ctlChargerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
Text(_serial.text.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11, color: muted)),
],
),
),
]),
),
],
const SizedBox(height: 12),
Row(children: [
// And named for whichever of the two it is showing. The tile was called
// "Connector" from when OCPP was the only thing it read: an OCPP
// connector state is what that word means. Reading the charger's own
// snapshot it holds the charger's own status — the same statusDesc the
// readings card shows as "Charging status", so it takes that card's
// name for it rather than a second name for one value. The energy tile
// has the same two readings and the same problem.
Expanded(
child: _MetricTile(
value: _ctl!.statusLabel,
label: _readsDevice
? t("charging.modbus.chargingStatus")
: t("charging.control.status"),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricTile(
value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh",
label: _readsDevice
? t("charging.modbus.sessionEnergy")
: 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")),
),
),
]),
// The limit, only where nothing else owns it. Both transports that read
// the charger have a settings card now, and on both the limit is the
// charger's own ceiling — the same register over Modbus, the same wire
// field over the cloud. The same slider in two cards was that one value
// twice.
//
// OCPP is the exception and keeps it: a charging profile is not a
// setting the charger reports, so there is no settings card to move it
// to. Clearing it is OCPP's alone too — the cloud sets a ceiling and has
// no message for "no ceiling".
if (!_readsDevice) ...[
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")),
),
),
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")),
),
),
],
// Rebooting the charger: an OCPP reset, or the cloud's own restart
// message, which is the way to reach a charger that is on neither a CSMS
// nor the LAN. No register does it, so Modbus is the one mode without
// the button.
if (!_readsDevice || _isCloud) ...[
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)),
// Over the cloud there is nothing to confirm it with: the
// charger that would answer is the one rebooting.
if (_isCloud) ...[
const SizedBox(height: 4),
Text(t("charging.control.restartCloudHint"),
style: const TextStyle(fontSize: 11, 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")),
),
),
]),
],
),
),
],
],
);
}
/// The charger the buttons act on, as a picture and a name.
///
/// The control card drives whichever serial is in force, which is not always
/// the record highlighted in the list beside it, so it identifies its charger
/// by that serial rather than by the selection. Two sources carry the same
/// product shot: the account's own charger list, and the live half held per
/// provider. Either will do; the account's is the one that arrives without the
/// home list having been opened.
String get _ctlImageUrl {
final sn = _serial.text.trim();
if (sn.isEmpty) return "";
for (final c in _accountChargers) {
if (c.sn == sn && c.imageUrl.isNotEmpty) return c.imageUrl;
}
return _live[sn]?.imageUrl ?? "";
}
String get _ctlChargerName {
final sn = _serial.text.trim();
if (sn.isEmpty) return "";
for (final c in _accountChargers) {
if (c.sn == sn && c.name.isNotEmpty) return c.name;
}
return _live[sn]?.name ?? "";
}
/// The cards that may start a charge without a phone. Directly under the
/// control card because it is the same question — who may use this charger —
/// asked of a person rather than of a button.
Widget _rfidCard(BuildContext context) {
final muted = DriverVault.muted(context);
final charger = _selectedCharger!;
final cards = _rfidCards;
final view = _rfidView;
final held = _chargerCardList;
return _FoldCard(
title: t("charging.rfid.title"),
open: _isOpen("rfid"),
onToggle: () => _toggleCard("rfid"),
badge: cards.isEmpty ? null : _NeutralBadge(label: "${cards.length}"),
action: TextButton(
onPressed: _detailsLoading.isNotEmpty ? null : () => _loadDetails(force: true),
child: Text(_detailsLoading.isNotEmpty ? t("common.loading") : t("charging.info.refresh")),
),
children: [
const SizedBox(height: 4),
Text(charger.name, style: TextStyle(fontSize: 11, color: muted)),
if (_rfidError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_rfidError!,
style: const TextStyle(fontSize: 12, color: DriverVault.danger)),
),
// A service that does not answer for cards, an account that may not read
// them, and a charger with none on it are three different answers, and
// each is said in its own words.
const SizedBox(height: 8),
if (view == null && cards.isEmpty)
Text(t("charging.rfid.unsupported"), style: TextStyle(fontSize: 12, color: muted))
else if ((view?.error.isNotEmpty ?? false) && cards.isEmpty)
Text(view!.error, style: TextStyle(fontSize: 12, color: muted))
else if (cards.isEmpty)
Text(t("charging.rfid.none"), style: TextStyle(fontSize: 12, color: muted))
else
for (final card in cards) _rfidCardRow(context, card, muted),
// What the charger itself holds. Everything above is the account's copy;
// this asks the device, which is the half that actually decides whether a
// card opens the charger.
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _rfidBusy.isNotEmpty ? null : _readChargerCards,
child: Text(_rfidBusy == "charger"
? t("common.loading")
: t("charging.rfid.readCharger")),
),
),
if (held != null) ...[
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.rfid.chargerTitle"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
held.isEmpty ? t("charging.rfid.chargerNone") : held.join(", "),
style: held.isEmpty
? TextStyle(fontSize: 11, color: muted)
: DriverVault.mono(context, size: 11),
),
// Only drawn when the two lists actually disagree: agreement is
// the ordinary case and does not need saying twice.
if (_cardsOnlyOnCharger.isNotEmpty || _cardsOnlyOnAccount.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
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.rfid.driftTitle"),
style: DriverVault.mono(context,
size: 10,
weight: FontWeight.w600,
color: DriverVault.warning)
.copyWith(letterSpacing: 1.4)),
if (_cardsOnlyOnCharger.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t("charging.rfid.onlyOnCharger",
params: {"cards": _cardsOnlyOnCharger.join(", ")}),
style: const TextStyle(fontSize: 11),
),
),
if (_cardsOnlyOnAccount.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t("charging.rfid.onlyOnAccount",
params: {"cards": _cardsOnlyOnAccount.join(", ")}),
style: const TextStyle(fontSize: 11),
),
),
],
),
),
],
const SizedBox(height: 6),
Text(t("charging.rfid.chargerHint"),
style: TextStyle(fontSize: 11, color: muted)),
],
),
),
],
// Adding one. The number is the card itself, so it is the only field that
// is required; a card added without a name gets the one the Anker app
// would have given it.
const SizedBox(height: 10),
_ReadingSection(
heading: t("charging.rfid.addTitle"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _newCardNumber,
autocorrect: false,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("charging.rfid.numberPlaceholder"),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
TextField(
controller: _newCardName,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("charging.rfid.namePlaceholder"),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _newCardNumber.text.trim().isEmpty || _rfidBusy.isNotEmpty
? null
: _addRfidCard,
child: Text(
_rfidBusy == "new" ? t("common.loading") : t("charging.rfid.add")),
),
),
// The other way to fill that field in: hold the card against the
// charger. The reader opens for twenty seconds and the number
// arrives on its own, which beats reading it off the card.
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _rfidBusy.isNotEmpty ? null : _scanRfidCard,
child: Text(_rfidBusy == "scan"
? t("charging.rfid.tapping", params: {"n": _rfidCountdown})
: t("charging.rfid.tap")),
),
),
// The same tap without the second press. Filled, because it is the
// one somebody standing at the charger wants; the button above
// stays for the times the number is wanted without the card being
// added.
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _rfidBusy.isNotEmpty ? null : _tapAndSaveRfidCard,
child: Text(_rfidBusy == "tapSave"
? t("charging.rfid.tapping", params: {"n": _rfidCountdown})
: t("charging.rfid.tapSave")),
),
),
const SizedBox(height: 6),
Text(
_rfidBusy == "scan" || _rfidBusy == "tapSave"
? t("charging.rfid.tapHint")
: t("charging.rfid.tapSaveHint"),
style: TextStyle(fontSize: 11, color: muted),
),
],
),
),
const SizedBox(height: 10),
Text(t("charging.rfid.inferred"), style: TextStyle(fontSize: 11, color: muted)),
],
);
}
Widget _rfidCardRow(BuildContext context, RfidCard card, Color muted) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(card.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: _rfidBusy.isNotEmpty ? null : () => _removeRfidCard(card),
child: Text(
_rfidBusy == card.number ? t("common.loading") : t("charging.rfid.remove"),
style: const TextStyle(fontSize: 12, color: DriverVault.danger),
),
),
]),
_PairList(
rows: [
(t("charging.info.fields.cardNumber"), card.number),
if (card.added.isNotEmpty)
(t("charging.info.fields.added"), _viewTimeValue(card.added)),
],
breakLong: true,
),
],
),
);
}
/// What the charger is set to, as it reports it back. Its own card under the
/// control one: these are the values those buttons write, so they are read
/// right after pressing them.
Widget _settingsCard(BuildContext context, ChargerStatus? dev) {
final muted = DriverVault.muted(context);
return _FoldCard(
title: t("charging.modbus.settingsTitle"),
open: _isOpen("settings"),
onToggle: () => _toggleCard("settings"),
children: [
if (_isModbus && dev != null) ..._modbusSettings(context, dev, muted),
if (_isCloud) ..._cloudSettings(context, dev, muted),
if (_ctlError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_ctlError!,
style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
],
);
}
/// Modbus has four writable registers, so its half is four hand-written
/// controls rather than a table.
List<Widget> _modbusSettings(BuildContext context, ChargerStatus dev, Color muted) {
final boostOn = dev.settingFlag("boost") ?? false;
final reported = _modbusSettingsReported(dev);
return [
// Current limit. The slider says what it will do at the floor, because
// 6 A is a pause and not a slow charge.
const SizedBox(height: 12),
_ReadingSection(
heading: t("charging.modbus.maxCurrentSet"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Spacer(),
Text("$_draftAmps A",
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
]),
Slider(
value: _draftAmps.toDouble().clamp(_kLimitFloor.toDouble(), _limitCeiling.toDouble()),
min: _kLimitFloor.toDouble(),
max: _limitCeiling.toDouble(),
divisions: (_limitCeiling - _kLimitFloor).clamp(1, 200),
label: "$_draftAmps A",
onChanged: (v) => setState(() => _draftAmps = v.round()),
),
Row(children: [
Expanded(
child: Text(t("charging.modbus.limitFloorHint", params: {"amps": _kLimitFloor}),
style: TextStyle(fontSize: 11, color: muted)),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _draftAmps}),
child: Text(t("charging.modbus.apply")),
),
]),
],
),
),
// Phase count and boost both write a single register, so they are sent on
// the change itself rather than through an Apply.
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.phaseSetting"),
child: DropdownButtonFormField<int>(
initialValue: _draftPhase,
isExpanded: true,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final v in [0, 1, 2])
DropdownMenuItem(value: v, child: Text(t("charging.modbus.phaseSet$v"))),
],
onChanged: _busy == "phase"
? null
: (v) {
if (v == null) return;
setState(() => _draftPhase = v);
_action("phase", {"phase": v});
},
),
),
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.boostSet"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("charging.modbus.boostHint"), style: TextStyle(fontSize: 11, color: muted)),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: boostOn
? OutlinedButton(
onPressed:
_busy == "boost" ? null : () => _action("boost", {"on": false}),
child: Text(t("charging.modbus.turnOff")),
)
: FilledButton(
onPressed:
_busy == "boost" ? null : () => _action("boost", {"on": true}),
child: Text(t("charging.modbus.turnOn")),
),
),
],
),
),
// The charger falls back to its own strategy when nothing writes within
// this, so it is a setting worth reaching.
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.timeout"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: TextFormField(
// Re-seeded only when the charger reports a different value,
// so typing is never wiped by a refresh that changed nothing.
key: ValueKey("timeout-${dev.settingInt("timeoutSeconds")}"),
initialValue: "$_draftSeconds",
keyboardType: TextInputType.number,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
onChanged: (v) =>
setState(() => _draftSeconds = int.tryParse(v.trim()) ?? 0),
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: _busy == "timeout" || _draftSeconds < 10
? null
: () => _action("timeout", {"seconds": _draftSeconds}),
child: Text(t("charging.modbus.apply")),
),
]),
const SizedBox(height: 6),
Text(t("charging.modbus.timeoutHint", params: {"n": 10}),
style: TextStyle(fontSize: 11, color: muted)),
],
),
),
if (reported.isNotEmpty) ...[
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.settingsReported"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PairList(rows: reported),
const SizedBox(height: 6),
Text(t("charging.modbus.settingsReportedHint"),
style: TextStyle(fontSize: 11, color: muted)),
],
),
),
],
];
}
/// The cloud's half. One section per block, and one write per section: the
/// charger takes a command whole, so its fields are sent together and Apply is
/// per block rather than per control.
List<Widget> _cloudSettings(BuildContext context, ChargerStatus? dev, Color muted) {
final reported = dev == null ? const <(String, String)>[] : _mqttSettingsReported(dev);
return [
const SizedBox(height: 12),
Text(t("charging.modbus.cloudSettingsHint"),
style: TextStyle(fontSize: 11, color: muted)),
for (final block in _mqttBlocks) ...[
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.${block.title}"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final f in block.fields) _settingControl(context, f, muted),
if (block.warning.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("charging.modbus.${block.warning}"),
style: const TextStyle(fontSize: 11, color: DriverVault.warning)),
),
// Nothing to apply until something differs from what the charger
// reported, so the button says so by being off.
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (_blockDirty(block))
TextButton(
onPressed: _mqttBusy.isNotEmpty ? null : () => _resetMqttBlock(block),
child: Text(t("charging.modbus.reset"),
style: TextStyle(fontSize: 12, color: muted)),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _mqttBusy.isNotEmpty || !_blockDirty(block) || !_blockValid(block)
? null
: () => _applyMqttBlock(block),
child: Text(_mqttBusy == block.id
? t("common.loading")
: t("charging.modbus.apply")),
),
],
),
],
),
),
],
// The settings group's remainder: reported, and with no command to write
// them.
if (reported.isNotEmpty) ...[
const SizedBox(height: 8),
_ReadingSection(
heading: t("charging.modbus.settingsReported"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PairList(rows: reported),
const SizedBox(height: 6),
Text(t("charging.modbus.cloudSettingsReportedHint"),
style: TextStyle(fontSize: 11, color: muted)),
],
),
),
],
];
}
/// One control of one block, drawn for whichever kind of setting it is.
Widget _settingControl(BuildContext context, _SetField f, Color muted) {
final label = t("charging.modbus.${f.label}");
switch (f.kind) {
// A slider needs the width, so its row stacks: the label and the value it
// is at on one line, the track under them.
case _SetKind.slider:
final max = (f.max ?? _limitCeiling).toDouble();
final min = f.min.toDouble();
final value = ((_mqttDraft[f.key] as num?) ?? min).toDouble().clamp(min, max);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))),
Text("${value.round()} ${f.unit}",
style: DriverVault.mono(context, size: 12, weight: FontWeight.w600)),
]),
Slider(
value: value,
min: min,
max: max,
divisions: ((max - min) / f.step).round().clamp(1, 200),
label: "${value.round()} ${f.unit}",
onChanged: (v) => setState(() => _mqttDraft[f.key] = v.round()),
),
if (f.hint.isNotEmpty)
Text(t("charging.modbus.${f.hint}", params: {"amps": f.min}),
style: TextStyle(fontSize: 11, color: muted)),
],
),
);
// A switch reads as what it is set to, not as a verb: the card is a form,
// and the control says the value it will send rather than the action it
// would take.
case _SetKind.toggle:
return Row(children: [
Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))),
Switch(
value: _mqttDraft[f.key] == true,
onChanged: (v) => setState(() => _mqttDraft[f.key] = v),
),
]);
case _SetKind.option:
final current = (_mqttDraft[f.key] as num?)?.toInt();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))),
const SizedBox(width: 8),
SizedBox(
width: 170,
child: DropdownButtonFormField<int>(
initialValue: current,
isExpanded: true,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final v in _fieldOptions(f))
DropdownMenuItem(
value: v,
child: Text(_enumLabel(f.enumPrefix, v) ?? "$v",
overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() => _mqttDraft[f.key] = v),
),
),
]),
);
case _SetKind.number:
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Expanded(child: Text(label, style: TextStyle(fontSize: 12, color: muted))),
const SizedBox(width: 8),
SizedBox(
width: 110,
child: TextFormField(
// Re-seeded only when the charger reports a different value, so
// typing is never wiped by a refresh that changed nothing.
key: ValueKey("num-${f.key}-${_mqttBase[f.key]}"),
initialValue: "${_mqttBase[f.key] ?? ""}",
keyboardType: TextInputType.number,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
suffixText: f.unit,
),
onChanged: (v) => setState(() => _mqttDraft[f.key] = int.tryParse(v.trim())),
),
),
]),
);
case _SetKind.window:
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: muted)),
const SizedBox(height: 4),
Row(children: [
TimeField(
value: "${_mqttDraft[f.from] ?? ""}",
label: t("charging.modbus.windowStart"),
onChanged: (v) => setState(() => _mqttDraft[f.from] = v),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text("", style: TextStyle(fontSize: 12, color: muted)),
),
TimeField(
value: "${_mqttDraft[f.to] ?? ""}",
label: t("charging.modbus.windowEnd"),
onChanged: (v) => setState(() => _mqttDraft[f.to] = v),
),
]),
],
),
);
}
}
/// 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 = [
("L1L2", s.number("voltageL1L2")),
("L2L3", s.number("voltageL2L3")),
("L3L1", 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)) ...[
Builder(builder: (context) {
final (rows, items) = _viewRowGroups(view.attrs);
return _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 (rows.isEmpty && items.isEmpty)
Text(t("charging.info.viewEmpty"), style: TextStyle(fontSize: 11, color: muted))
else ...[
// What the view says about itself. A field the card has a
// name for is drawn like every other named row; one it does
// not is drawn under its own key, in the key's own typeface,
// so the two are never mistaken for each other.
if (rows.isNotEmpty)
_PairList(
rows: [for (final r in rows) (r.label, r.value)],
rawLabels: {for (final r in rows) if (!r.named) r.label},
breakLong: true,
),
// And the records it answered with, a block each under the
// record's own name.
for (final item in items)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label,
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w600)),
_PairList(
rows: [for (final r in item.rows) (r.label, r.value)],
rawLabels: {
for (final r in item.rows)
if (!r.named) r.label
},
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 attrs = live?.attrs ?? const <String, String>{};
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),
// The fields the service sent under its own names that this group
// has a name for, after the ones DriverVault stores itself.
..._namedAttrRows(id, attrs, live),
],
),
];
}
/// A flag the service sends as true/false or 1/0, read out in words. Anything
/// else is relayed as it arrived rather than forced into a yes.
String _attrBoolLabel(String v) => switch (v.toLowerCase()) {
"true" || "1" => t("common.yes"),
"false" || "0" => t("common.no"),
_ => v,
};
/// Two values that are the same fact written two ways: 7C:E9:13:73:C2:38 is the
/// address 7CE91373C238 with colons in it.
bool _sameAttrValue(String a, String b) {
String norm(String v) => v.toLowerCase().replaceAll(RegExp(r"[^a-z0-9]"), "");
return norm(a).isNotEmpty && norm(a) == norm(b);
}
/// The named fields that belong in one group, for a charger whose service sent
/// them. Unlike the rows above, a field missing here draws nothing: these are
/// one service's fields, and a row of dashes on a charger from another service
/// would say a field is absent when it was never a field at all.
List<(String, String)> _namedAttrRows(
String groupId, Map<String, String> attrs, ProviderCharger? live) {
final rows = <(String, String)>[];
for (final entry in _kNamedAttrs.entries) {
final spec = entry.value;
if (spec.group != groupId) continue;
final raw = attrs[entry.key];
if (raw == null || raw.isEmpty) continue;
// The only row the card already draws in another form.
if (spec.sameAs == "bleMac" && _sameAttrValue(raw, live?.bleMac ?? "")) continue;
rows.add((t("charging.info.${spec.label}"), spec.isBool ? _attrBoolLabel(raw) : raw));
}
return rows;
}
/// 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
.where((key) => !_isEchoedAttr(key) && !_kNamedAttrs.containsKey(key))
.toList()
..sort();
return [for (final key in keys) (key, attrs[key]!)];
}
// --- the per-charger views, read a record at a time ------------------------
/// A unix second the cloud sent as a bare number, read as a date. Zero is how
/// these views say "never" rather than 1970, so it is left as it arrived.
String _viewTimeValue(String v) {
final n = num.tryParse(v.trim());
if (n == null || n <= 0) return v;
return formatDateTime(
DateTime.fromMillisecondsSinceEpoch((n * 1000).round()));
}
/// One field of one view: named when the table above has a name for it, and
/// keyed by the cloud's own key when it does not.
_ViewRow _viewRow(String key, String lookup, String value) {
// A field inside a list is looked up under its own list first and under the
// bare field name second, so an email is an email whichever list it came in.
final spec = _kViewFields[lookup] ?? _kViewFields[lookup.split("].").last];
return _ViewRow(
spec == null ? key : t("charging.info.fields.${spec.label}"),
spec != null && spec.time ? _viewTimeValue(value) : value,
spec != null,
);
}
/// One view's fields, with the lists it answered with grouped a record at a
/// time. list[0].* and list[1].* are two RFID cards, two endpoints, two
/// anything: read as one alphabetical run of indexed keys they are unreadable,
/// and as a block each — under the record's own name, when it has one — they
/// are the list the view actually sent. The record's name becomes the heading
/// rather than a row, so it is said once.
(List<_ViewRow>, List<_ViewItem>) _viewRowGroups(Map<String, String> attrs) {
final rows = <_ViewRow>[];
final items = <String, (int, String, String, List<_ViewRow>)>{};
final keys = attrs.keys.toList()..sort();
for (final key in keys) {
final m = _kViewListKey.firstMatch(key);
if (m == null) {
rows.add(_viewRow(key, key, attrs[key]!));
continue;
}
final list = m.group(1)!;
final index = int.parse(m.group(2)!);
final field = m.group(3)!;
final id = "$list[$index]";
if (!items.containsKey(id)) {
var titleKey = "";
for (final f in _kViewItemTitles) {
if ((attrs["$id.$f"] ?? "").isNotEmpty) {
titleKey = "$id.$f";
break;
}
}
final label = titleKey.isEmpty ? "#${index + 1}" : attrs[titleKey]!;
items[id] = (index, label, titleKey, <_ViewRow>[]);
}
final item = items[id]!;
if (key == item.$3) continue;
item.$4.add(_viewRow(key, "$list[].$field", attrs[key]!));
}
final ordered = items.values.toList()..sort((a, b) => a.$1.compareTo(b.$1));
return (rows, [for (final i in ordered) _ViewItem(i.$2, i.$4)]);
}
/// 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;
/// Labels that are the cloud's own key rather than a name of ours, drawn in
/// the typeface the rest of the app reads keys in so the two are never
/// mistaken for each other.
final Set<String> rawLabels;
const _PairList({required this.rows, this.breakLong = false, this.rawLabels = const {}});
@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: rawLabels.contains(label)
? DriverVault.mono(context, size: 11, color: muted)
: 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)),
],
),
);
}
}