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