Control had two transports and neither fitted the ordinary customer. OCPP waits for the charger to dial in, which needs a public endpoint it can reach, a certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the charger, which needs the server on the charger's own network. Between them they cover a charger we host and a charger we stand next to; the common case is a charger behind someone else's router, and that had nothing. It was never unreachable, though. The charger holds a connection open to Anker's own broker — it is how the mobile app drives it from anywhere, and it is the mqttStatus register the Modbus snapshot has been reporting all along. So a third control mode joins that broker as the account: get_user_mqtt_info issues a client certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same topics the app publishes on. Nothing on the customer's side has to be forwarded, addressed or certificated. What travels is not an API call. The payload is a JSON envelope around a base64 binary frame the device itself speaks — marker, little-endian length, message type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec rather than a client, written from the message maps in anker-solix-api and anchored on the one frame that project documents byte for byte. A frame whose fields do not tile exactly up to the checksum is refused rather than half-read: these arrive over a link we do not control, and a truncated frame must not read as a charger reporting zeros. Two of the charger's habits shape the rest. It publishes nothing unless asked, so a status read arms a telemetry trigger and waits for the next frame, and a poll inside that window answers from what has since arrived. And a broker connection costs a fetched certificate and a TLS handshake while the plugin manager builds a throwaway instance per request — so the connection lives on the account's shared session beside the auth token, for exactly the reason the token lives there, and closes itself after five idle minutes. The transport also sees two signals no other one does: the boost flag, and the plug and start countdowns. The package doc has said since the first commit that they are never set and the derived mode must do without them. Here they are set, so a charger that has been told to start and is counting down a delay says so rather than sitting in "preparing", and "skip the delay" is offered only while there is a delay to skip. The clients generalise instead of growing a second layout. Both snapshots name the same quantities the same way, so what was Modbus-only in the readouts is now whichever transport read the charger — ModbusStatus becomes ChargerStatus on the phone, mb becomes dev on the web. What each transport can be *told* still differs, and the buttons branch on that: reset and clear-limit stay with OCPP, the timeout and phase registers with Modbus, skip-delay with the cloud. A command a transport has no equivalent for is refused by name, saying which one has it. The cost is worth saying plainly. This leans on Anker's cloud being up and on an unofficial protocol the app may change under us, where Modbus leans on nothing but the LAN. And it is checked against the reference implementation's own worked example rather than against hardware — there is no charger on this end to point it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2475 lines
91 KiB
Dart
2475 lines
91 KiB
Dart
import "package:flutter/material.dart";
|
||
import "package:shared_preferences/shared_preferences.dart";
|
||
|
||
import "../format.dart";
|
||
import "../i18n.dart";
|
||
import "../main.dart";
|
||
import "../models.dart";
|
||
import "../theme.dart";
|
||
import "car_view_sheet.dart" show arrangeKeys;
|
||
import "charger_import_sheet.dart";
|
||
|
||
/// Charging & map screen, mirroring the web Charging.vue. Two tabs split the
|
||
/// public charging network (a presentational discovery map + demo session +
|
||
/// nearby public stations) from the user's own chargers and their real control.
|
||
///
|
||
/// There is no live public-charging API yet — the session and station lists are
|
||
/// placeholders, exactly as on the web. The home tab is not: those are chargers
|
||
/// the user imported from a service they connected, and the cards beside them
|
||
/// drive a real charger through the Anker Solix control endpoints once a control
|
||
/// mode is picked in Settings → Integrations.
|
||
|
||
/// The tabs this page can show, in the order they appear when the account has no
|
||
/// arrangement of its own. Mirrors ALL_CHARGER_TABS in the web Charging.vue; the
|
||
/// server rejects nothing here, but a key outside this set is dropped on read.
|
||
const List<String> kChargerTabKeys = ["public", "home"];
|
||
|
||
/// The home tab's cards, in their default order. Controls first, because acting
|
||
/// on the charger is what the page is opened for; the connection below it,
|
||
/// because it is touched once and then left alone.
|
||
const List<String> kChargerCardKeys = ["control", "connection", "readings", "info"];
|
||
|
||
/// Which cards are folded away, kept on the device rather than on the profile:
|
||
/// it is a per-device reading habit, not an account setting. Keyed by card, so a
|
||
/// fold outlives switching charger.
|
||
const String _kCollapsedKey = "cc_chargingCollapsed";
|
||
|
||
/// The serial the control cards are pointed at. Remembered so the page comes
|
||
/// back up on the charger it was left on, the way the web does.
|
||
const String _kSerialKey = "cc_ctlSerial";
|
||
|
||
class ChargingScreen extends StatefulWidget {
|
||
const ChargingScreen({super.key});
|
||
@override
|
||
State<ChargingScreen> createState() => _ChargingScreenState();
|
||
}
|
||
|
||
/// A public charging station, tone-coded by availability. Presentational demo
|
||
/// data — there is no public-network API to fill it from yet.
|
||
class _Station {
|
||
final String id;
|
||
final String name;
|
||
final String dist;
|
||
final int kw;
|
||
final String conn;
|
||
final int avail;
|
||
final int total;
|
||
final String price;
|
||
final String tone; // "good" | "due" | "fault"
|
||
const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail,
|
||
this.total, this.price, this.tone);
|
||
}
|
||
|
||
class _ChargingScreenState extends State<ChargingScreen> {
|
||
/// The tab bar's arrangement, and the home cards'. Both live on the profile —
|
||
/// they are layout choices that should follow the account, the way the garage
|
||
/// order does — so they arrive with /me and are written back with it.
|
||
List<String> _tabKeys = List.of(kChargerTabKeys);
|
||
List<String> _cardKeys = List.of(kChargerCardKeys);
|
||
String? _orderError;
|
||
|
||
int _tab = 0;
|
||
String _selectedStation = "sc";
|
||
|
||
static const List<_Station> _stations = [
|
||
_Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"),
|
||
_Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"),
|
||
_Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"),
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadArrangement();
|
||
}
|
||
|
||
/// The arrangement arrives with the profile. A failure is silent: the page is
|
||
/// perfectly usable in its default order, and an error banner about it would
|
||
/// be about nothing the user asked for.
|
||
Future<void> _loadArrangement() async {
|
||
try {
|
||
final me = await apiClient.getMe();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_tabKeys = arrangeKeys(kChargerTabKeys, me.chargerTabOrder);
|
||
_cardKeys = arrangeKeys(kChargerCardKeys, me.chargerCardOrder);
|
||
});
|
||
} catch (_) {
|
||
// Keep the defaults.
|
||
}
|
||
}
|
||
|
||
Future<void> _arrange() async {
|
||
final result = await showChargingArrangeSheet(
|
||
context,
|
||
tabs: _tabKeys,
|
||
cards: _cardKeys,
|
||
);
|
||
if (result == null || !mounted) return;
|
||
|
||
// The tab in view is followed through the rearrangement rather than left at
|
||
// its index: moving a tab should not switch which one you are reading.
|
||
final current = _tabKeys[_tab.clamp(0, _tabKeys.length - 1)];
|
||
setState(() {
|
||
_tabKeys = result.tabs;
|
||
_cardKeys = result.cards;
|
||
_tab = result.tabs.indexOf(current).clamp(0, result.tabs.length - 1);
|
||
_orderError = null;
|
||
});
|
||
try {
|
||
await apiClient.updateMe({
|
||
"chargerTabOrder": result.tabs,
|
||
"chargerCardOrder": result.cards,
|
||
});
|
||
} catch (e) {
|
||
// It didn't stick; say so and put the stored arrangement back rather than
|
||
// leaving the page showing an order the server does not have.
|
||
if (!mounted) return;
|
||
setState(() => _orderError = "$e");
|
||
_loadArrangement();
|
||
}
|
||
}
|
||
|
||
Color _toneColor(String tone) {
|
||
switch (tone) {
|
||
case "due":
|
||
return DriverVault.warning;
|
||
case "fault":
|
||
return DriverVault.danger;
|
||
default:
|
||
return DriverVault.success;
|
||
}
|
||
}
|
||
|
||
String _stationStatus(_Station s) => s.avail == 0
|
||
? t("charging.stations.full")
|
||
: t("charging.stations.free", params: {"avail": s.avail, "total": s.total});
|
||
|
||
Widget _tabBody(String key, {required bool active}) {
|
||
switch (key) {
|
||
case "home":
|
||
return _HomeTab(key: const ValueKey("home"), cardKeys: _cardKeys, active: active);
|
||
default:
|
||
return _PublicTab(
|
||
key: const ValueKey("public"),
|
||
stations: _stations,
|
||
selected: _selectedStation,
|
||
onSelect: (id) => setState(() => _selectedStation = id),
|
||
toneColor: _toneColor,
|
||
statusFor: _stationStatus,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final index = _tab.clamp(0, _tabKeys.length - 1);
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(t("charging.title")),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.swap_vert),
|
||
tooltip: t("charging.arrange.title"),
|
||
onPressed: _arrange,
|
||
),
|
||
],
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(12),
|
||
children: [
|
||
// Eyebrow + title header, matching the web layout.
|
||
Text(t("charging.eyebrow"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
|
||
.copyWith(letterSpacing: 2.2)),
|
||
const SizedBox(height: 2),
|
||
Text(t("charging.title"),
|
||
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||
const SizedBox(height: 16),
|
||
|
||
if (_orderError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: Text(_orderError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
||
),
|
||
|
||
// Tabs: public network vs. the user's own chargers.
|
||
_TabBar(
|
||
tabs: [for (final key in _tabKeys) t("charging.tabs.$key")],
|
||
index: index,
|
||
onChanged: (i) => setState(() => _tab = i),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// IndexedStack keeps each tab's state alive across switches (v-show parity).
|
||
// Keyed, so rearranging the bar moves the tabs rather than rebuilding
|
||
// whichever widget now sits at that index from scratch.
|
||
IndexedStack(
|
||
index: index,
|
||
children: [
|
||
for (var i = 0; i < _tabKeys.length; i++)
|
||
_tabBody(_tabKeys[i], active: i == index),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Arranging the page ------------------------------------------------------
|
||
|
||
/// What the arrange sheet returns: the two arrangements, saved together.
|
||
class ChargingArrangement {
|
||
final List<String> tabs;
|
||
final List<String> cards;
|
||
const ChargingArrangement(this.tabs, this.cards);
|
||
}
|
||
|
||
/// Rearranges the charging tabs and the home tab's cards.
|
||
///
|
||
/// The web drags the tab bar and the card headings themselves; on a touch screen
|
||
/// the tab bar owns that gesture and a card heading is a fold toggle, so both
|
||
/// arrangements are made here instead, with a handle — exactly the trade the
|
||
/// car's view picker makes.
|
||
Future<ChargingArrangement?> showChargingArrangeSheet(
|
||
BuildContext context, {
|
||
required List<String> tabs,
|
||
required List<String> cards,
|
||
}) {
|
||
return showModalBottomSheet<ChargingArrangement>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (_) => _ChargingArrangeSheet(tabs: tabs, cards: cards),
|
||
);
|
||
}
|
||
|
||
class _ChargingArrangeSheet extends StatefulWidget {
|
||
final List<String> tabs;
|
||
final List<String> cards;
|
||
const _ChargingArrangeSheet({required this.tabs, required this.cards});
|
||
@override
|
||
State<_ChargingArrangeSheet> createState() => _ChargingArrangeSheetState();
|
||
}
|
||
|
||
class _ChargingArrangeSheetState extends State<_ChargingArrangeSheet> {
|
||
late final List<String> _tabs = List.of(widget.tabs);
|
||
late final List<String> _cards = List.of(widget.cards);
|
||
|
||
/// The card's own heading, so the sheet names each row the way the page does.
|
||
String _cardLabel(String key) => switch (key) {
|
||
"control" => t("charging.control.title"),
|
||
"connection" => t("charging.control.connectionTitle"),
|
||
"readings" => t("charging.modbus.title"),
|
||
_ => t("charging.info.title"),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
top: 16,
|
||
bottom: DriverVault.sheetBottomInset(context),
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.arrange.title"),
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 4),
|
||
Text(t("car.viewPicker.reorderHint"), style: TextStyle(fontSize: 12, color: muted)),
|
||
const SizedBox(height: 16),
|
||
_section(t("charging.arrange.tabs")),
|
||
_reorder(_tabs, (k) => t("charging.tabs.$k"),
|
||
(from, to) => setState(() => _tabs.insert(to, _tabs.removeAt(from)))),
|
||
const SizedBox(height: 16),
|
||
_section(t("charging.arrange.cards")),
|
||
_reorder(_cards, _cardLabel,
|
||
(from, to) => setState(() => _cards.insert(to, _cards.removeAt(from)))),
|
||
const SizedBox(height: 16),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(t("common.cancel")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: () => Navigator.pop(context, ChargingArrangement(_tabs, _cards)),
|
||
child: Text(t("common.save")),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _section(String label) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
);
|
||
|
||
/// A short reorderable list of labelled rows, each dragged by its own handle.
|
||
/// shrinkWrap because the sheet scrolls, not the list.
|
||
Widget _reorder(List<String> keys, String Function(String) label,
|
||
void Function(int, int) onMove) {
|
||
return ReorderableListView(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
buildDefaultDragHandles: false,
|
||
onReorderItem: onMove,
|
||
children: [
|
||
for (var i = 0; i < keys.length; i++)
|
||
ListTile(
|
||
key: ValueKey(keys[i]),
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text(label(keys[i]), style: const TextStyle(fontSize: 14)),
|
||
trailing: ReorderableDragStartListener(
|
||
index: i,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Icon(Icons.drag_handle, color: DriverVault.muted(context)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Shared: underline tab bar (matches Settings tabs) ----------------------
|
||
|
||
class _TabBar extends StatelessWidget {
|
||
final List<String> tabs;
|
||
final int index;
|
||
final ValueChanged<int> onChanged;
|
||
const _TabBar({required this.tabs, required this.index, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final accent = Theme.of(context).colorScheme.primary;
|
||
final muted = DriverVault.muted(context);
|
||
final border = DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100;
|
||
return Container(
|
||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: border))),
|
||
child: Row(
|
||
children: [
|
||
for (var i = 0; i < tabs.length; i++)
|
||
GestureDetector(
|
||
onTap: () => onChanged(i),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
padding: const EdgeInsets.only(right: 20, bottom: 10, top: 2),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: i == index ? accent : Colors.transparent,
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
child: Text(
|
||
tabs[i],
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: i == index ? (DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900) : muted,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A titled bordered card, matching the web's dh-card.
|
||
class _Card extends StatelessWidget {
|
||
final EdgeInsetsGeometry padding;
|
||
final Widget child;
|
||
const _Card({this.padding = const EdgeInsets.all(16), required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
elevation: 0,
|
||
margin: EdgeInsets.zero,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
side: BorderSide(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Padding(padding: padding, child: child),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A card whose heading folds it away. The column runs long once the readings
|
||
/// are in it, and which cards are folded is remembered per device.
|
||
class _FoldCard extends StatelessWidget {
|
||
final String title;
|
||
final bool open;
|
||
final VoidCallback onToggle;
|
||
|
||
/// Rides in the header, so it is still readable with the card folded — the
|
||
/// connection badge is the one thing worth seeing either way.
|
||
final Widget? badge;
|
||
|
||
/// Sits beside the heading rather than inside the fold, so it can be pressed
|
||
/// without unfolding anything.
|
||
final Widget? action;
|
||
|
||
final List<Widget> children;
|
||
|
||
const _FoldCard({
|
||
required this.title,
|
||
required this.open,
|
||
required this.onToggle,
|
||
required this.children,
|
||
this.badge,
|
||
this.action,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onToggle,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(title,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||
),
|
||
if (badge != null) ...[badge!, const SizedBox(width: 8)],
|
||
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<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
final String Function(_Station) statusFor;
|
||
const _StationList({
|
||
required this.heading,
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
required this.statusFor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return _Card(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(6, 6, 6, 8),
|
||
child: Text(
|
||
"$heading · ${t("charging.stations.count", params: {"n": stations.length}, n: stations.length)}",
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
|
||
.copyWith(letterSpacing: 1.4),
|
||
),
|
||
),
|
||
for (final s in stations)
|
||
_StationTile(
|
||
station: s,
|
||
selected: selected == s.id,
|
||
onTap: () => onSelect(s.id),
|
||
tone: toneColor(s.tone),
|
||
status: statusFor(s),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Public tab: discovery map + demo session + public station list ----------
|
||
|
||
class _PublicTab extends StatefulWidget {
|
||
final List<_Station> stations;
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
final String Function(_Station) statusFor;
|
||
const _PublicTab({
|
||
super.key,
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
required this.statusFor,
|
||
});
|
||
|
||
@override
|
||
State<_PublicTab> createState() => _PublicTabState();
|
||
}
|
||
|
||
class _PublicTabState extends State<_PublicTab> {
|
||
// The demo session card is presentational, so its running/stopped state is
|
||
// purely local.
|
||
bool _charging = true;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
children: [
|
||
_MapPanel(
|
||
stations: widget.stations,
|
||
selected: widget.selected,
|
||
onSelect: widget.onSelect,
|
||
toneColor: widget.toneColor,
|
||
),
|
||
const SizedBox(height: 16),
|
||
_SessionCard(charging: _charging, onStop: () => setState(() => _charging = false)),
|
||
const SizedBox(height: 16),
|
||
_StationList(
|
||
heading: t("charging.stations.heading"),
|
||
stations: widget.stations,
|
||
selected: widget.selected,
|
||
onSelect: widget.onSelect,
|
||
toneColor: widget.toneColor,
|
||
statusFor: widget.statusFor,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A stylized discovery map with a "you are here" marker and charger pins,
|
||
/// standing in for a real map the way the web's CSS grid panel does.
|
||
class _MapPanel extends StatelessWidget {
|
||
final List<_Station> stations;
|
||
final String selected;
|
||
final ValueChanged<String> onSelect;
|
||
final Color Function(String) toneColor;
|
||
const _MapPanel({
|
||
required this.stations,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
required this.toneColor,
|
||
});
|
||
|
||
// Pin positions as fractions of the panel, mirroring the web's x/y percents.
|
||
static const _pos = {
|
||
"sc": Offset(0.47, 0.34),
|
||
"evgo": Offset(0.26, 0.60),
|
||
"cp": Offset(0.70, 0.64),
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dark = DriverVault.isDark(context);
|
||
final base = dark ? DriverVault.darkSunken : DriverVault.ink25;
|
||
final grid = dark ? Colors.white.withValues(alpha: 0.04) : const Color(0x0B0F1E3D);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
child: Container(
|
||
height: 320,
|
||
decoration: BoxDecoration(
|
||
color: base,
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, c) {
|
||
final w = c.maxWidth, h = c.maxHeight;
|
||
return Stack(
|
||
children: [
|
||
// Grid lines.
|
||
CustomPaint(size: Size(w, h), painter: _GridPainter(grid)),
|
||
// Roads.
|
||
Positioned(left: 0, right: 0, top: h * 0.52, child: Container(height: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
|
||
Positioned(top: 0, bottom: 0, left: w * 0.58, child: Container(width: 12, color: DriverVault.brand100.withValues(alpha: dark ? 0.10 : 1))),
|
||
// Legend chip.
|
||
Positioned(
|
||
left: 12,
|
||
top: 12,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).cardColor.withValues(alpha: 0.9),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
const Icon(Icons.map_outlined, size: 13, color: DriverVault.brand600),
|
||
const SizedBox(width: 5),
|
||
Text(t("charging.liveMap"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
),
|
||
),
|
||
// You are here.
|
||
Positioned(
|
||
left: w * 0.5 - 20,
|
||
top: h * 0.5 - 20,
|
||
child: Tooltip(
|
||
message: t("charging.youAreHere"),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(color: DriverVault.brand600.withValues(alpha: 0.16), shape: BoxShape.circle),
|
||
child: Center(
|
||
child: Container(
|
||
width: 14,
|
||
height: 14,
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.brand600,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Charger pins.
|
||
for (final s in stations)
|
||
if (_pos[s.id] != null)
|
||
Positioned(
|
||
left: w * _pos[s.id]!.dx - (selected == s.id ? 18 : 14),
|
||
top: h * _pos[s.id]!.dy - (selected == s.id ? 36 : 28),
|
||
child: _Pin(
|
||
selected: selected == s.id,
|
||
color: toneColor(s.tone),
|
||
label: "${s.avail}/${s.total} · ${s.kw} kW",
|
||
onTap: () => onSelect(s.id),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Pin extends StatelessWidget {
|
||
final bool selected;
|
||
final Color color;
|
||
final String label;
|
||
final VoidCallback onTap;
|
||
const _Pin({required this.selected, required this.color, required this.label, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final size = selected ? 36.0 : 28.0;
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (selected)
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).cardColor,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Theme.of(context).dividerColor),
|
||
),
|
||
child: Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600)),
|
||
),
|
||
Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(999),
|
||
topRight: Radius.circular(999),
|
||
bottomRight: Radius.circular(999),
|
||
bottomLeft: Radius.circular(3),
|
||
),
|
||
),
|
||
child: Icon(Icons.bolt, color: Colors.white, size: selected ? 18 : 14),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Faint grid lines behind the map, echoing the web's repeating gradients.
|
||
class _GridPainter extends CustomPainter {
|
||
final Color color;
|
||
_GridPainter(this.color);
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final p = Paint()
|
||
..color = color
|
||
..strokeWidth = 1;
|
||
const step = 44.0;
|
||
for (double x = 0; x < size.width; x += step) {
|
||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||
}
|
||
for (double y = 0; y < size.height; y += step) {
|
||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _GridPainter old) => old.color != color;
|
||
}
|
||
|
||
/// The presentational "Charging now" session card (dark brand background).
|
||
class _SessionCard extends StatelessWidget {
|
||
final bool charging;
|
||
final VoidCallback onStop;
|
||
const _SessionCard({required this.charging, required this.onStop});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const from = 62, to = 80;
|
||
final metrics = [
|
||
(t("charging.session.rate"), "142 kW"),
|
||
(t("charging.session.added"), "+29 km"),
|
||
(t("charging.session.cost"), "5,80 €"),
|
||
(t("charging.session.done"), "~18 min"),
|
||
];
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.brand900,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusCard),
|
||
),
|
||
child: charging
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(width: 6, height: 6, decoration: const BoxDecoration(color: DriverVault.success, shape: BoxShape.circle)),
|
||
const SizedBox(width: 8),
|
||
Text("${t("charging.session.chargingNow")} · Model Y",
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
RichText(
|
||
text: TextSpan(
|
||
style: DriverVault.mono(context, size: 34, weight: FontWeight.w500, color: Colors.white),
|
||
children: [
|
||
const TextSpan(text: "$from"),
|
||
TextSpan(
|
||
text: " % → $to%",
|
||
style: DriverVault.mono(context, size: 15, weight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.7)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: LinearProgressIndicator(
|
||
value: from / 100,
|
||
minHeight: 8,
|
||
backgroundColor: Colors.white.withValues(alpha: 0.15),
|
||
valueColor: const AlwaysStoppedAnimation(DriverVault.brand400),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
for (final m in metrics)
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(m.$1,
|
||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.2)),
|
||
const SizedBox(height: 2),
|
||
Text(m.$2, style: DriverVault.mono(context, size: 14, weight: FontWeight.w500, color: Colors.white)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: onStop,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: Colors.white,
|
||
backgroundColor: Colors.white.withValues(alpha: 0.10),
|
||
side: BorderSide(color: Colors.white.withValues(alpha: 0.20)),
|
||
),
|
||
child: Text(t("charging.session.stop")),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.4), shape: BoxShape.circle)),
|
||
const SizedBox(width: 8),
|
||
Text(t("charging.session.idle"),
|
||
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: DriverVault.brand300)
|
||
.copyWith(letterSpacing: 1.4)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Text(t("charging.session.idleHint"),
|
||
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- Home tab: the user's own chargers + real control ------------------------
|
||
|
||
/// The home half of the page: the cards that act on one charger, in whatever
|
||
/// order the account arranged them, and below them the list of chargers the user
|
||
/// has imported.
|
||
///
|
||
/// One State for the lot, mirroring the web view: the cards all read the same
|
||
/// charger, and picking one in the list is what points the control cards at it.
|
||
class _HomeTab extends StatefulWidget {
|
||
final List<String> cardKeys;
|
||
|
||
/// Whether this is the tab being read. Reachability costs a round trip to each
|
||
/// connected service, so it is asked for when the question is actually being
|
||
/// asked — not because an IndexedStack built both tabs.
|
||
final bool active;
|
||
|
||
const _HomeTab({super.key, required this.cardKeys, required this.active});
|
||
@override
|
||
State<_HomeTab> createState() => _HomeTabState();
|
||
}
|
||
|
||
class _HomeTabState extends State<_HomeTab> {
|
||
// --- control ---
|
||
final _serial = TextEditingController();
|
||
final _host = TextEditingController();
|
||
final _port = TextEditingController(text: "502");
|
||
final _resetPassword = TextEditingController();
|
||
|
||
/// The chargers on the linked Anker account. With them the serial is a pick
|
||
/// from a list; without them (account not linked, or the cloud unreachable)
|
||
/// the field stays a plain text box so a serial can still be typed by hand.
|
||
List<AnkerCharger> _accountChargers = const [];
|
||
|
||
/// Typing the serial rather than picking it. A dropdown can only show a serial
|
||
/// it has an option for, so a remembered serial the account does not report —
|
||
/// a charger imported before the account was linked, one the cloud is quiet
|
||
/// about today — would render as a blank field with no way to read or fix it.
|
||
/// Falling back to the text box shows the serial that is actually in force.
|
||
bool _manualSerial = false;
|
||
|
||
String _mode = "off"; // effective control mode (off | mqtt | modbus | own | proxy)
|
||
AnkerControl? _ctl;
|
||
String? _ctlError;
|
||
String _busy = ""; // action name currently in flight
|
||
double _limitAmps = 16;
|
||
bool _resetPrompt = false;
|
||
bool _savingAddress = false;
|
||
|
||
// --- the user's own chargers ---
|
||
List<HomeCharger> _homeChargers = const [];
|
||
String? _homeError;
|
||
|
||
/// The charger services this user could import from. Importing only makes
|
||
/// sense once one is connected, so the button appears only then — the same
|
||
/// rule the garage's import follows — and the list doubles as the id → label
|
||
/// map the information card names a charger's origin with.
|
||
List<ChargerProvider> _providers = const [];
|
||
|
||
/// Live reachability, keyed by the provider's own id for the charger. The
|
||
/// record says what a charger is; only the service it came from knows whether
|
||
/// it is reachable right now, so that half is asked for separately and held
|
||
/// beside the records rather than in them.
|
||
Map<String, ProviderCharger> _live = const {};
|
||
bool _liveLoading = false;
|
||
bool _liveLoaded = false;
|
||
|
||
String _selected = ""; // the charger the cards are about
|
||
String _removing = "";
|
||
|
||
/// Which cards are folded, read once and written on every toggle.
|
||
Set<String> _collapsed = <String>{};
|
||
|
||
bool get _active => _mode != "off";
|
||
bool get _connected => _ctl?.connected == true;
|
||
bool get _isModbus => _mode == "modbus";
|
||
|
||
/// The Anker cloud path: both ends meet at the broker the charger already
|
||
/// talks to, so there is nothing to address and nothing to install — only an
|
||
/// account to be signed in to. It is the mode for a charger somewhere else.
|
||
bool get _isCloud => _mode == "mqtt";
|
||
|
||
/// Those two read the charger itself and answer with its own snapshot, where
|
||
/// OCPP answers with the session our CSMS holds. Anything both can report is
|
||
/// named the same in both, so one set of readouts serves them; what each can
|
||
/// be told still differs, which is what the buttons below branch on.
|
||
bool get _readsDevice => _isModbus || _isCloud;
|
||
|
||
/// The charger's snapshot, whichever transport read it.
|
||
ChargerStatus? get _dev => _ctl?.device;
|
||
bool get _canImport => _providers.any((p) => p.connected);
|
||
|
||
bool get _serialInList =>
|
||
_accountChargers.any((c) => c.sn == _serial.text.trim());
|
||
|
||
/// A dropdown can only show a serial it has an option for, so one the account
|
||
/// does not report — a charger picked from the list below, imported before the
|
||
/// account was linked — falls back to the text box rather than rendering as a
|
||
/// blank field with no way to read or fix it.
|
||
bool get _pickingFromList =>
|
||
_accountChargers.isNotEmpty && !_manualSerial && (_serial.text.trim().isEmpty || _serialInList);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_init();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_HomeTab old) {
|
||
super.didUpdateWidget(old);
|
||
// Switching to this tab is the moment reachability is being asked about.
|
||
if (widget.active && !old.active) _loadLive();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_serial.dispose();
|
||
_host.dispose();
|
||
_port.dispose();
|
||
_resetPassword.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (!mounted) return;
|
||
_serial.text = prefs.getString(_kSerialKey) ?? "";
|
||
setState(() => _collapsed = (prefs.getStringList(_kCollapsedKey) ?? const []).toSet());
|
||
|
||
await _loadHomeChargers();
|
||
await _loadProviders();
|
||
if (widget.active) await _loadLive();
|
||
await _loadMode();
|
||
if (_active) await _loadAccountChargers();
|
||
await _refresh();
|
||
}
|
||
|
||
// --- folding ---
|
||
|
||
bool _isOpen(String id) => !_collapsed.contains(id);
|
||
|
||
Future<void> _toggleCard(String id) async {
|
||
setState(() {
|
||
if (_collapsed.contains(id)) {
|
||
_collapsed.remove(id);
|
||
} else {
|
||
_collapsed.add(id);
|
||
}
|
||
});
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setStringList(_kCollapsedKey, _collapsed.toList());
|
||
}
|
||
|
||
// --- loading ---
|
||
|
||
Future<void> _loadMode() async {
|
||
try {
|
||
final v = await apiClient.getAnkerSolix();
|
||
if (mounted) setState(() => _mode = v.controlMode);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _mode = "off");
|
||
}
|
||
}
|
||
|
||
Future<void> _loadAccountChargers() async {
|
||
try {
|
||
final res = await apiClient.listAnkerChargers();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_accountChargers = res.chargers;
|
||
// Nothing chosen yet: start on the first charger the account reports.
|
||
if (_serial.text.trim().isEmpty && _accountChargers.isNotEmpty) {
|
||
_serial.text = _accountChargers.first.sn;
|
||
}
|
||
// Decided when the list arrives rather than on every keystroke:
|
||
// recomputing it as the serial is typed would swap the text box for a
|
||
// dropdown mid-word, the moment what had been typed happened to match.
|
||
_manualSerial = _accountChargers.isNotEmpty && !_serialInList;
|
||
});
|
||
} catch (_) {
|
||
if (mounted) setState(() => _accountChargers = const []);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadHomeChargers() async {
|
||
try {
|
||
final list = await apiClient.listHomeChargers();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_homeChargers = list;
|
||
_homeError = null;
|
||
if (_selected.isEmpty && list.isNotEmpty) _selected = list.first.id;
|
||
});
|
||
} catch (e) {
|
||
if (mounted) setState(() => _homeError = "$e");
|
||
}
|
||
}
|
||
|
||
Future<void> _loadProviders() async {
|
||
try {
|
||
final list = await apiClient.listChargerProviders();
|
||
if (mounted) setState(() => _providers = list);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _providers = const []);
|
||
}
|
||
}
|
||
|
||
/// Asking costs a round trip to each connected service, so it happens when the
|
||
/// home tab is first built — the moment the question is being asked — and on
|
||
/// demand after that.
|
||
Future<void> _loadLive({bool force = false}) async {
|
||
final connected = _providers.where((p) => p.connected).toList();
|
||
if (_liveLoading || connected.isEmpty || !mounted) return;
|
||
if (_liveLoaded && !force) return; // switching tabs is not a new question
|
||
setState(() => _liveLoading = true);
|
||
final live = <String, ProviderCharger>{};
|
||
await Future.wait(connected.map((p) async {
|
||
try {
|
||
final res = await apiClient.listProviderChargers(p.id);
|
||
for (final c in res.chargers) {
|
||
live[c.id] = c;
|
||
}
|
||
} catch (_) {
|
||
// A service that will not answer leaves its chargers unknown rather
|
||
// than offline — this page cannot tell those two apart.
|
||
}
|
||
}));
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_live = live;
|
||
_liveLoaded = true;
|
||
_liveLoading = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
if (!mounted) return;
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) {
|
||
setState(() => _ctl = null);
|
||
return;
|
||
}
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(_kSerialKey, sn);
|
||
if (!mounted) return;
|
||
setState(() => _ctlError = null);
|
||
try {
|
||
final c = await apiClient.getAnkerControl(sn);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_ctl = c;
|
||
// The saved address belongs to the charger, not to the form: switching
|
||
// chargers has to bring its own address along rather than leave the
|
||
// previous one sitting in the field, where saving would move it to the
|
||
// wrong charger.
|
||
_host.text = c.modbusHost;
|
||
_port.text = "${c.modbusPort}";
|
||
});
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_ctlError = "$e";
|
||
_ctl = null;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- acting on the charger ---
|
||
|
||
Future<void> _action(String action, [Map<String, dynamic> body = const {}]) async {
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) return;
|
||
setState(() {
|
||
_busy = action;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlAction(sn, action, body);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _busy = "");
|
||
}
|
||
}
|
||
|
||
Future<void> _confirmReset() async {
|
||
if (_resetPassword.text.isEmpty) return;
|
||
setState(() => _resetPrompt = false);
|
||
await _action("reset", {"hard": false, "confirm": true, "password": _resetPassword.text});
|
||
_resetPassword.clear();
|
||
}
|
||
|
||
/// One control for both directions, so a serial that is not on the account is
|
||
/// never a dead end and the list is never the only option.
|
||
void _toggleSerialEntry() {
|
||
final toList = !_pickingFromList;
|
||
setState(() => _manualSerial = !toList);
|
||
// Returning to a list that does not hold this serial would blank the
|
||
// dropdown again, which is the thing being fixed; land on one it does hold.
|
||
if (toList && !_serialInList && _accountChargers.isNotEmpty) {
|
||
_serial.text = _accountChargers.first.sn;
|
||
_refresh();
|
||
}
|
||
}
|
||
|
||
Future<void> _saveAddress() async {
|
||
final sn = _serial.text.trim();
|
||
final host = _host.text.trim();
|
||
if (sn.isEmpty || host.isEmpty) return;
|
||
setState(() {
|
||
_savingAddress = true;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlAddress(sn, host, int.tryParse(_port.text.trim()) ?? 502);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _savingAddress = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _forgetAddress() async {
|
||
final sn = _serial.text.trim();
|
||
if (sn.isEmpty) return;
|
||
if (!await _confirm(t("charging.control.forgetAddressConfirm"))) return;
|
||
setState(() {
|
||
_savingAddress = true;
|
||
_ctlError = null;
|
||
});
|
||
try {
|
||
await apiClient.ankerControlForgetAddress(sn);
|
||
await _refresh();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _ctlError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _savingAddress = false);
|
||
}
|
||
}
|
||
|
||
Future<bool> _confirm(String message) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
content: Text(message),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(t("common.confirm")),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
return ok == true;
|
||
}
|
||
|
||
// --- the user's own chargers ---
|
||
|
||
/// The live half for one imported charger, or null when the service it came
|
||
/// from says nothing about it (disconnected since the import, or a charger
|
||
/// added by hand). Both services we speak to id a charger by its serial, so
|
||
/// the serial is a sound fallback for a record imported before the provider
|
||
/// link was stored.
|
||
ProviderCharger? _liveFor(HomeCharger c) =>
|
||
_live[c.providerChargerId] ?? _live[c.serial];
|
||
|
||
/// The card shows one charger: whichever is picked in the list below it. Until
|
||
/// something is picked that is the first one — a card that says nothing until
|
||
/// tapped would be a worse first impression than the charger most people have
|
||
/// only one of.
|
||
HomeCharger? get _selectedCharger {
|
||
for (final c in _homeChargers) {
|
||
if (c.id == _selected) return c;
|
||
}
|
||
return _homeChargers.isEmpty ? null : _homeChargers.first;
|
||
}
|
||
|
||
/// A charger picked here becomes the one the control cards drive.
|
||
void _select(HomeCharger c) {
|
||
setState(() => _selected = c.id);
|
||
if (c.serial.isEmpty) return;
|
||
_serial.text = c.serial;
|
||
_refresh();
|
||
}
|
||
|
||
Future<void> _import() async {
|
||
final charger = await showChargerImportSheet(context);
|
||
if (charger == null || !mounted) return;
|
||
setState(() => _homeChargers = [..._homeChargers, charger]);
|
||
_select(charger);
|
||
_loadLive(force: true);
|
||
}
|
||
|
||
Future<void> _remove(HomeCharger c) async {
|
||
if (!await _confirm(t("charging.home.removeConfirm", params: {"name": c.name}))) return;
|
||
setState(() {
|
||
_removing = c.id;
|
||
_homeError = null;
|
||
});
|
||
try {
|
||
await apiClient.deleteHomeCharger(c.id);
|
||
if (mounted) {
|
||
setState(() => _homeChargers = _homeChargers.where((x) => x.id != c.id).toList());
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() => _homeError = "$e");
|
||
} finally {
|
||
if (mounted) setState(() => _removing = "");
|
||
}
|
||
}
|
||
|
||
String _providerLabel(String id) {
|
||
for (final p in _providers) {
|
||
if (p.id == id) return p.label;
|
||
}
|
||
return id;
|
||
}
|
||
|
||
/// The cloud's own slug for what the charger is doing (charging, standby, …),
|
||
/// translated. The vocabulary is the integration's, so the wording lives with
|
||
/// it in Settings rather than being said twice.
|
||
String _stateLabel(String slug) {
|
||
if (slug.isEmpty) return "";
|
||
final key = "settings.integrations.states.$slug";
|
||
final label = t(key);
|
||
return label == key ? slug.replaceAll("_", " ") : label;
|
||
}
|
||
|
||
/// How the charger is registered on the account, in the service's own terms.
|
||
String _sourcesLabel(List<String> sources) => sources.map((src) {
|
||
final key = "charging.info.sourceNames.$src";
|
||
final label = t(key);
|
||
return label == key ? src : label;
|
||
}).join(" · ");
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cards = <String, Widget?>{
|
||
"control": _active && _connected ? _controlCard(context) : null,
|
||
"connection": _active ? _connectionCard(context) : null,
|
||
"readings": _readsDevice && _connected ? _readingsCard(context) : null,
|
||
"info": _infoCard(context),
|
||
};
|
||
|
||
return Column(
|
||
children: [
|
||
for (final key in widget.cardKeys)
|
||
if (cards[key] != null) ...[cards[key]!, const SizedBox(height: 16)],
|
||
_chargerListCard(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
// --- the cards -------------------------------------------------------------
|
||
|
||
/// Acting on the charger. It appears only once there is a connection to act
|
||
/// over, which the card below it is where you set up.
|
||
Widget _controlCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
return _FoldCard(
|
||
title: t("charging.control.title"),
|
||
open: _isOpen("control"),
|
||
onToggle: () => _toggleCard("control"),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(child: _MetricTile(value: _ctl!.statusLabel, label: t("charging.control.status"))),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh",
|
||
label: t("charging.control.meter"),
|
||
),
|
||
),
|
||
]),
|
||
// A charger told to start can sit in "preparing" for a good while, and
|
||
// these say why: it is waiting for a plug, or counting down a start
|
||
// delay. Only the cloud transport can see them.
|
||
if (_countdown("plugCountdownSeconds") != null ||
|
||
_countdown("startCountdownSeconds") != null) ...[
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
if (_countdown("plugCountdownSeconds") != null)
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: _countdown("plugCountdownSeconds")!,
|
||
label: t("charging.modbus.plugCountdown"),
|
||
),
|
||
),
|
||
if (_countdown("plugCountdownSeconds") != null &&
|
||
_countdown("startCountdownSeconds") != null)
|
||
const SizedBox(width: 8),
|
||
if (_countdown("startCountdownSeconds") != null)
|
||
Expanded(
|
||
child: _MetricTile(
|
||
value: _countdown("startCountdownSeconds")!,
|
||
label: t("charging.modbus.startCountdown"),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: _busy == "start" ? null : () => _action("start"),
|
||
child: Text(t("charging.control.start")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "stop" ? null : () => _action("stop"),
|
||
child: Text(t("charging.control.stop")),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Text(t("charging.control.limit"), style: TextStyle(fontSize: 13, color: muted)),
|
||
const Spacer(),
|
||
Text("${_limitAmps.round()} A",
|
||
style: DriverVault.mono(context, size: 13, weight: FontWeight.w500)),
|
||
]),
|
||
Slider(
|
||
value: _limitAmps,
|
||
min: 6,
|
||
max: 32,
|
||
divisions: 26,
|
||
label: "${_limitAmps.round()} A",
|
||
onChanged: (v) => setState(() => _limitAmps = v),
|
||
),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "limit" ? null : () => _action("limit", {"amps": _limitAmps.round()}),
|
||
child: Text(t("charging.control.applyLimit")),
|
||
),
|
||
),
|
||
// Clearing a limit is an OCPP command. Both of the transports that
|
||
// talk to the charger itself take an explicit ceiling, so there is
|
||
// nothing for them to clear to.
|
||
if (!_readsDevice) ...[
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"),
|
||
child: Text(t("charging.control.clearLimit")),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
|
||
// Boost lasts for the current session only, and is a command the
|
||
// charger itself takes — over the register map or over the cloud, but
|
||
// never over OCPP.
|
||
if (_readsDevice) ...[
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "boost" ? null : () => _action("boost", {"on": true}),
|
||
child: Text(t("charging.control.boost")),
|
||
),
|
||
),
|
||
],
|
||
|
||
// Skipping a start delay is only offered while one is running, and only
|
||
// the cloud transport knows that it is.
|
||
if (_dev?.modeOptions.contains("skip_delay") ?? false) ...[
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "skip-delay" ? null : () => _action("skip-delay"),
|
||
child: Text(t("charging.control.skipDelay")),
|
||
),
|
||
),
|
||
],
|
||
|
||
// Reset reboots the charger over OCPP; neither the register map nor the
|
||
// cloud has an equivalent, so the button is not offered there.
|
||
if (!_readsDevice) ...[
|
||
const SizedBox(height: 12),
|
||
if (!_resetPrompt)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _busy == "reset"
|
||
? null
|
||
: () => setState(() {
|
||
_ctlError = null;
|
||
_resetPassword.clear();
|
||
_resetPrompt = true;
|
||
}),
|
||
child: Text(t("charging.control.reset")),
|
||
),
|
||
)
|
||
else
|
||
// Step-up: a destructive reset requires re-entering the password.
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
border: Border.all(color: DriverVault.danger.withValues(alpha: 0.4)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.resetConfirm"),
|
||
style: const TextStyle(
|
||
fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _resetPassword,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.resetPassword"),
|
||
),
|
||
onSubmitted: (_) => _confirmReset(),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => setState(() {
|
||
_resetPrompt = false;
|
||
_resetPassword.clear();
|
||
}),
|
||
child: Text(t("common.cancel")),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||
onPressed: _busy == "reset" ? null : _confirmReset,
|
||
child: Text(t("charging.control.reset")),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Which charger, and how to reach it. Below the controls: it is touched once
|
||
/// and then left alone.
|
||
Widget _connectionCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final sn = _serial.text.trim();
|
||
return _FoldCard(
|
||
title: t("charging.control.connectionTitle"),
|
||
open: _isOpen("connection"),
|
||
onToggle: () => _toggleCard("connection"),
|
||
// Whether the charger is reachable is the one thing worth seeing with the
|
||
// card folded, so the badge rides in the header.
|
||
badge: _StatusBadge(
|
||
label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"),
|
||
ok: _connected,
|
||
),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Row(children: [
|
||
Expanded(
|
||
child: _pickingFromList
|
||
? DropdownButtonFormField<String>(
|
||
// initialValue is read once, so the field is rebuilt when the
|
||
// serial changes from elsewhere — picking a charger below.
|
||
key: ValueKey(sn),
|
||
initialValue: _serialInList ? sn : null,
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||
items: [
|
||
for (final c in _accountChargers)
|
||
DropdownMenuItem(
|
||
value: c.sn,
|
||
child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn,
|
||
overflow: TextOverflow.ellipsis),
|
||
),
|
||
],
|
||
onChanged: (picked) {
|
||
if (picked == null) return;
|
||
setState(() => _serial.text = picked);
|
||
_refresh();
|
||
},
|
||
)
|
||
: TextField(
|
||
controller: _serial,
|
||
autocorrect: false,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.serialPlaceholder"),
|
||
),
|
||
onSubmitted: (_) => _refresh(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))),
|
||
]),
|
||
|
||
// Only worth offering when there is a list to switch to or from.
|
||
if (_accountChargers.isNotEmpty)
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton(
|
||
onPressed: _toggleSerialEntry,
|
||
child: Text(
|
||
_pickingFromList
|
||
? t("charging.control.enterSerial")
|
||
: t("charging.control.pickSerial"),
|
||
style: TextStyle(fontSize: 12, color: muted),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Modbus mode dials the charger, so it needs the charger's address on
|
||
// this network rather than a token installed into the charger.
|
||
if (_isModbus) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.address"),
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _host,
|
||
autocorrect: false,
|
||
keyboardType: TextInputType.url,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
hintText: t("charging.control.addressPlaceholder"),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
onSubmitted: (_) => _saveAddress(),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 92,
|
||
child: TextField(
|
||
controller: _port,
|
||
keyboardType: TextInputType.number,
|
||
decoration: InputDecoration(
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
labelText: t("charging.control.addressPort"),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Text(t("charging.control.addressHint"),
|
||
style: TextStyle(fontSize: 11, color: muted)),
|
||
const SizedBox(height: 8),
|
||
Row(children: [
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: _host.text.trim().isEmpty || !_addressChanged || _savingAddress
|
||
? null
|
||
: _saveAddress,
|
||
child: Text(t("charging.control.saveAddress")),
|
||
),
|
||
),
|
||
if ((_ctl?.modbusHost ?? "").isNotEmpty) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton(
|
||
onPressed: _savingAddress ? null : _forgetAddress,
|
||
child: Text(t("charging.control.forgetAddress")),
|
||
),
|
||
],
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// The cloud path has nothing to set up: the account is the credential,
|
||
// and it is entered in Settings. What it does have to say is what the
|
||
// charger reports about its own local side, which is the address the
|
||
// Modbus mode would otherwise have to be told.
|
||
if (_isCloud) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(t("charging.control.cloudNote"), style: TextStyle(fontSize: 11, color: muted)),
|
||
if (_localAccess != null) ...[
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
t("charging.control.cloudLocalFound", params: {"address": _localAccess!}),
|
||
style: DriverVault.mono(context, size: 11),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// Why there is nothing to control yet. Reading the charger directly, the
|
||
// server has already tried and says what it found, which beats a generic
|
||
// hint.
|
||
if (!_connected)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Text(
|
||
(_ctl?.detail.isNotEmpty ?? false) ? _ctl!.detail : t(_hintKey),
|
||
style: TextStyle(fontSize: 12, color: muted),
|
||
),
|
||
),
|
||
if (_ctlError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(_ctlError!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Saving is only worth offering when there is something to save that is not
|
||
/// already saved, so the button goes quiet once the form matches the server.
|
||
bool get _addressChanged =>
|
||
_host.text.trim() != (_ctl?.modbusHost ?? "") ||
|
||
(int.tryParse(_port.text.trim()) ?? 502) != (_ctl?.modbusPort ?? 502);
|
||
|
||
/// A countdown the charger is running, as minutes and seconds. Only shown
|
||
/// while it is actually running: zero is not a countdown, it is the absence
|
||
/// of one.
|
||
String? _countdown(String key) {
|
||
final v = _dev?.integer(key);
|
||
if (v == null || v <= 0) return null;
|
||
final m = v ~/ 60;
|
||
return m > 0 ? "$m min ${v % 60} s" : "$v s";
|
||
}
|
||
|
||
/// The charger's own local address, when the cloud snapshot carries it: the
|
||
/// address the Modbus mode has to be given by hand, discovered instead.
|
||
String? get _localAccess {
|
||
final local = _dev?.local ?? const {};
|
||
final host = local["host"];
|
||
if (local["modbusEnabled"] != true || host is! String || host.isEmpty) return null;
|
||
final port = local["port"];
|
||
return port is num && port != 502 ? "$host:${port.toInt()}" : host;
|
||
}
|
||
|
||
/// Why nothing is connected yet, in the terms of the transport in force.
|
||
String get _hintKey {
|
||
if (_isModbus) return "charging.control.modbusHint";
|
||
if (_isCloud) return "charging.control.cloudHint";
|
||
return "charging.control.connectHint";
|
||
}
|
||
|
||
/// What the charger reports about itself, over whichever transport reads it.
|
||
/// Its own card rather than a tail on the control one: control is for acting
|
||
/// on the charger, and this is a long read that pushed the buttons off screen.
|
||
Widget _readingsCard(BuildContext context) {
|
||
final s = _dev;
|
||
return _FoldCard(
|
||
title: t("charging.modbus.title"),
|
||
open: _isOpen("readings"),
|
||
onToggle: () => _toggleCard("readings"),
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
if (s != null) ..._readingSections(context, s),
|
||
],
|
||
);
|
||
}
|
||
|
||
List<Widget> _readingSections(BuildContext context, ChargerStatus s) {
|
||
final phases = _phaseRows(s);
|
||
final live = _liveRows(s);
|
||
final settings = _settingRows(s);
|
||
final device = _deviceRows(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")),
|
||
_th(context, t("charging.modbus.reactivePower")),
|
||
_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),
|
||
],
|
||
// 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"))),
|
||
]);
|
||
}
|
||
|
||
/// 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 [];
|
||
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"),
|
||
cell(s.number("reactiveL$n"), 0, "var"),
|
||
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<String> _lineVoltages(ChargerStatus s) {
|
||
final pairs = [
|
||
("L1–L2", s.number("voltageL1L2")),
|
||
("L2–L3", s.number("voltageL2L3")),
|
||
("L3–L1", s.number("voltageL3L1")),
|
||
];
|
||
return [
|
||
for (final (name, v) in pairs)
|
||
if (v != null && v > 10) "$name ${v.toStringAsFixed(1)} V",
|
||
];
|
||
}
|
||
|
||
List<String> _alarmWords(ChargerStatus s) {
|
||
if (!s.alarm) return const [];
|
||
final words = s.alarms;
|
||
return [
|
||
for (var i = 0; i < words.length; i++)
|
||
if (words[i] != 0)
|
||
"${t("charging.modbus.alarmWord", params: {"n": i + 1})} "
|
||
"0x${words[i].toRadixString(16).toUpperCase().padLeft(4, "0")}",
|
||
];
|
||
}
|
||
|
||
/// Charger information: everything the record holds about the picked charger.
|
||
/// It stands on its own — control needs a control mode, but what the charger
|
||
/// *is* is known either way, so with control off this card is what fills the
|
||
/// column instead of a bare hint.
|
||
Widget _infoCard(BuildContext context) {
|
||
final muted = DriverVault.muted(context);
|
||
final charger = _selectedCharger;
|
||
return _FoldCard(
|
||
title: t("charging.info.title"),
|
||
open: _isOpen("info"),
|
||
onToggle: () => _toggleCard("info"),
|
||
action: _homeChargers.isEmpty
|
||
? null
|
||
: TextButton(
|
||
onPressed: _liveLoading ? null : () => _loadLive(force: true),
|
||
child: Text(_liveLoading ? t("common.loading") : t("charging.info.refresh")),
|
||
),
|
||
children: [
|
||
if (charger != null) ...[
|
||
const SizedBox(height: 12),
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
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),
|
||
];
|
||
}
|
||
|
||
/// 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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|