Files
DriverVault/Phone App/lib/screens/charger_import_sheet.dart
tajniak81andClaude Opus 5 641f427db4 The chargers you own, on the phone as well
The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 08:27:16 +02:00

377 lines
12 KiB
Dart

import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
/// Create a home charger from one on a connected charger service (Anker Solix or
/// Greencell today; the endpoints are generic, so the next service needs no
/// changes here).
///
/// Shorter than the car import on purpose: a charger is a name, a serial and the
/// hardware behind it, and the service's list already carries all three. There is
/// nothing to choose about what to pull, so the whole sheet is pick one, press
/// Import. Pops the created [HomeCharger] when it saved.
Future<HomeCharger?> showChargerImportSheet(BuildContext context) {
return showModalBottomSheet<HomeCharger>(
context: context,
isScrollControlled: true,
builder: (_) => const _ChargerImportSheet(),
);
}
class _ChargerImportSheet extends StatefulWidget {
const _ChargerImportSheet();
@override
State<_ChargerImportSheet> createState() => _ChargerImportSheetState();
}
class _ChargerImportSheetState extends State<_ChargerImportSheet> {
final _name = TextEditingController();
List<ChargerProvider> _providers = const [];
String _provider = "";
List<ProviderCharger> _chargers = const [];
String _selectedId = "";
bool _loading = true;
bool _loadingChargers = false;
bool _importing = false;
String? _error;
/// Why the provider can't be used right now (not connected, an org switch off,
/// …). The server phrases this; the sheet just shows it.
String _detail = "";
/// The name field tracks the selected charger until the user types their own.
bool _nameEdited = false;
@override
void initState() {
super.initState();
_init();
}
@override
void dispose() {
_name.dispose();
super.dispose();
}
ChargerProvider? get _current {
for (final p in _providers) {
if (p.id == _provider) return p;
}
return null;
}
ProviderCharger? get _selected {
for (final c in _chargers) {
if (c.id == _selectedId) return c;
}
return null;
}
bool get _canImport =>
_selected != null && _selected!.linkedChargerId.isEmpty && !_importing;
Future<void> _init() async {
try {
final list = await apiClient.listChargerProviders();
if (!mounted) return;
setState(() {
_providers = list;
final connected = list.where((p) => p.connected);
_provider = connected.isNotEmpty
? connected.first.id
: (list.isNotEmpty ? list.first.id : "");
});
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _loading = false);
}
await _loadChargers();
}
Future<void> _loadChargers() async {
setState(() {
_chargers = const [];
_selectedId = "";
_detail = "";
});
if (_provider.isEmpty) return;
// A provider that is not connected has nothing to list; say what to do about
// it instead of asking the server a question it has already answered.
if (_current?.connected != true) {
setState(() => _detail = _current?.detail ?? "");
return;
}
setState(() {
_loadingChargers = true;
_error = null;
});
try {
final res = await apiClient.listProviderChargers(_provider);
if (!mounted) return;
// Preselect the first charger that isn't here already — with one charger
// on the account that is the whole selection step.
var pick = "";
for (final c in res.chargers) {
if (c.linkedChargerId.isEmpty) {
pick = c.id;
break;
}
}
setState(() {
_chargers = res.chargers;
_detail = res.unavailable ? res.detail : "";
_selectedId = pick;
});
_syncName();
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _loadingChargers = false);
}
}
void _syncName() {
if (_nameEdited) return;
_name.text = _selected?.name ?? "";
}
Future<void> _submit() async {
if (!_canImport) return;
setState(() {
_importing = true;
_error = null;
});
try {
final charger = await apiClient.importProviderCharger(
_provider,
_selected!.id,
name: _name.text.trim(),
);
if (mounted) Navigator.pop(context, charger);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _importing = false);
}
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("forms.importCharger.title"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
if (_loading)
Text(t("common.loading"), style: TextStyle(fontSize: 13, color: muted))
else if (_providers.isEmpty)
Text(t("forms.importCharger.noProviders"),
style: TextStyle(fontSize: 13, color: muted))
else
..._form(context, muted),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: Text(t("common.cancel")),
),
),
if (!_loading && _providers.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _canImport ? _submit : null,
child: Text(_importing
? t("forms.import.importing")
: t("forms.importCharger.submit")),
),
),
],
]),
],
),
),
);
}
List<Widget> _form(BuildContext context, Color muted) {
return [
Text(t("forms.importCharger.subtitle"), style: TextStyle(fontSize: 13, color: muted)),
const SizedBox(height: 12),
// Service picker. Hidden while there is only one to pick.
if (_providers.length > 1) ...[
DropdownButtonFormField<String>(
initialValue: _provider.isEmpty ? null : _provider,
isExpanded: true,
decoration: InputDecoration(
labelText: t("forms.import.service"),
border: const OutlineInputBorder(),
isDense: true,
),
items: [
for (final p in _providers)
DropdownMenuItem(
value: p.id,
enabled: p.connected,
child: Text(
p.connected ? p.label : "${p.label}${t("forms.import.notConnected")}",
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (id) {
if (id == null || id == _provider) return;
setState(() => _provider = id);
_loadChargers();
},
),
const SizedBox(height: 12),
],
if (_detail.isNotEmpty) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: DriverVault.isDark(context)
? DriverVault.warningSoftDark
: DriverVault.warningSoft,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Text(_detail,
style: const TextStyle(fontSize: 13, color: DriverVault.warning)),
),
const SizedBox(height: 12),
],
Text(t("forms.importCharger.selectCharger"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
if (_loadingChargers)
Text(t("forms.importCharger.loadingChargers"),
style: TextStyle(fontSize: 13, color: muted))
else if (_chargers.isEmpty && _detail.isEmpty)
Text(t("forms.importCharger.noChargers"), style: TextStyle(fontSize: 13, color: muted))
else
for (final c in _chargers) _chargerRow(context, c, muted),
if (_selected != null) ...[
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: InputDecoration(
labelText: t("forms.importCharger.name"),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => _nameEdited = true,
),
],
];
}
Widget _chargerRow(BuildContext context, ProviderCharger c, Color muted) {
final imported = c.linkedChargerId.isNotEmpty;
final picked = _selectedId == c.id;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
onTap: imported
? null
: () {
setState(() => _selectedId = c.id);
_syncName();
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: picked ? DriverVault.brandTint(context) : null,
border: Border.all(
color: picked
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 10, top: 2),
child: Icon(
picked ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 20,
color: imported
? muted
: picked
? Theme.of(context).colorScheme.primary
: muted,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.name.isNotEmpty ? c.name : c.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
if (c.subtitle.isNotEmpty)
Text(c.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: muted)),
Text(c.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11).copyWith(color: muted)),
if (imported)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("forms.importCharger.alreadyImported"),
style: TextStyle(fontSize: 12, color: muted)),
)
else if (c.online == false)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("settings.integrations.chargerOffline"),
style: const TextStyle(fontSize: 12, color: DriverVault.warning)),
),
],
),
),
],
),
),
),
);
}
}