Files
DriverVault/Phone App/lib/screens/charging_screen.dart
T
tajniak81andClaude Opus 4.8 13f50aa9a4 Phone App: add Charging screen + Settings integration tabs
Brings the Flutter app to parity with the recent Web App changes, which
touched features the Phone App did not yet have — so this builds the
Charging and Integrations subsystems, then applies the tab/fold structure.

Charging (new nav destination): split into "Public chargers" (stylized
discovery map + demo session + nearby public stations) and "Home chargers"
(the real Anker Solix OCPP control card — serial refresh, connector/energy
tiles, start/stop, current limit, password step-up on reset — plus the
user's home charger list). Gated by the per-user control mode, degrading to
a Settings hint when off.

Settings: split into "Personal settings" (the existing account/appearance/
profile/security/privacy/danger sections) and "Integrations". The latter
holds foldable Toyota and Anker Solix cards over the superadmin -> org ->
user cascade: locked fields with "inherited from" notes, org-admin scope
switch, enable toggle, save/test with health result, and Anker OCPP token
provisioning. Both tabs stay mounted (IndexedStack) so in-flight edits
survive a switch.

Adds the integration + OCPP control endpoints to api.dart, the resolved
IntegrationView/Scope/Field, IntegrationHealth and AnkerControl models, and
charging.*/settings.tabs.*/nav.charging strings (en/pl/da) plus
settings.integrations.* (en) — mirroring the Web App's own pl/da coverage,
which leaves integrations and charger control untranslated as an English
fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:03:52 +02:00

990 lines
35 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();
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");
}
await _refresh();
}
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: 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)),
],
),
);
}
}