get_user_bind_and_not_in_station_evchargers is the only list the connector ever asked for, and its name says exactly what it withholds. A charger that belongs to a system is not in it. Its userBindEvChargersCount, though, counts every charger bound to the account — so an owner with two chargers in a system got "authenticated; 2 EV charger(s) bound to account" from the health probe and an empty list from the capability that is supposed to show them. A working login that finds nothing. So the capability now asks every view the cloud has and merges them by serial. The standalone list still answers for chargers standing on their own; get_site_list walks the systems and reads each one through get_scen_info, falling back to get_system_running_info where that is silent — the power-service / HES split charger-state already knows; and get_relate_and_bind_devices contributes model, firmware and the Wi-Fi flag, and discovers anything in the A519 family that the first two missed. Whichever way a charger was registered, one of the three has it. The merge is first-writer-wins per field rather than last view overwriting: the standalone record knows the name, the site record knows the live state, and neither should blank what the other established. A view that fails is a warning on the document instead of an error on the call, because one dead endpoint should not cost the chargers the other two found. Only losing all three is a failure. When nothing comes back at all the response says so in its own words and names the remaining suspect — country picks the regional server, and the wrong one authenticates happily and shows an empty account. The other half of "not showing any chargers" was that neither client ever showed a list. The serial was a text box, and the number is printed on a charger hanging on a wall. Both apps now list what the account holds — name, serial, model, site, state, an offline badge — and hand the serial to the OCPP control card instead of asking anyone to go and read it. Where control is off the list still stands on its own, as the answer to the first question an owner has after entering credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1030 lines
37 KiB
Dart
1030 lines
37 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../models.dart";
|
|
import "../theme.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 home charger(s) and their real
|
|
/// OCPP control.
|
|
///
|
|
/// There is no live public-charging API yet — the session and station lists are
|
|
/// placeholders, exactly as on the web. The one real piece is the OCPP control
|
|
/// card on the Home tab, which drives a charger through the Anker Solix control
|
|
/// endpoints once the user picks Own/Proxy CSMS in Settings → Integrations.
|
|
class ChargingScreen extends StatefulWidget {
|
|
const ChargingScreen({super.key});
|
|
@override
|
|
State<ChargingScreen> createState() => _ChargingScreenState();
|
|
}
|
|
|
|
/// A charging station, tone-coded by availability. Presentational demo data.
|
|
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"
|
|
final bool home;
|
|
const _Station(this.id, this.name, this.dist, this.kw, this.conn, this.avail,
|
|
this.total, this.price, this.tone,
|
|
{this.home = false});
|
|
}
|
|
|
|
class _ChargingScreenState extends State<ChargingScreen> {
|
|
int _tab = 0; // 0 = public, 1 = home
|
|
String _selected = "sc";
|
|
|
|
List<_Station> get _stations => [
|
|
const _Station("sc", "DriverVault Supercharge", "0.4 km", 250, "CCS · NACS", 6, 8, "0,34 €", "good"),
|
|
const _Station("evgo", "EVgo · Market St", "1.2 km", 150, "CCS", 2, 6, "0,41 €", "due"),
|
|
const _Station("cp", "ChargePoint Garage", "2.1 km", 62, "J1772", 0, 4, "0,29 €", "fault"),
|
|
_Station("home", t("charging.stations.homeCharger"), "—", 11, "NACS", 1, 1,
|
|
t("charging.stations.offPeak"), "good", home: true),
|
|
];
|
|
|
|
List<_Station> get _publicStations => _stations.where((s) => !s.home).toList();
|
|
List<_Station> get _homeStations => _stations.where((s) => s.home).toList();
|
|
|
|
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});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = DriverVault.muted(context);
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t("charging.title"))),
|
|
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),
|
|
|
|
// Tabs: public network vs. the user's own home charger(s).
|
|
_TabBar(
|
|
tabs: [t("charging.tabs.public"), t("charging.tabs.home")],
|
|
index: _tab,
|
|
onChanged: (i) => setState(() => _tab = i),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// IndexedStack keeps each tab's state alive across switches (v-show parity).
|
|
IndexedStack(
|
|
index: _tab,
|
|
children: [
|
|
_PublicTab(
|
|
stations: _publicStations,
|
|
selected: _selected,
|
|
onSelect: (id) => setState(() => _selected = id),
|
|
toneColor: _toneColor,
|
|
statusFor: _stationStatus,
|
|
),
|
|
_HomeTab(
|
|
stations: _homeStations,
|
|
selected: _selected,
|
|
onSelect: (id) => setState(() => _selected = id),
|
|
toneColor: _toneColor,
|
|
statusFor: _stationStatus,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- 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),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The green/amber/red "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 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 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({
|
|
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: real OCPP control + home station list -------------------------
|
|
|
|
class _HomeTab extends StatelessWidget {
|
|
final List<_Station> stations;
|
|
final String selected;
|
|
final ValueChanged<String> onSelect;
|
|
final Color Function(String) toneColor;
|
|
final String Function(_Station) statusFor;
|
|
const _HomeTab({
|
|
required this.stations,
|
|
required this.selected,
|
|
required this.onSelect,
|
|
required this.toneColor,
|
|
required this.statusFor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
const _ControlCard(),
|
|
const SizedBox(height: 16),
|
|
_StationList(
|
|
heading: t("charging.stations.homeHeading"),
|
|
stations: stations,
|
|
selected: selected,
|
|
onSelect: onSelect,
|
|
toneColor: toneColor,
|
|
statusFor: statusFor,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The real OCPP control card, gated by the per-user Anker Solix control mode.
|
|
/// When no control mode is active it degrades to a hint pointing at Settings.
|
|
class _ControlCard extends StatefulWidget {
|
|
const _ControlCard();
|
|
@override
|
|
State<_ControlCard> createState() => _ControlCardState();
|
|
}
|
|
|
|
class _ControlCardState extends State<_ControlCard> {
|
|
final _serial = TextEditingController();
|
|
List<AnkerCharger> _chargers = const []; // the account's chargers, when known
|
|
String _mode = "off"; // effective control mode (off | own | proxy)
|
|
AnkerControl? _ctl;
|
|
String? _error;
|
|
String _busy = ""; // action name currently in flight
|
|
double _limitAmps = 16;
|
|
bool _resetPrompt = false;
|
|
final _resetPassword = TextEditingController();
|
|
|
|
bool get _active => _mode != "off";
|
|
bool get _connected => _ctl?.connected == true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_init();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_serial.dispose();
|
|
_resetPassword.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
try {
|
|
final v = await apiClient.getAnkerSolix();
|
|
if (mounted) setState(() => _mode = v.controlMode);
|
|
} catch (_) {
|
|
if (mounted) setState(() => _mode = "off");
|
|
}
|
|
if (_active) await _loadChargers();
|
|
await _refresh();
|
|
}
|
|
|
|
/// The account's chargers turn the serial into a pick from a list. Without
|
|
/// them (nothing linked, or the cloud unreachable) the field stays a text box
|
|
/// so a serial can still be typed in by hand.
|
|
Future<void> _loadChargers() async {
|
|
try {
|
|
final res = await apiClient.listAnkerChargers();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_chargers = res.chargers;
|
|
// Nothing chosen yet: start on the first charger the account reports.
|
|
if (_serial.text.trim().isEmpty && _chargers.isNotEmpty) {
|
|
_serial.text = _chargers.first.sn;
|
|
}
|
|
});
|
|
} catch (_) {
|
|
if (mounted) setState(() => _chargers = const []);
|
|
}
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
final sn = _serial.text.trim();
|
|
if (sn.isEmpty) {
|
|
setState(() => _ctl = null);
|
|
return;
|
|
}
|
|
setState(() => _error = null);
|
|
try {
|
|
final c = await apiClient.getAnkerControl(sn);
|
|
if (mounted) setState(() => _ctl = c);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = "$e";
|
|
_ctl = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _action(String action, [Map<String, dynamic> body = const {}]) async {
|
|
final sn = _serial.text.trim();
|
|
if (sn.isEmpty) return;
|
|
setState(() {
|
|
_busy = action;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await apiClient.ankerControlAction(sn, action, body);
|
|
await _refresh();
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = "$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();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = DriverVault.muted(context);
|
|
if (!_active) {
|
|
// No control mode active — point the user at Settings to enable it.
|
|
return _Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(t("charging.control.title"), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Text(t("charging.stations.noControlHint"), style: TextStyle(fontSize: 12, color: muted)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return _Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(t("charging.control.title"),
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
|
),
|
|
_StatusBadge(
|
|
label: _connected ? t("charging.control.connected") : t("charging.control.disconnected"),
|
|
ok: _connected,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(children: [
|
|
Expanded(
|
|
child: _chargers.isNotEmpty
|
|
? DropdownButtonFormField<String>(
|
|
initialValue: _serial.text.trim().isEmpty ? null : _serial.text.trim(),
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
|
items: [
|
|
for (final c in _chargers)
|
|
DropdownMenuItem(
|
|
value: c.sn,
|
|
child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn,
|
|
overflow: TextOverflow.ellipsis),
|
|
),
|
|
],
|
|
onChanged: (sn) {
|
|
if (sn == null) return;
|
|
setState(() => _serial.text = sn);
|
|
_refresh();
|
|
},
|
|
)
|
|
: TextField(
|
|
controller: _serial,
|
|
autocorrect: false,
|
|
decoration: InputDecoration(
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
hintText: t("charging.control.serialPlaceholder"),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))),
|
|
]),
|
|
if (_connected) ...[
|
|
const SizedBox(height: 12),
|
|
Row(children: [
|
|
Expanded(child: _MetricTile(value: _ctl?.connectorStatus.isNotEmpty == true ? _ctl!.connectorStatus : "—", label: t("charging.control.status"))),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _MetricTile(value: "${_ctl!.meterKwh.toStringAsFixed(2)} kWh", label: t("charging.control.meter"))),
|
|
]),
|
|
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")),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _busy == "clear-limit" ? null : () => _action("clear-limit"),
|
|
child: Text(t("charging.control.clearLimit")),
|
|
),
|
|
),
|
|
]),
|
|
const SizedBox(height: 12),
|
|
if (!_resetPrompt)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton(
|
|
onPressed: _busy == "reset"
|
|
? null
|
|
: () => setState(() {
|
|
_error = null;
|
|
_resetPassword.clear();
|
|
_resetPrompt = true;
|
|
}),
|
|
child: Text(t("charging.control.reset")),
|
|
),
|
|
)
|
|
else
|
|
// Step-up: 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")),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
] else
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Text(t("charging.control.connectHint"), style: TextStyle(fontSize: 12, color: muted)),
|
|
),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|