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>
990 lines
35 KiB
Dart
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|