Files
DriverVault/Phone App/lib/screens/provider_tab.dart
T
tajniak81andClaude Opus 5 e249c2f4d8 Phone App: connected service, charging cost, a tab picker and data export
Both READMEs claimed web parity with data export/import as the only
omission. That was three gaps out of date: the car screen had no
connected-service tab, no per-car charging-cost tab, and no way to say what
a car's page shows - all three of which the web has had since the car view
became a property of the car rather than of the browser.

The tab bar was the thing blocking the rest. It was a fixed list of eight
Tab(text: "Information") literals, so it could neither grow a tab nor read
an arrangement, and it sat outside the translation system that the rest of
the app has used since b6bb6b1. It now builds from the car's own tabOrder
and hiddenTabs, and labels come from car.tabs.* like the web's.

Rather than retype four subtrees of strings in three languages, the shared
ones - car.*, settings.advanced, forms.charging, forms.import and the
common keys the phone was missing - are copied out of the Web App's own
language files, with the phone's existing wording winning every collision.
Polish and Danish therefore arrive complete and cannot drift between the
two apps. Only five strings are genuinely phone-only: the reorder hint, the
saved-file message, the open action and two validation lines.

The connected-service tab mirrors ProviderPanel: headline readings, the
offer to take a provider odometer that is ahead of the stored one, the
vehicle record, and one collapsible card per capability, rendered from the
server's flattened key/value pairs so a provider adding a field surfaces it
without touching this app. Two deliberate differences. The raw-payload
disclosure is dropped - Toyota's eight sections are megabytes of JSON on a
phone screen, and the flattened fields carry the same content. And the
readings cannot be dragged here, though a stored metricOrder is still
honoured, so an arrangement made on the web carries over.

The view picker takes the same line on gestures. The web rearranges by
dragging the tab bar itself and the Information rows themselves; on a touch
screen that gesture belongs to the tab bar, so both arrangements are made
in the picker with a handle instead, and hiddenTabs, hiddenFields, tabOrder
and fieldOrder all save in one PUT. The key catalogues live in
car_view_sheet.dart and mirror hideableCarTabs / arrangeableCarTabs /
hideableCarFields in cars.go, because the server rejects anything else.
arrangeKeys applies a partial stored order the way the API documents it: an
unknown key is dropped and an unnamed one follows the arranged ones, which
is what puts a tab added in a later release at the end of somebody's page
rather than the middle of it.

Charging cost is the electric twin of Fuel and is built as one - the same
stats panel, tile and form shape, measured between full charges. It is the
per-car cost log, not the Charging section in the bottom bar, which remains
the charger network and OCPP control.

Export and import needed a phone answer to two browser affordances. The
export is written to the app's documents directory under the filename the
server's Content-Disposition names, and offered to whatever opens JSON via
open_filex - the same route attachments already take. The import goes
through the system file picker, validates the file locally, and confirms
with the number of cars the file actually holds, because the server always
creates new records and never merges.

The one field worth calling out on the client: _carPayload still leaves the
provider link and the view arrangement out, matching carPayload in
records.go, so saving the car form cannot silently unlink a car or undo an
arrangement.

Known gap, deliberately not closed here: the older sheets in
record_form_sheets.dart and most of car_detail_screen.dart still carry
hardcoded English. Everything added here and every tab label goes through
t(), but translating the rest of the car screen is its own change and would
have buried this one.

Verified by flutter analyze (clean), flutter test - 13 pass, 6 of them new,
covering arrangeKeys against partial, unknown and duplicate keys, the
charging models keeping uncomputed figures null rather than a plausible
zero, the new Car fields, and ProviderSnapshot parsing an unreachable
provider as an answer rather than a failure - and flutter build apk
--debug, which succeeds. The Kotlin Gradle plugin warnings in that build
are pre-existing.

Not verified: nothing was run against a live API Server or on a device, so
the new screens have not been driven end to end - only compiled, analyzed
and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +02:00

631 lines
22 KiB
Dart

import "package:flutter/material.dart";
import "../format.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
/// The connected-service tab on a car: everything the manufacturer's own app
/// knows about it, fetched live. Mirrors the web ProviderPanel.
///
/// Two things shape what is below. The data is read under *this* user's account
/// — so a car shared from someone else shows nothing here unless the vehicle is
/// on this user's account too, and the tab says so rather than failing. And the
/// sections are whatever the plugin exposes, rendered from the server's
/// flattened key/value pairs: nothing here knows a single upstream field name,
/// so a provider adding a field surfaces it without a change to this file.
///
/// Unlike the web, the headline readings cannot be rearranged here — a drag on a
/// touch screen is the tab bar's gesture. The stored arrangement is still
/// honoured, so an order set on the web carries over.
class ProviderTab extends StatefulWidget {
final Car car;
/// Called with the updated car after a link, unlink or odometer sync, so the
/// page holds one copy of it and the tab bar can pick up a new provider link.
final void Function(Car) onCarUpdated;
const ProviderTab({super.key, required this.car, required this.onCarUpdated});
@override
State<ProviderTab> createState() => _ProviderTabState();
}
class _ProviderTabState extends State<ProviderTab> {
ProviderSnapshot? _snap;
bool _loading = false;
bool _syncing = false;
String? _error;
// The connect flow, for a car with no link yet (added by hand, or from before
// the feature existed).
List<VehicleProvider> _providers = const [];
String _linkProvider = "";
List<ProviderVehicle> _linkVehicles = const [];
String _linkVehicleId = "";
bool _linkLoading = false;
bool _linking = false;
bool get _linked => widget.car.provider.isNotEmpty;
List<VehicleProvider> get _connectable =>
_providers.where((p) => p.connected).toList();
String get _label {
final fromSnap = _snap?.label ?? "";
if (fromSnap.isNotEmpty) return fromSnap;
for (final p in _providers) {
if (p.id == widget.car.provider) return p.label;
}
return widget.car.provider.isEmpty ? t("car.tabs.connected") : widget.car.provider;
}
@override
void initState() {
super.initState();
if (_linked) {
_load();
} else {
_loadProviders();
}
}
@override
void didUpdateWidget(ProviderTab old) {
super.didUpdateWidget(old);
// Linking from the connect card replaces the car, which is when the live
// snapshot becomes fetchable at all.
if (old.car.provider != widget.car.provider) {
if (_linked) {
_load();
} else {
setState(() => _snap = null);
_loadProviders();
}
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final snap = await apiClient.getCarProvider(widget.car.id);
if (mounted) setState(() => _snap = snap);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadProviders() async {
try {
final list = await apiClient.listVehicleProviders();
if (!mounted) return;
setState(() {
_providers = list;
_linkProvider = _connectable.isEmpty ? "" : _connectable.first.id;
});
await _loadLinkVehicles();
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
Future<void> _loadLinkVehicles() async {
setState(() {
_linkVehicles = const [];
_linkVehicleId = "";
});
if (_linkProvider.isEmpty) return;
setState(() => _linkLoading = true);
try {
final list = await apiClient.listProviderVehicles(_linkProvider);
if (!mounted) return;
// Prefer the vehicle whose VIN matches the car — usually the only guess
// needed.
final vin = widget.car.vin.trim().toUpperCase();
String pick = list.isEmpty ? "" : list.first.id;
if (vin.isNotEmpty) {
for (final v in list) {
if (v.vin.toUpperCase() == vin) {
pick = v.id;
break;
}
}
}
setState(() {
_linkVehicles = list;
_linkVehicleId = pick;
});
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _linkLoading = false);
}
}
Future<void> _connect() async {
if (_linkVehicleId.isEmpty) return;
setState(() {
_linking = true;
_error = null;
});
try {
final car = await apiClient.linkCarProvider(widget.car.id,
provider: _linkProvider, vehicleId: _linkVehicleId);
widget.onCarUpdated(car);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _linking = false);
}
}
Future<void> _disconnect() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(t("car.provider.unlinkConfirm", params: {"label": _label})),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t("car.provider.unlink")),
),
],
),
);
if (ok != true) return;
setState(() => _error = null);
try {
final car = await apiClient.linkCarProvider(widget.car.id);
if (mounted) setState(() => _snap = null);
widget.onCarUpdated(car);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
/// Only the odometer is written back: it is the one provider reading the rest
/// of the app computes from (service intervals, km-triggered reminders), and
/// the one the user would otherwise retype after every drive.
Future<void> _applyOdometer() async {
setState(() {
_syncing = true;
_error = null;
});
try {
final car = await apiClient.syncCarProvider(widget.car.id, include: {
"identity": false,
"fuelType": false,
"dates": false,
"odometer": true,
});
widget.onCarUpdated(car);
await _load();
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _syncing = false);
}
}
/// The readings in the car's stored arrangement. A reading the arrangement
/// does not name — one the provider only started reporting later — follows the
/// arranged ones rather than appearing in the middle.
List<ProviderMetric> get _metrics {
final reported = _snap?.metrics ?? const <ProviderMetric>[];
final order = widget.car.metricOrder;
if (order.isEmpty) return reported;
final rank = {for (var i = 0; i < order.length; i++) order[i]: i};
final list = [...reported];
// A stable sort, so two unarranged readings keep the order they came in.
list.sort((a, b) => (rank[a.key] ?? order.length).compareTo(rank[b.key] ?? order.length));
return list;
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
children: [
if (_error != null) ...[
_ErrorBanner(_error!),
const SizedBox(height: 12),
],
if (_linked) ..._linkedBody(context) else ..._connectBody(context),
],
);
}
// --- linked: the live snapshot -------------------------------------------
List<Widget> _linkedBody(BuildContext context) {
final snap = _snap;
return [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_label,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
Text(t("car.provider.subtitle", params: {"label": _label}),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
],
),
),
IconButton(
tooltip: _loading ? t("car.provider.refreshing") : t("car.provider.refresh"),
icon: const Icon(Icons.refresh),
onPressed: _loading ? null : _load,
),
if (widget.car.canWrite)
IconButton(
tooltip: t("car.provider.unlink"),
icon: const Icon(Icons.link_off, color: DriverVault.danger),
onPressed: _disconnect,
),
],
),
const SizedBox(height: 8),
if (snap == null)
const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator()))
else if (snap.unavailable)
_Card(
child: Column(
children: [
Text(snap.detail,
textAlign: TextAlign.center,
style: const TextStyle(color: DriverVault.warning)),
const SizedBox(height: 6),
Text(t("car.provider.own"),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
],
),
)
else ...[
if (_metrics.isNotEmpty) _readings(context),
if (snap.suggestedCurrentKm > 0 && widget.car.canWrite) _odometerOffer(context, snap),
if (snap.vehicle != null) _vehicleCard(context, snap.vehicle!),
...snap.sections.map((s) => _sectionCard(context, s)),
const SizedBox(height: 8),
Text(
"${t("car.provider.updated", params: {"time": formatDateTime(snap.fetchedAt)})}"
" · ${t("car.provider.own")}",
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
),
],
];
}
Widget _readings(BuildContext context) => _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.provider.readings"),
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
for (final m in _metrics)
SizedBox(
width: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.provider.metrics.${m.key}"),
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
Text(m.unit.isEmpty ? m.value : "${m.value} ${m.unit}",
style: DriverVault.mono(context,
size: 13,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface)),
],
),
),
],
),
],
),
);
Widget _odometerOffer(BuildContext context, ProviderSnapshot snap) => _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.provider.odometerSuggest", params: {
"label": _label,
"km": formatKm(snap.suggestedCurrentKm),
})),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _syncing ? null : _applyOdometer,
child: Text(_syncing
? t("car.provider.updatingOdometer")
: t("car.provider.updateOdometer")),
),
),
],
),
);
Widget _vehicleCard(BuildContext context, ProviderVehicle v) => _Card(
padding: EdgeInsets.zero,
child: ExpansionTile(
initiallyExpanded: true,
shape: const Border(),
collapsedShape: const Border(),
title: Text(t("car.provider.vehicle"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(v.name, style: const TextStyle(fontWeight: FontWeight.w700)),
if (v.subtitle.isNotEmpty)
Text(v.subtitle,
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
if (v.vin.isNotEmpty)
Text(v.vin,
style: DriverVault.mono(context,
size: 11, color: DriverVault.muted(context))),
],
),
),
if (v.imageUrl.isNotEmpty)
// The provider's own CDN, so it can fail on a phone with no
// route to it — a broken image must not take the tab with it.
Image.network(v.imageUrl,
height: 64,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
],
),
if (v.fields.isNotEmpty) _fieldsExpander(context, t("car.provider.allFields"), v.fields),
],
),
);
Widget _sectionCard(BuildContext context, ProviderSection sec) => _Card(
padding: EdgeInsets.zero,
child: ExpansionTile(
shape: const Border(),
collapsedShape: const Border(),
title: Row(
children: [
Expanded(
child: Text(t("car.provider.sections.${sec.id}"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
),
// A failed section says so in the collapsed header, but only as a
// badge: the provider's own message can run to hundreds of
// characters, which belongs in the body with the rest of it.
if (sec.status == "error")
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: DriverVault.isDark(context)
? DriverVault.warningSoftDark
: DriverVault.warningSoft,
borderRadius: BorderRadius.circular(6),
),
child: Text(t("car.provider.sectionFailed"),
style: const TextStyle(fontSize: 11, color: DriverVault.warning)),
),
],
),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
children: [
if (sec.status == "error")
Align(
alignment: Alignment.centerLeft,
child: Text(sec.error,
style: const TextStyle(fontSize: 12, color: DriverVault.warning)),
)
else if (sec.status == "empty")
Align(
alignment: Alignment.centerLeft,
child: Text(t("car.provider.sectionEmpty"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
)
else ...[
_fieldList(context, sec.fields),
if (sec.truncated)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
t("car.provider.truncated", params: {"n": sec.fields.length}),
style: const TextStyle(fontSize: 11, color: DriverVault.warning),
),
),
],
],
),
);
Widget _fieldsExpander(BuildContext context, String label, List<ProviderField> fields) =>
Theme(
// The nested expander would otherwise draw its own divider lines inside
// the card it is already sitting in.
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: DriverVault.brandOnTint(context))),
childrenPadding: EdgeInsets.zero,
children: [_fieldList(context, fields)],
),
);
Widget _fieldList(BuildContext context, List<ProviderField> fields) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final f in fields)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(f.key,
style: DriverVault.mono(context,
size: 11, color: DriverVault.muted(context))),
),
Expanded(child: Text(f.value, style: const TextStyle(fontSize: 12))),
],
),
),
],
);
// --- not linked: offer to connect this car to a vehicle -------------------
List<Widget> _connectBody(BuildContext context) {
if (_connectable.isEmpty) {
return [
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.provider.connectTitle"),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Text(t("car.provider.noProviders"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
],
),
),
];
}
return [
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.provider.connectTitle"),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(t("car.provider.connectHint"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
const SizedBox(height: 12),
if (_connectable.length > 1) ...[
DropdownButtonFormField<String>(
initialValue: _linkProvider.isEmpty ? null : _linkProvider,
decoration: InputDecoration(
labelText: t("forms.import.service"), border: const OutlineInputBorder()),
items: [
for (final p in _connectable)
DropdownMenuItem(value: p.id, child: Text(p.label)),
],
onChanged: (v) {
if (v == null) return;
setState(() => _linkProvider = v);
_loadLinkVehicles();
},
),
const SizedBox(height: 12),
],
if (_linkLoading)
Text(t("forms.import.loadingVehicles"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context)))
else if (_linkVehicles.isEmpty)
Text(t("forms.import.noVehicles"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context)))
else
DropdownButtonFormField<String>(
key: ValueKey("$_linkProvider/$_linkVehicleId"),
initialValue: _linkVehicleId.isEmpty ? null : _linkVehicleId,
isExpanded: true,
decoration: InputDecoration(
labelText: t("forms.import.selectVehicle"),
border: const OutlineInputBorder()),
items: [
for (final v in _linkVehicles)
DropdownMenuItem(
value: v.id,
child: Text(v.vin.isEmpty ? v.name : "${v.name} · ${v.vin}",
overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() => _linkVehicleId = v ?? ""),
),
if (widget.car.canWrite) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _linkVehicleId.isEmpty || _linking ? null : _connect,
child: Text(_linking
? t("car.provider.connecting")
: t("car.provider.connectSubmit")),
),
),
],
],
),
),
];
}
}
/// The card chrome the rest of the car screen uses, repeated here so this tab
/// sits flush with the others.
class _Card extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
const _Card({required this.child, this.padding = const EdgeInsets.all(14)});
@override
Widget build(BuildContext context) => Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: Padding(padding: padding, child: child),
);
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner(this.message);
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: DriverVault.isDark(context)
? DriverVault.dangerSoftDark
: DriverVault.dangerSoft,
borderRadius: BorderRadius.circular(10),
),
child: Text(message,
style: const TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w500)),
);
}