import "package:flutter/material.dart"; import "package:shared_preferences/shared_preferences.dart"; import "../format.dart"; import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../theme.dart"; import "car_view_sheet.dart" show arrangeKeys; import "charger_import_sheet.dart"; /// Charging & map screen, mirroring the web Charging.vue. Two tabs split the /// public charging network (a presentational discovery map + demo session + /// nearby public stations) from the user's own chargers and their real control. /// /// There is no live public-charging API yet — the session and station lists are /// placeholders, exactly as on the web. The home tab is not: those are chargers /// the user imported from a service they connected, and the cards beside them /// drive a real charger through the Anker Solix control endpoints once a control /// mode is picked in Settings → Integrations. /// The tabs this page can show, in the order they appear when the account has no /// arrangement of its own. Mirrors ALL_CHARGER_TABS in the web Charging.vue; the /// server rejects nothing here, but a key outside this set is dropped on read. const List kChargerTabKeys = ["public", "home"]; /// The home tab's cards, in their default order. Controls first, because acting /// on the charger is what the page is opened for; the connection below it, /// because it is touched once and then left alone. const List kChargerCardKeys = ["control", "connection", "readings", "info"]; /// Which cards are folded away, kept on the device rather than on the profile: /// it is a per-device reading habit, not an account setting. Keyed by card, so a /// fold outlives switching charger. const String _kCollapsedKey = "cc_chargingCollapsed"; /// The serial the control cards are pointed at. Remembered so the page comes /// back up on the charger it was left on, the way the web does. const String _kSerialKey = "cc_ctlSerial"; class ChargingScreen extends StatefulWidget { const ChargingScreen({super.key}); @override State 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 { /// 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 _tabKeys = List.of(kChargerTabKeys); List _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 _loadArrangement() async { try { final me = await apiClient.getMe(); if (!mounted) return; setState(() { _tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder); _cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder); }); } catch (_) { // Keep the defaults. } } Future _arrange() async { final result = await showChargingArrangeSheet( context, tabs: _tabKeys, cards: _cardKeys, ); if (result == null || !mounted) return; // The tab in view is followed through the rearrangement rather than left at // its index: moving a tab should not switch which one you are reading. final current = _tabKeys[_tab.clamp(0, _tabKeys.length - 1)]; setState(() { _tabKeys = result.tabs; _cardKeys = result.cards; _tab = result.tabs.indexOf(current).clamp(0, result.tabs.length - 1); _orderError = null; }); try { await apiClient.updateMe({ "chargerTabOrder": result.tabs, "chargerCardOrder": result.cards, }); } catch (e) { // It didn't stick; say so and put the stored arrangement back rather than // leaving the page showing an order the server does not have. if (!mounted) return; setState(() => _orderError = "$e"); _loadArrangement(); } } Color _toneColor(String tone) { switch (tone) { case "due": return DriverVault.warning; case "fault": return DriverVault.danger; default: return DriverVault.success; } } String _stationStatus(_Station s) => s.avail == 0 ? t("charging.stations.full") : t("charging.stations.free", params: {"avail": s.avail, "total": s.total}); Widget _tabBody(String key, {required bool active}) { switch (key) { case "home": return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active); default: return _PublicTab( key: const ValueKey("public"), stations: _stations, selected: _selectedStation, onSelect: (id) => setState(() => _selectedStation = id), toneColor: _toneColor, statusFor: _stationStatus, ); } } @override Widget build(BuildContext context) { final muted = DriverVault.muted(context); final index = _tab.clamp(0, _tabKeys.length - 1); return Scaffold( appBar: AppBar( title: Text(t("charging.title")), actions: [ IconButton( icon: const Icon(Icons.swap_vert), tooltip: t("charging.arrange.title"), onPressed: _arrange, ), ], ), body: ListView( padding: const EdgeInsets.all(12), children: [ // Eyebrow + title header, matching the web layout. Text(t("charging.eyebrow"), style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) .copyWith(letterSpacing: 2.2)), const SizedBox(height: 2), Text(t("charging.title"), style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)), const SizedBox(height: 16), if (_orderError != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(_orderError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), ), // Tabs: public network vs. the user's own chargers. _TabBar( tabs: [for (final key in _tabKeys) t("charging.tabs.$key")], index: index, onChanged: (i) => setState(() => _tab = i), ), const SizedBox(height: 16), // IndexedStack keeps each tab's state alive across switches (v-show parity). // Keyed, so rearranging the bar moves the tabs rather than rebuilding // whichever widget now sits at that index from scratch. IndexedStack( index: index, children: [ for (var i = 0; i < _tabKeys.length; i++) _tabBody(_tabKeys[i], active: i == index), ], ), const SizedBox(height: 24), ], ), ); } } // --- Arranging the page ------------------------------------------------------ /// What the arrange sheet returns: the two arrangements, saved together. class ChargingArrangement { final List tabs; final List 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 showChargingArrangeSheet( BuildContext context, { required List tabs, required List cards, }) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ChargingArrangeSheet(tabs: tabs, cards: cards), ); } class _ChargingArrangeSheet extends StatefulWidget { final List tabs; final List cards; const _ChargingArrangeSheet({required this.tabs, required this.cards}); @override State<_ChargingArrangeSheet> createState() => _ChargingArrangeSheetState(); } class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> { late final List _tabs = List.of(widget.tabs); late final List _cards = List.of(widget.cards); /// The card's own heading, so the sheet names each row the way the page does. String _cardLabel(String key) => switch (key) { "control" => t("charging.control.title"), "connection" => t("charging.control.connectionTitle"), "readings" => t("charging.modbus.title"), _ => t("charging.info.title"), }; @override Widget build(BuildContext context) { final muted = DriverVault.muted(context); return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: DriverVault.sheetBottomInset(context), ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t("charging.arrange.title"), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(t("car.viewPicker.reorderHint"), style: TextStyle(fontSize: 12, color: muted)), const SizedBox(height: 16), _section(t("charging.arrange.tabs")), _reorder(_tabs, (k) => t("charging.tabs.$k"), (from, to) => setState(() => _tabs.insert(to, _tabs.removeAt(from)))), const SizedBox(height: 16), _section(t("charging.arrange.cards")), _reorder(_cards, _cardLabel, (from, to) => setState(() => _cards.insert(to, _cards.removeAt(from)))), const SizedBox(height: 16), Row(children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(context), child: Text(t("common.cancel")), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: () => Navigator.pop(context, ChargingArrangement(_tabs, _cards)), child: Text(t("common.save")), ), ), ]), ], ), ), ); } Widget _section(String label) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), ); /// A short reorderable list of labelled rows, each dragged by its own handle. /// shrinkWrap because the sheet scrolls, not the list. Widget _reorder(List 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 tabs; final int index; final ValueChanged 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 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)], Icon(open ? Icons.expand_more : Icons.chevron_right, size: 20, color: DriverVault.muted(context)), ], ), ), ), if (action != null) action!, ], ), 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 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 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 onSelect; final Color Function(String) toneColor; const _MapPanel({ required this.stations, required this.selected, required this.onSelect, required this.toneColor, }); // Pin positions as fractions of the panel, mirroring the web's x/y percents. static const _pos = { "sc": Offset(0.47, 0.34), "evgo": Offset(0.26, 0.60), "cp": Offset(0.70, 0.64), }; @override Widget build(BuildContext context) { final dark = DriverVault.isDark(context); final base = dark ? DriverVault.darkSunken : DriverVault.ink25; final grid = dark ? Colors.white.withValues(alpha: 0.04) : const Color(0x0B0F1E3D); return ClipRRect( borderRadius: BorderRadius.circular(DriverVault.radiusCard), child: Container( height: 320, decoration: BoxDecoration( color: base, border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(DriverVault.radiusCard), ), child: LayoutBuilder( builder: (context, c) { final w = c.maxWidth, h = c.maxHeight; return Stack( children: [ // Grid lines. CustomPaint(size: Size(w, h), painter: _GridPainter(grid)), // Roads. Positioned(left: 0, right: 0, top: h * 0.52, child: Container(height: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))), Positioned(top: 0, bottom: 0, left: w * 0.58, child: Container(width: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))), // Legend chip. Positioned( left: 12, top: 12, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).cardColor.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(999), border: Border.all(color: Theme.of(context).dividerColor), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.map_outlined, size: 13, color: DriverVault.brand600), const SizedBox(width: 5), Text(t("charging.liveMap"), style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) .copyWith(letterSpacing: 1.4)), ]), ), ), // You are here. Positioned( left: w * 0.5 - 20, top: h * 0.5 - 20, child: Tooltip( message: t("charging.youAreHere"), child: Container( width: 40, height: 40, decoration: BoxDecoration(color: DriverVault.brand600.withValues(alpha: 0.16), shape: BoxShape.circle), child: Center( child: Container( width: 14, height: 14, decoration: BoxDecoration( color: DriverVault.brand600, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), ), ), ), ), ), // Charger pins. for (final s in stations) if (_pos[s.id] != null) Positioned( left: w * _pos[s.id]!.dx - (selected == s.id ? 18 : 14), top: h * _pos[s.id]!.dy - (selected == s.id ? 36 : 28), child: _Pin( selected: selected == s.id, color: toneColor(s.tone), label: "${s.avail}/${s.total} · ${s.kw} kW", onTap: () => onSelect(s.id), ), ), ], ); }, ), ), ); } } class _Pin extends StatelessWidget { final bool selected; final Color color; final String label; final VoidCallback onTap; const _Pin({required this.selected, required this.color, required this.label, required this.onTap}); @override Widget build(BuildContext context) { final size = selected ? 36.0 : 28.0; return GestureDetector( onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (selected) Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: Theme.of(context).dividerColor), ), child: Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600)), ), Container( width: size, height: size, decoration: BoxDecoration( color: color, border: Border.all(color: Colors.white, width: 2), borderRadius: const BorderRadius.only( topLeft: Radius.circular(999), topRight: Radius.circular(999), bottomRight: Radius.circular(999), bottomLeft: Radius.circular(3), ), ), child: Icon(Icons.bolt, color: Colors.white, size: selected ? 18 : 14), ), ], ), ); } } /// Faint grid lines behind the map, echoing the web's repeating gradients. class _GridPainter extends CustomPainter { final Color color; _GridPainter(this.color); @override void paint(Canvas canvas, Size size) { final p = Paint() ..color = color ..strokeWidth = 1; const step = 44.0; for (double x = 0; x < size.width; x += step) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); } for (double y = 0; y < size.height; y += step) { canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } } @override bool shouldRepaint(covariant _GridPainter old) => old.color != color; } /// The presentational "Charging now" session card (dark brand background). class _SessionCard extends StatelessWidget { final bool charging; final VoidCallback onStop; const _SessionCard({required this.charging, required this.onStop}); @override Widget build(BuildContext context) { const from = 62, to = 80; final metrics = [ (t("charging.session.rate"), "142 kW"), (t("charging.session.added"), "+29 km"), (t("charging.session.cost"), "5,80 €"), (t("charging.session.done"), "~18 min"), ]; return Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: DriverVault.brand900, borderRadius: BorderRadius.circular(DriverVault.radiusCard), ), child: charging ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(mainAxisSize: MainAxisSize.min, children: [ Container(width: 6, height: 6, decoration: const BoxDecoration(color: DriverVault.success, shape: BoxShape.circle)), const SizedBox(width: 8), Text("${t("charging.session.chargingNow")} · Model Y", style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300) .copyWith(letterSpacing: 1.4)), ]), const SizedBox(height: 12), RichText( text: TextSpan( style: DriverVault.mono(context, size: 34, weight: FontWeight.w500, color: Colors.white), children: [ const TextSpan(text: "$from"), TextSpan( text: " % → $to%", style: DriverVault.mono(context, size: 15, weight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.7)), ), ], ), ), const SizedBox(height: 14), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: from / 100, minHeight: 8, backgroundColor: Colors.white.withValues(alpha: 0.15), valueColor: const AlwaysStoppedAnimation(DriverVault.brand400), ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (final m in metrics) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(m.$1, style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, color: DriverVault.brand300) .copyWith(letterSpacing: 1.2)), const SizedBox(height: 2), Text(m.$2, style: DriverVault.mono(context, size: 14, weight: FontWeight.w500, color: Colors.white)), ], ), ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: OutlinedButton( onPressed: onStop, style: OutlinedButton.styleFrom( foregroundColor: Colors.white, backgroundColor: Colors.white.withValues(alpha: 0.10), side: BorderSide(color: Colors.white.withValues(alpha: 0.20)), ), child: Text(t("charging.session.stop")), ), ), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(mainAxisSize: MainAxisSize.min, children: [ Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.4), shape: BoxShape.circle)), const SizedBox(width: 8), Text(t("charging.session.idle"), style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300) .copyWith(letterSpacing: 1.4)), ]), const SizedBox(height: 12), Text(t("charging.session.idleHint"), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))), ], ), ); } } // --- Home tab: the user's own chargers + real control ------------------------ /// The home half of the page: the cards that act on one charger, in whatever /// order the account arranged them, and below them the list of chargers the user /// has imported. /// /// One State for the lot, mirroring the web view: the cards all read the same /// charger, and picking one in the list is what points the control cards at it. class _HomeTab extends StatefulWidget { final List cardKeys; /// Whether this is the tab being read. Reachability costs a round trip to each /// connected service, so it is asked for when the question is actually being /// asked — not because an IndexedStack built both tabs. final bool active; const _HomeTab({super.key, required this.cardKeys, required this.active}); @override State<_HomeTab> createState() => _HomeTabState(); } class _HomeTabState extends State<_HomeTab> { // --- control --- final _serial = TextEditingController(); final _host = TextEditingController(); final _port = TextEditingController(text: "502"); final _resetPassword = TextEditingController(); /// The chargers on the linked Anker account. With them the serial is a pick /// from a list; without them (account not linked, or the cloud unreachable) /// the field stays a plain text box so a serial can still be typed by hand. List _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 _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 _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 _live = const {}; bool _liveLoading = false; bool _liveLoaded = false; String _selected = ""; // the charger the cards are about String _removing = ""; /// Which cards are folded, read once and written on every toggle. Set _collapsed = {}; bool get _active => _mode != "off"; bool get _connected => _ctl?.connected == true; bool get _isModbus => _mode == "modbus"; /// The Anker cloud path: both ends meet at the broker the charger already /// talks to, so there is nothing to address and nothing to install — only an /// account to be signed in to. It is the mode for a charger somewhere else. bool get _isCloud => _mode == "mqtt"; /// Those two read the charger itself and answer with its own snapshot, where /// OCPP answers with the session our CSMS holds. Anything both can report is /// named the same in both, so one set of readouts serves them; what each can /// be told still differs, which is what the buttons below branch on. bool get _readsDevice => _isModbus || _isCloud; /// The charger's snapshot, whichever transport read it. ChargerStatus? get _dev => _ctl?.device; bool get _canImport => _providers.any((p) => p.connected); bool get _serialInList => _accountChargers.any((c) => c.sn == _serial.text.trim()); /// A dropdown can only show a serial it has an option for, so one the account /// does not report — a charger picked from the list below, imported before the /// account was linked — falls back to the text box rather than rendering as a /// blank field with no way to read or fix it. bool get _pickingFromList => _accountChargers.isNotEmpty && !_manualSerial && (_serial.text.trim().isEmpty || _serialInList); @override void initState() { super.initState(); _init(); } @override void didUpdateWidget(_HomeTab old) { super.didUpdateWidget(old); // Switching to this tab is the moment reachability is being asked about. if (widget.active && !old.active) _loadLive(); } @override void dispose() { _serial.dispose(); _host.dispose(); _port.dispose(); _resetPassword.dispose(); super.dispose(); } Future _init() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; _serial.text = prefs.getString(_kSerialKey) ?? ""; setState(() => _collapsed = (prefs.getStringList(_kCollapsedKey) ?? const []).toSet()); await _loadHomeChargers(); await _loadProviders(); if (widget.active) await _loadLive(); await _loadMode(); if (_active) await _loadAccountChargers(); await _refresh(); } // --- folding --- bool _isOpen(String id) => !_collapsed.contains(id); Future _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 _loadMode() async { try { final v = await apiClient.getAnkerSolix(); if (mounted) setState(() => _mode = v.controlMode); } catch (_) { if (mounted) setState(() => _mode = "off"); } } Future _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 _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 _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 _loadLive({bool force = false}) async { final connected = _providers.where((p) => p.connected).toList(); if (_liveLoading || connected.isEmpty || !mounted) return; if (_liveLoaded && !force) return; // switching tabs is not a new question setState(() => _liveLoading = true); final live = {}; await Future.wait(connected.map((p) async { try { final res = await apiClient.listProviderChargers(p.id); for (final c in res.chargers) { live[c.id] = c; } } catch (_) { // A service that will not answer leaves its chargers unknown rather // than offline — this page cannot tell those two apart. } })); if (!mounted) return; setState(() { _live = live; _liveLoaded = true; _liveLoading = false; }); } Future _refresh() async { if (!mounted) return; final sn = _serial.text.trim(); if (sn.isEmpty) { setState(() => _ctl = null); return; } final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kSerialKey, sn); if (!mounted) return; setState(() => _ctlError = null); try { final c = await apiClient.getAnkerControl(sn); if (!mounted) return; setState(() { _ctl = c; // The saved address belongs to the charger, not to the form: switching // chargers has to bring its own address along rather than leave the // previous one sitting in the field, where saving would move it to the // wrong charger. _host.text = c.modbusHost; _port.text = "${c.modbusPort}"; }); } catch (e) { if (mounted) { setState(() { _ctlError = "$e"; _ctl = null; }); } } } // --- acting on the charger --- Future _action(String action, [Map 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 _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 _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 _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 _confirm(String message) async { final ok = await showDialog( 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); if (c.serial.isEmpty) return; _serial.text = c.serial; _refresh(); } Future _import() async { final charger = await showChargerImportSheet(context); if (charger == null || !mounted) return; setState(() => _homeChargers = [..._homeChargers, charger]); _select(charger); _loadLive(force: true); } Future _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 sources) => sources.map((src) { final key = "charging.info.sourceNames.$src"; final label = t(key); return label == key ? src : label; }).join(" · "); @override Widget build(BuildContext context) { final cards = { "control": _active && _connected ? _controlCard(context) : null, "connection": _active ? _connectionCard(context) : null, "readings": _readsDevice && _connected ? _readingsCard(context) : null, "info": _infoCard(context), }; return Column( children: [ for (final key in widget.cardKeys) if (cards[key] != null) ...[cards[key]!, const SizedBox(height: 16)], _chargerListCard(context), ], ); } // --- the cards ------------------------------------------------------------- /// Acting on the charger. It appears only once there is a connection to act /// over, which the card below it is where you set up. Widget _controlCard(BuildContext context) { final muted = DriverVault.muted(context); return _FoldCard( title: t("charging.control.title"), open: _isOpen("control"), onToggle: () => _toggleCard("control"), children: [ const SizedBox(height: 12), Row(children: [ Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))), const SizedBox(width: 8), Expanded( child: _MetricTile( value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", label: t("charging.control.meter"), ), ), ]), // A charger told to start can sit in "preparing" for a good while, and // these say why: it is waiting for a plug, or counting down a start // delay. Only the cloud transport can see them. if (_countdown("plugCountdownSeconds") != null || _countdown("startCountdownSeconds") != null) ...[ const SizedBox(height: 8), Row(children: [ if (_countdown("plugCountdownSeconds") != null) Expanded( child: _MetricTile( value: _countdown("plugCountdownSeconds")!, label: t("charging.modbus.plugCountdown"), ), ), if (_countdown("plugCountdownSeconds") != null && _countdown("startCountdownSeconds") != null) const SizedBox(width: 8), if (_countdown("startCountdownSeconds") != null) Expanded( child: _MetricTile( value: _countdown("startCountdownSeconds")!, label: t("charging.modbus.startCountdown"), ), ), ]), ], const SizedBox(height: 12), Row(children: [ Expanded( child: FilledButton( onPressed: _busy == "start" ? null : () => _action("start"), child: Text(t("charging.control.start")), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: _busy == "stop" ? null : () => _action("stop"), child: Text(t("charging.control.stop")), ), ), ]), const SizedBox(height: 12), Row(children: [ Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)), const Spacer(), Text("${_limitAmps.round()} A", style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)), ]), Slider( value: _limitAmps, min: 6, max: 32, divisions: 26, label: "${_limitAmps.round()} A", onChanged: (v) => setState(() => _limitAmps = v), ), Row(children: [ Expanded( child: OutlinedButton( onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}), child: Text(t("charging.control.applyLimit")), ), ), // Clearing a limit is an OCPP command. Both of the transports that // talk to the charger itself take an explicit ceiling, so there is // nothing for them to clear to. if (!_readsDevice) ...[ const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"), child: Text(t("charging.control.clearLimit")), ), ), ], ]), // Boost lasts for the current session only, and is a command the // charger itself takes — over the register map or over the cloud, but // never over OCPP. if (_readsDevice) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton( onPressed: _busy == "boost" ? null : () => _action("boost", {"on": true}), child: Text(t("charging.control.boost")), ), ), ], // Skipping a start delay is only offered while one is running, and only // the cloud transport knows that it is. if (_dev?.modeOptions.contains("skip_delay") ?? false) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton( onPressed: _busy == "skip-delay" ? null : () => _action("skip-delay"), child: Text(t("charging.control.skipDelay")), ), ), ], // Reset reboots the charger over OCPP; neither the register map nor the // cloud has an equivalent, so the button is not offered there. if (!_readsDevice) ...[ const SizedBox(height: 12), if (!_resetPrompt) SizedBox( width: double.infinity, child: OutlinedButton( onPressed: _busy == "reset" ? null : () => setState(() { _ctlError = null; _resetPassword.clear(); _resetPrompt = true; }), child: Text(t("charging.control.reset")), ), ) else // Step-up: a destructive reset requires re-entering the password. Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft, borderRadius: BorderRadius.circular(DriverVault.radiusControl), border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t("charging.control.resetConfirm"), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)), const SizedBox(height: 8), TextField( controller: _resetPassword, obscureText: true, decoration: InputDecoration( border: const OutlineInputBorder(), isDense: true, hintText: t("charging.control.resetPassword"), ), onSubmitted: (_) => _confirmReset(), ), const SizedBox(height: 8), Row(children: [ Expanded( child: OutlinedButton( onPressed: () => setState(() { _resetPrompt = false; _resetPassword.clear(); }), child: Text(t("common.cancel")), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: _busy == "reset" ? null : _confirmReset, child: Text(t("charging.control.reset")), ), ), ]), ], ), ), ], ], ); } /// Which charger, and how to reach it. Below the controls: it is touched once /// and then left alone. Widget _connectionCard(BuildContext context) { final muted = DriverVault.muted(context); final sn = _serial.text.trim(); return _FoldCard( title: t("charging.control.connectionTitle"), open: _isOpen("connection"), onToggle: () => _toggleCard("connection"), // Whether the charger is reachable is the one thing worth seeing with the // card folded, so the badge rides in the header. badge: _StatusBadge( label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"), ok: _connected, ), children: [ const SizedBox(height: 12), Row(children: [ Expanded( child: _pickingFromList ? DropdownButtonFormField( // 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 _readingSections(BuildContext context, ChargerStatus s) { final phases = _phaseRows(s); final live = _liveRows(s); final settings = _settingRows(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")), ]), 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), ], 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"))), ]); } 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 %"), ]); } 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")), ("hardware", s.text("hardware")), ("productNumber", product == null ? null : "$product"), ("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")), ("currentRange", min != null && max != null ? "$min–$max A" : null), ("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> _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); 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"), ], ]; } /// 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 _lineVoltages(ChargerStatus s) { final pairs = [ ("L1–L2", s.number("voltageL1L2")), ("L2–L3", s.number("voltageL2L3")), ("L3–L1", s.number("voltageL3L1")), ]; return [ for (final (name, v) in pairs) if (v != null && v > 10) "$name ${v.toStringAsFixed(1)} V", ]; } List _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), 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: [ Row(children: [ 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), _PairList(rows: _infoRows(charger), breakLong: true), // 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) ...[ const SizedBox(height: 10), Text( t("charging.info.rawTitle"), style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) .copyWith(letterSpacing: 1.4), ), const SizedBox(height: 4), _PairList(rows: _attrRows(charger), breakLong: true), const SizedBox(height: 4), Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)), ], ], ), ), ], 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. 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, String)> _infoRows(HomeCharger c) { final live = _liveFor(c); final pairs = <(String, String)>[ ("vendor", c.vendor), ("model", c.model), ("firmware", live?.firmware ?? ""), ("serial", c.serial), ("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")), ("siteId", live?.siteId ?? ""), ("sources", _sourcesLabel(live?.sources ?? const [])), ("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""), ("connector", c.connector), ("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 ?? ""), ("providerId", c.providerChargerId), ("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())), ]; return [ for (final (key, value) in pairs) (t("charging.info.$key"), value.isEmpty ? "—" : value), ]; } /// Everything else the service said about this charger, under its own field /// names. The rows above are the ones DriverVault has a name for; these are /// the remainder — the service documents none of them, so its own key is the /// only honest label, and renaming one here would be inventing a meaning for /// it. Sorted so the same charger reads the same way on every refresh. List<(String, String)> _attrRows(HomeCharger c) { final attrs = _liveFor(c)?.attrs ?? const {}; final keys = attrs.keys.toList()..sort(); return [for (final key in keys) (key, attrs[key]!)]; } /// The user's own chargers, and the import that fills the list. Widget _chargerListCard(BuildContext context) { final muted = DriverVault.muted(context); return _Card( padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(6, 6, 6, 4), child: Row(children: [ Expanded( child: Text( "${t("charging.stations.homeHeading")} · " "${t("charging.home.count", params: {"n": _homeChargers.length}, n: _homeChargers.length)}", style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) .copyWith(letterSpacing: 1.4), ), ), if (_canImport) TextButton(onPressed: _import, child: Text(t("charging.home.import"))), ]), ), for (final c in _homeChargers) _chargerRow(context, c), // Nothing imported yet: say what this list is for and offer the import. if (_homeChargers.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(6, 4, 6, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t("charging.home.empty"), style: TextStyle(fontSize: 13, color: muted)), const SizedBox(height: 10), if (_canImport) SizedBox( width: double.infinity, child: FilledButton( onPressed: _import, child: Text(t("charging.home.import")), ), ) else Text(t("charging.home.connectFirst"), style: TextStyle(fontSize: 12, color: muted)), ], ), ), if (_homeError != null) Padding( padding: const EdgeInsets.fromLTRB(6, 4, 6, 8), child: Text(_homeError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), ), ], ), ); } Widget _chargerRow(BuildContext context, HomeCharger c) { final muted = DriverVault.muted(context); final selected = _selectedCharger?.id == c.id; final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; // The colour of the list icon: the same green and amber the badges in the // information card use, so the list can be read at a glance without opening // anything. A charger the service says nothing about stays muted — unknown // is not offline, and a green bolt for it would be a claim. final online = _liveFor(c)?.online; final tone = online == true ? DriverVault.success : online == false ? DriverVault.warning : muted; return Container( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Expanded( child: InkWell( onTap: () => _select(c), borderRadius: BorderRadius.circular(DriverVault.radiusControl), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: selected ? DriverVault.brandTint(context) : null, borderRadius: BorderRadius.circular(DriverVault.radiusControl), ), child: Row(children: [ Container( width: 36, height: 36, decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(10)), child: Icon(Icons.bolt, size: 18, color: tone), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(c.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), if (c.subtitle.isNotEmpty) ...[ const SizedBox(height: 2), Text(c.subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: DriverVault.mono(context, size: 11, color: muted)), ], ], ), ), ]), ), ), ), IconButton( icon: const Icon(Icons.close, size: 18), color: muted, tooltip: t("charging.home.remove"), onPressed: _removing == c.id ? null : () => _remove(c), ), ], ), ); } } /// One block of the readings card: an eyebrow heading over a sunken panel. class _ReadingSection extends StatelessWidget { final String heading; final Widget child; const _ReadingSection({required this.heading, required this.child}); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, borderRadius: BorderRadius.circular(DriverVault.radiusControl), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(heading, style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) .copyWith(letterSpacing: 1.4)), const SizedBox(height: 8), child, ], ), ); } } /// Label/value rows: the label muted on the left, the value right-aligned in the /// mono face the rest of the app reads numbers in. class _PairList extends StatelessWidget { final List<(String, String)> rows; /// Let a long value wrap rather than clip. Serials and site ids are long /// enough to need it; a reading never is. final bool breakLong; const _PairList({required this.rows, this.breakLong = false}); @override Widget build(BuildContext context) { final muted = DriverVault.muted(context); return Column( children: [ for (final (label, value) in rows) Padding( padding: const EdgeInsets.only(bottom: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 4, child: Text(label, style: TextStyle(fontSize: 11, color: muted)), ), const SizedBox(width: 10), Expanded( flex: 5, child: Text( value, textAlign: TextAlign.right, maxLines: breakLong ? 3 : 1, overflow: TextOverflow.ellipsis, style: DriverVault.mono(context, size: 11), ), ), ], ), ), ], ); } } /// A small sunken metric tile (connector status / energy). class _MetricTile extends StatelessWidget { final String value; final String label; const _MetricTile({required this.value, required this.label}); @override Widget build(BuildContext context) { final muted = DriverVault.muted(context); final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration(color: sunken, borderRadius: BorderRadius.circular(DriverVault.radiusControl)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(value, style: DriverVault.mono(context, size: 14, weight: FontWeight.w600)), const SizedBox(height: 2), Text(label, style: TextStyle(fontSize: 11, color: muted)), ], ), ); } }