Files
DriverVault/Phone App/lib/screens/car_detail_screen.dart
T
tajniak81andClaude Opus 5 6b7abb4b84 Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.

A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.

The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.

Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.

Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.

Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.

Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.

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

2286 lines
82 KiB
Dart

import "package:flutter/material.dart";
import "../api.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../format.dart";
import "../service_parts.dart";
import "../theme.dart";
import "../widgets/attachment_field.dart";
import "car_form_sheet.dart";
import "car_view_sheet.dart";
import "provider_tab.dart";
import "record_form_sheets.dart";
/// Serializes a car to the full update payload. The API's updateCar rewrites
/// every column from the body, so partial payloads would blank omitted fields —
/// always send them all, overriding only what changed.
Map<String, dynamic> _carPayload(Car car, {int? currentKm}) => {
"name": car.name,
"make": car.make,
"model": car.model,
"year": car.year,
"registration": car.registration,
"registrationCountry": car.registrationCountry,
"vin": car.vin,
"oilSpec": car.oilSpec,
"transmissionOilSpec": car.transmissionOilSpec,
"differentialOilSpec": car.differentialOilSpec,
"brakeFluidSpec": car.brakeFluidSpec,
"coolantSpec": car.coolantSpec,
"fuelType": car.fuelType,
"buildDate": car.buildDate,
"firstRegistrationDate": car.firstRegistrationDate,
"serviceIntervalDays": car.serviceIntervalDays,
"serviceIntervalKm": car.serviceIntervalKm,
"technicalCheckIntervalDays": car.technicalCheckIntervalDays,
"currentKm": currentKm ?? car.currentKm,
};
class CarDetailScreen extends StatefulWidget {
final String carId;
const CarDetailScreen({super.key, required this.carId});
@override
State<CarDetailScreen> createState() => _CarDetailScreenState();
}
class _CarDetailData {
final Car car;
final List<ServiceRecord> services;
final List<TechnicalCheck> technicalChecks;
final List<MaintenanceEntry> maintenance;
final List<FuelEntry> fuel;
final FuelStats fuelStats;
final List<ChargingSession> charging;
final ChargingStats chargingStats;
final List<CarDocument> documents;
final List<Part> parts;
final List<Reminder> reminders;
/// The manufacturer services this user has connected. Only read to decide
/// whether the connected-service tab is on offer for a car that has no link
/// yet — the tab loads its own data once it is open.
final List<VehicleProvider> providers;
_CarDetailData({
required this.car,
required this.services,
required this.technicalChecks,
required this.maintenance,
required this.fuel,
required this.fuelStats,
required this.charging,
required this.chargingStats,
required this.documents,
required this.parts,
required this.reminders,
required this.providers,
});
}
class _CarDetailScreenState extends State<CarDetailScreen> {
late Future<_CarDetailData> _future;
@override
void initState() {
super.initState();
_future = _load();
}
// One round trip per collection, all in flight together: the tabs are all
// rendered by the same FutureBuilder, so waiting for them serially would show
// the spinner for the sum of the requests rather than the slowest one.
Future<_CarDetailData> _load() async {
final results = await Future.wait([
apiClient.getCar(widget.carId),
apiClient.listCarServices(widget.carId),
apiClient.listCarTechnicalChecks(widget.carId),
apiClient.listCarMaintenance(widget.carId),
apiClient.listCarFuelEntries(widget.carId),
apiClient.getCarFuelStats(widget.carId),
apiClient.listCarChargingSessions(widget.carId),
apiClient.getCarChargingStats(widget.carId),
apiClient.listCarDocuments(widget.carId),
apiClient.listCarParts(widget.carId),
apiClient.listCarReminders(widget.carId),
// Only decides whether one tab is offered, so it must not be able to take
// the whole page down with it — a provider plugin that is misconfigured
// (or a server too old to know the endpoint) leaves the tab off instead.
apiClient.listVehicleProviders().catchError((_) => <VehicleProvider>[]),
]);
return _CarDetailData(
car: results[0] as Car,
services: results[1] as List<ServiceRecord>,
technicalChecks: results[2] as List<TechnicalCheck>,
maintenance: results[3] as List<MaintenanceEntry>,
fuel: results[4] as List<FuelEntry>,
fuelStats: results[5] as FuelStats,
charging: results[6] as List<ChargingSession>,
chargingStats: results[7] as ChargingStats,
documents: results[8] as List<CarDocument>,
parts: results[9] as List<Part>,
reminders: results[10] as List<Reminder>,
providers: results[11] as List<VehicleProvider>,
);
}
void _reload() => setState(() => _future = _load());
Future<void> _addService(Car car) async {
final added = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _ServiceSheet(carId: car.id, car: car),
);
if (added == true) _reload();
}
Future<void> _editService(Car car, ServiceRecord record) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _ServiceSheet(carId: car.id, car: car, record: record),
);
if (saved == true) _reload();
}
Future<void> _deleteService(ServiceRecord record) async {
final ok = await _confirm(t("car.services.confirmDelete"));
if (!ok) return;
try {
await apiClient.deleteService(record.id);
_reload();
} catch (e) {
_snack("Delete failed: $e");
}
}
Future<void> _editCar(Car car) async {
final updated = await showModalBottomSheet<Car?>(
context: context,
isScrollControlled: true,
builder: (_) => CarFormSheet(car: car),
);
if (updated != null) _reload();
}
Future<void> _addPart(Car car) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _PartSheet(carId: car.id),
);
if (saved == true) _reload();
}
Future<void> _editPart(Car car, Part part) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _PartSheet(carId: car.id, part: part),
);
if (saved == true) _reload();
}
Future<void> _deletePart(Part part) async {
final ok = await _confirm(t("car.parts.confirmDelete"));
if (!ok) return;
try {
await apiClient.deletePart(part.id);
_reload();
} catch (e) {
_snack("Delete failed: $e");
}
}
/// Opens one of the record sheets and reloads if it saved. Every sheet pops
/// `true` on success, so they all route through here.
Future<void> _sheet(Widget sheet) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => sheet,
);
if (saved == true) _reload();
}
/// Confirms, deletes, then reloads. [confirmKey] is the collection's own
/// confirmation string — "Delete this refill?" and the rest — rather than a
/// noun slotted into one template, because the sentence does not survive that
/// treatment in every language.
Future<void> _deleteRecord(String confirmKey, Future<void> Function() delete) async {
final ok = await _confirm(t(confirmKey));
if (!ok) return;
try {
await delete();
_reload();
} catch (e) {
_snack(t("errors.deleteFailed", params: {"error": e}));
}
}
Future<void> _completeReminder(Reminder r) async {
try {
await apiClient.completeReminder(r.id);
_reload();
} catch (e) {
_snack(t("errors.completeFailed", params: {"error": e}));
}
}
Future<bool> _confirm(String message) async {
final res = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
content: Text(message),
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("common.delete")),
),
],
),
);
return res == true;
}
void _snack(String msg) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _shareCar(Car car) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _ShareSheet(car: car),
);
}
Future<void> _editOdometer(Car car) async {
final controller = TextEditingController(text: "${car.currentKm}");
final saved = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("car.actions.odometer")),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(suffixText: "km", border: OutlineInputBorder()),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
FilledButton(
onPressed: () async {
await apiClient.updateCar(
car.id,
_carPayload(car, currentKm: int.tryParse(controller.text.trim()) ?? 0),
);
if (ctx.mounted) Navigator.pop(ctx, true);
},
child: Text(t("common.save")),
),
],
),
);
if (saved == true) _reload();
}
Future<void> _deleteCar(Car car, _CarDetailData data) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => _DeleteCarDialog(car: car, data: data),
);
if (confirmed != true) return;
try {
await apiClient.deleteCar(car.id);
if (mounted) Navigator.pop(context); // back to dashboard (it refreshes)
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(t("errors.deleteFailed", params: {"error": e}))));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<_CarDetailData>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (snap.hasError) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text("${snap.error}")),
);
}
final data = snap.data!;
final car = data.car;
final latest = data.services.isNotEmpty ? data.services.first : null;
final status = serviceStatus(latest, car);
final tabKeys = _tabKeys(car, data.providers);
return DefaultTabController(
// The tab set is a property of the car: the view picker switches
// tabs off, and linking a provider adds one. Keying the controller
// on it rebuilds the controller instead of leaving it addressing a
// length that no longer exists.
key: ValueKey(tabKeys.join("/")),
length: tabKeys.length,
child: Scaffold(
appBar: AppBar(
title: Text(car.name),
actions: [
if (car.isOwner)
IconButton(
tooltip: t("car.actions.share"),
icon: const Icon(Icons.person_add_alt),
onPressed: () => _shareCar(car),
),
if (car.canWrite)
IconButton(
tooltip: t("car.viewPicker.open"),
icon: const Icon(Icons.tune),
onPressed: () => _openViewPicker(car, data.providers),
),
if (car.canWrite)
IconButton(
tooltip: t("car.actions.edit"),
icon: const Icon(Icons.edit_outlined),
onPressed: () => _editCar(car),
),
if (car.canWrite)
IconButton(
tooltip: t("car.actions.odometer"),
icon: const Icon(Icons.speed),
onPressed: () => _editOdometer(car),
),
if (car.isOwner)
IconButton(
tooltip: t("car.actions.delete"),
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteCar(car, data),
),
],
bottom: TabBar(
isScrollable: true,
tabs: [
for (final key in tabKeys)
Tab(text: _tabLabel(key, car, data.providers)),
],
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
children: [
_StatusHeader(car: car, status: status),
if (car.isReadOnly) ...[
const SizedBox(height: 12),
const _ReadOnlyNotice(),
],
],
),
),
Expanded(
child: TabBarView(
children: [for (final key in tabKeys) _tabBody(key, data)],
),
),
],
),
),
);
},
),
);
}
// --- which tabs this car shows -------------------------------------------
/// Whether the connected-service tab is on offer. It shows for a linked car,
/// and also for an unlinked one as long as this user has some manufacturer
/// account connected — that is where the tab offers to link it. A user with
/// nothing connected never sees the tab at all.
bool _showProviderTab(Car car, List<VehicleProvider> providers) =>
car.provider.isNotEmpty || providers.any((p) => p.connected);
/// The linked provider's display name ("MyToyota"), falling back to the raw
/// plugin name and then to the generic tab label.
String _providerLabel(Car car, List<VehicleProvider> providers) {
for (final p in providers) {
if (p.id == car.provider) return p.label;
}
return car.provider.isEmpty ? t("car.tabs.connected") : car.provider;
}
/// The tabs to show, in this car's arrangement: its stored order applied to
/// the catalogue, minus the ones switched off, minus the connected service
/// when it does not apply.
List<String> _tabKeys(Car car, List<VehicleProvider> providers) =>
arrangeKeys(kCarTabKeys, car.tabOrder)
.where((key) => !car.hiddenTabs.contains(key))
.where((key) => key != "provider" || _showProviderTab(car, providers))
.toList();
String _tabLabel(String key, Car car, List<VehicleProvider> providers) =>
key == "provider" ? _providerLabel(car, providers) : t("car.tabs.$key");
Future<void> _openViewPicker(Car car, List<VehicleProvider> providers) async {
final updated = await showModalBottomSheet<Car>(
context: context,
isScrollControlled: true,
builder: (_) => CarViewSheet(
car: car,
showProvider: _showProviderTab(car, providers),
providerLabel: car.provider.isEmpty ? "" : _providerLabel(car, providers),
),
);
if (updated != null) _reload();
}
/// One tab's content, by key. The keys are the web app's, so the two apps
/// cannot drift on what a stored arrangement means.
Widget _tabBody(String key, _CarDetailData data) {
final car = data.car;
final latest = data.services.isNotEmpty ? data.services.first : null;
switch (key) {
case "provider":
return ProviderTab(car: car, onCarUpdated: (_) => _reload());
case "info":
return _InfoTab(car: car, latest: latest);
case "services":
// The columns this car shows, in its arrangement — computed once for the
// whole list rather than per tile, since every row shows the same ones.
final columns = arrangeKeys(kServiceColumnKeys, car.serviceColumnOrder)
.where((key) => !car.hiddenServiceColumns.contains(key))
.toList();
return _TabList(
empty: data.services.isEmpty ? t("car.services.empty") : null,
onAdd: car.canWrite ? () => _addService(car) : null,
addLabel: t("car.services.add"),
children: data.services
.map((s) => _ServiceTile(
record: s,
columns: columns,
onEdit: car.canWrite ? () => _editService(car, s) : null,
onDelete: car.canWrite ? () => _deleteService(s) : null,
))
.toList(),
);
case "technical":
return _TabList(
empty: data.technicalChecks.isEmpty ? t("car.technical.empty") : null,
onAdd: car.canWrite ? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car)) : null,
addLabel: t("car.technical.add"),
children: data.technicalChecks
.map((c) => _TechnicalCheckTile(
check: c,
onEdit: car.canWrite
? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car, check: c))
: null,
onDelete: car.canWrite
? () => _deleteRecord("car.technical.confirmDelete",
() => apiClient.deleteTechnicalCheck(c.id))
: null,
))
.toList(),
);
case "maintenance":
return _TabList(
empty: data.maintenance.isEmpty ? t("car.maintenance.empty") : null,
onAdd: car.canWrite ? () => _sheet(MaintenanceSheet(carId: car.id)) : null,
addLabel: t("car.maintenance.add"),
children: data.maintenance
.map((m) => _MaintenanceTile(
entry: m,
onEdit: car.canWrite
? () => _sheet(MaintenanceSheet(carId: car.id, entry: m))
: null,
onDelete: car.canWrite
? () => _deleteRecord("car.maintenance.confirmDelete",
() => apiClient.deleteMaintenance(m.id))
: null,
))
.toList(),
);
// The stats panel sits above the refill list.
case "fuel":
return _TabList(
empty: null,
onAdd: car.canWrite ? () => _sheet(FuelSheet(carId: car.id)) : null,
addLabel: t("car.fuel.add"),
children: [
_FuelStatsPanel(stats: data.fuelStats),
const SizedBox(height: 8),
if (data.fuel.isEmpty)
_Empty(t("car.fuel.empty"))
else
...data.fuel.reversed.map((f) => _FuelTile(
entry: f,
onEdit: car.canWrite ? () => _sheet(FuelSheet(carId: car.id, entry: f)) : null,
onDelete: car.canWrite
? () => _deleteRecord(
"car.fuel.confirmDelete", () => apiClient.deleteFuelEntry(f.id))
: null,
)),
],
);
// The electric twin of the fuel tab, laid out the same way.
case "charging":
return _TabList(
empty: null,
onAdd: car.canWrite ? () => _sheet(ChargingSheet(carId: car.id)) : null,
addLabel: t("car.charging.add"),
children: [
_ChargingStatsPanel(stats: data.chargingStats),
const SizedBox(height: 8),
if (data.charging.isEmpty)
_Empty(t("car.charging.empty"))
else
...data.charging.reversed.map((c) => _ChargingTile(
entry: c,
onEdit: car.canWrite
? () => _sheet(ChargingSheet(carId: car.id, entry: c))
: null,
onDelete: car.canWrite
? () => _deleteRecord("car.charging.confirmDelete",
() => apiClient.deleteChargingSession(c.id))
: null,
)),
],
);
case "documents":
return _TabList(
empty: data.documents.isEmpty ? t("car.documents.empty") : null,
onAdd: car.canWrite ? () => _sheet(DocumentSheet(carId: car.id)) : null,
addLabel: t("car.documents.add"),
children: data.documents
.map((d) => _DocumentTile(
doc: d,
onEdit:
car.canWrite ? () => _sheet(DocumentSheet(carId: car.id, doc: d)) : null,
onDelete: car.canWrite
? () => _deleteRecord(
"car.documents.confirmDelete", () => apiClient.deleteDocument(d.id))
: null,
))
.toList(),
);
case "parts":
return _TabList(
empty: data.parts.isEmpty ? t("car.parts.empty") : null,
onAdd: car.canWrite ? () => _addPart(car) : null,
addLabel: t("car.parts.add"),
children: data.parts
.map((p) => _PartTile(
part: p,
onEdit: car.canWrite ? () => _editPart(car, p) : null,
onDelete: car.canWrite ? () => _deletePart(p) : null,
))
.toList(),
);
case "reminders":
return _TabList(
empty: data.reminders.isEmpty ? t("car.reminders.empty") : null,
onAdd: car.canWrite ? () => _sheet(ReminderSheet(carId: car.id, car: car)) : null,
addLabel: t("car.reminders.add"),
children: data.reminders
.map((r) => _ReminderTile(
reminder: r,
// Auto reminders are derived from a document or service
// record, so they are read-only here — edit the record they
// came from instead.
onEdit: car.canWrite && !r.auto
? () => _sheet(ReminderSheet(carId: car.id, car: car, reminder: r))
: null,
onDelete: car.canWrite && !r.auto
? () => _deleteRecord(
"car.reminders.confirmDelete", () => apiClient.deleteReminder(r.id))
: null,
onComplete: car.canWrite && !r.auto && !r.done
? () => _completeReminder(r)
: null,
))
.toList(),
);
}
// Unreachable for the keys above; a key from a newer release reaching an
// older build lands here rather than taking the page down.
return const SizedBox.shrink();
}
}
/// A scrollable tab body: an optional "+ Add" button, an empty-state message, or
/// the list of tiles.
class _TabList extends StatelessWidget {
final String? empty;
final VoidCallback? onAdd;
final String addLabel;
final List<Widget> children;
const _TabList({
required this.empty,
required this.onAdd,
required this.addLabel,
required this.children,
});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
children: [
if (onAdd != null)
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 18),
label: Text(addLabel),
),
),
const SizedBox(height: 8),
if (empty != null) _Empty(empty!) else ...children,
],
);
}
}
/// Slim header shown above the tabs: car subtitle (make/model) plus the
/// service-status badge. The car's spec details now live in the Information tab.
class _StatusHeader extends StatelessWidget {
final Car car;
final Status status;
const _StatusHeader({required this.car, required this.status});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Expanded(
child: Text(car.subtitle.isEmpty ? car.name : car.subtitle,
style: TextStyle(color: DriverVault.muted(context))),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)),
child: Text(status.label,
style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontWeight: FontWeight.w600)),
),
],
),
),
);
}
}
/// Information tab: the car's spec details (oil specs, odometer, interval, next
/// due, VIN) in a scrollable card.
///
/// Which rows show and in which order belongs to the car, not to this device —
/// everyone it is shared with sees the same page. Both are edited in the view
/// picker (see [CarViewSheet]).
class _InfoTab extends StatelessWidget {
final Car car;
final ServiceRecord? latest;
const _InfoTab({required this.car, required this.latest});
/// Every row this tab can show, by the key the car's arrangement names it by.
/// Keys mirror hideableCarFields in the API's cars.go and car.info.* in the
/// translation files.
Map<String, String> _values() => {
"oilSpec": _orDash(car.oilSpec),
"transmissionOil": _orDash(car.transmissionOilSpec),
"differentialOil": _orDash(car.differentialOilSpec),
"brakeFluid": _orDash(car.brakeFluidSpec),
"coolant": _orDash(car.coolantSpec),
"odometer": formatKm(car.currentKm),
"serviceInterval":
"${_days(car.serviceIntervalDays)} · ${formatKm(car.serviceIntervalKm)}",
"nextDue":
"${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}",
"registrationPlate": _orDash(car.registration),
"registrationCountry": _orDash(car.registrationCountry),
"vin": _orDash(car.vin),
"fuelType": _fuelLabel(car.fuelType),
// Half-known dates print at their own precision: a build date is often
// only a year, and _dateOrDash would show a dash for one.
"buildDate": formatPartialDate(car.buildDate),
"firstRegistration": formatPartialDate(car.firstRegistrationDate),
};
@override
Widget build(BuildContext context) {
final values = _values();
final keys = arrangeKeys(kCarInfoFieldKeys, car.fieldOrder)
.where((key) => !car.hiddenFields.contains(key))
.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
children: [
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (keys.isEmpty)
Text(t("car.info.allHidden"),
style: TextStyle(color: DriverVault.muted(context)))
else
for (final key in keys) ...[
_kv(context, t("car.info.$key"), values[key] ?? "—"),
// The roadworthiness cycle is the car's other interval, and
// reads as a footnote to the service one rather than a row
// of its own — so it follows it, and goes when it goes.
if (key == "serviceInterval")
_kv(
context,
t("car.info.technicalCheckInterval"),
_days(car.technicalCheckIntervalDays > 0
? car.technicalCheckIntervalDays
: 365),
),
],
],
),
),
),
],
);
}
static String _orDash(String v) => v.isEmpty ? "—" : v;
/// A day count as prose, so Polish gets its plural forms rather than an
/// English-style n==1 split.
static String _days(int n) => t("car.info.daysValue", n: n);
static String _fuelLabel(String v) =>
kFuelTypes.contains(v) ? t("enums.fuelType.$v") : "—";
Widget _kv(BuildContext context, String k, String v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 150, child: Text(k, style: TextStyle(color: DriverVault.muted(context)))),
Expanded(child: Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface))),
],
),
);
}
/// One service record, laid out from the columns the car shows and in the order
/// it arranges them — the same two lists the web app drives its table from, so a
/// column switched off there is off here too.
///
/// A card is not a table, so the columns render as pieces rather than cells:
/// consecutive short ones share a wrapping line (which flows in reading order,
/// keeping the arrangement intact), and the three that need room of their own —
/// the parts, the notes and the file — each break onto their own. The date is
/// the one piece that carries no heading: it is what the record *is*, which is
/// also why the server refuses to let it be switched off.
class _ServiceTile extends StatelessWidget {
final ServiceRecord record;
/// The visible column keys, already arranged. Never empty: date cannot be
/// hidden.
final List<String> columns;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _ServiceTile({
required this.record,
required this.columns,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
// One widget per run. A run of short columns is a wrapping line; the other
// three are a run of one and render as themselves.
final pieces = [
for (final run in serviceColumnRuns(columns))
if (kInlineServiceColumns.contains(run.first))
Wrap(
spacing: 14,
runSpacing: 4,
children: [for (final key in run) _value(context, key)],
)
else
switch (run.first) {
"parts" => _parts(context),
"notes" => _notes(context),
_ => _file(context),
},
];
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The menu rides the first piece rather than a header line of its
// own, so hiding columns shortens the card instead of leaving a gap
// where the date used to be.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: pieces.first),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
],
),
for (final piece in pieces.skip(1))
Padding(padding: const EdgeInsets.only(top: 6), child: piece),
],
),
),
);
}
/// One short column: its heading and its value side by side. The date goes
/// bare and bold — a card list is read down its dates, and "Date" in front of
/// one says nothing the date doesn't.
Widget _value(BuildContext context, String key) {
final text = switch (key) {
"date" => formatDate(record.date),
"km" => formatKm(record.km),
"nextDate" => formatDate(record.nextServiceDate),
_ => formatKm(record.nextServiceKm),
};
if (key == "date") {
return Text(text, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14));
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${serviceColumnLabel(key)} ",
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
Text(text, style: const TextStyle(fontSize: 13)),
],
);
}
/// The parts column: a chip per part this service changed. The web has to fit
/// them in one table cell and so names the first and counts the rest behind a
/// panel; a card has the width to simply show them all.
Widget _parts(BuildContext context) {
final changed = changedParts(record);
if (changed.isEmpty) return _empty(context, "parts");
return Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final part in changed) _chip(context, t(part.chipLabel))],
);
}
Widget _notes(BuildContext context) {
if (record.notes.isEmpty) return _empty(context, "notes");
return Text(record.notes,
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic));
}
Widget _file(BuildContext context) {
if (!record.hasFile) return _empty(context, "file");
return _AttachmentLine(path: "/service-records", id: record.id, record: record, top: 0);
}
/// A column that is on but has nothing to show. Named rather than blank: it
/// was switched on deliberately, and a card that silently drops it reads as a
/// record where a part or a receipt might simply not have loaded.
Widget _empty(BuildContext context, String key) => Text(
"${serviceColumnLabel(key)} ${t("common.empty")}",
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
);
Widget _chip(BuildContext context, String label) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft,
borderRadius: BorderRadius.circular(6),
),
child: Text(label, style: const TextStyle(color: DriverVault.success, fontSize: 11, fontWeight: FontWeight.w500)),
);
}
class _PartTile extends StatelessWidget {
final Part part;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _PartTile({required this.part, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: ListTile(
dense: true,
title: Text(part.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(part.partNumber.isEmpty ? "—" : part.partNumber,
style: DriverVault.mono(context,
size: 12, weight: FontWeight.w400, color: DriverVault.muted(context))),
if (part.notes.isNotEmpty)
Text(part.notes,
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
_AttachmentLine(path: "/parts", id: part.id, record: part),
],
),
trailing: (onEdit != null || onDelete != null)
? _RowMenu(onEdit: onEdit, onDelete: onDelete)
: null,
),
);
}
}
/// Overflow menu with Edit/Delete for a record row (write-gated). [onComplete]
/// adds the reminders' extra action.
class _RowMenu extends StatelessWidget {
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final VoidCallback? onComplete;
const _RowMenu({this.onEdit, this.onDelete, this.onComplete});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) {
if (v == "edit") onEdit?.call();
if (v == "delete") onDelete?.call();
if (v == "complete") onComplete?.call();
},
itemBuilder: (_) => [
if (onComplete != null)
PopupMenuItem(value: "complete", child: Text(t("car.reminders.markDone"))),
if (onEdit != null) PopupMenuItem(value: "edit", child: Text(t("common.edit"))),
if (onDelete != null)
PopupMenuItem(
value: "delete",
child: Text(t("common.delete"),
style: const TextStyle(color: DriverVault.danger))),
],
);
}
}
/// The card every record tile sits in.
class _RecordCard extends StatelessWidget {
final Widget child;
const _RecordCard({required this.child});
@override
Widget build(BuildContext context) => Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
),
child: Padding(padding: const EdgeInsets.all(12), child: child),
);
}
/// The status pill used by the record tiles, in the shared badge colours.
class _Badge extends StatelessWidget {
final Status status;
const _Badge(this.status);
@override
Widget build(BuildContext context) {
final dark = DriverVault.isDark(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration:
BoxDecoration(color: status.bg(dark), borderRadius: BorderRadius.circular(999)),
child: Text(status.label,
style: TextStyle(
color: status.fg(dark), fontSize: 11, fontWeight: FontWeight.w600)),
);
}
}
/// A tappable "Receipt.pdf" line that fetches the record's attachment and hands
/// it to the phone's viewer. Renders nothing when there is no file.
class _AttachmentLine extends StatelessWidget {
final String path;
final String id;
final HasAttachment record;
/// The gap above the line. Zero for a caller that already spaces the rows it
/// stacks — the service card, whose file is one piece among several — and 6
/// for the tiles that append this to a block of prose.
final double top;
const _AttachmentLine({
required this.path,
required this.id,
required this.record,
this.top = 6,
});
@override
Widget build(BuildContext context) {
if (!record.hasFile) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: top),
child: InkWell(
onTap: () => openAttachment(context, path, id, record.fileName),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.attach_file, size: 14, color: DriverVault.brandOnTint(context)),
const SizedBox(width: 4),
Flexible(
child: Text(
record.fileName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: DriverVault.brandOnTint(context),
fontWeight: FontWeight.w500),
),
),
]),
),
);
}
}
/// A muted secondary line, used for the tiles' detail rows.
Widget _sub(BuildContext context, String text) => Text(
text,
style: TextStyle(fontSize: 12, color: DriverVault.muted(context)),
);
class _TechnicalCheckTile extends StatelessWidget {
final TechnicalCheck check;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _TechnicalCheckTile({required this.check, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
final dark = DriverVault.isDark(context);
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDate(check.date), style: const TextStyle(fontWeight: FontWeight.w600)),
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: check.passed
? (dark ? DriverVault.successSoftDark : DriverVault.successSoft)
: (dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft),
borderRadius: BorderRadius.circular(6),
),
child: Text(t(check.passed ? "car.technical.passed" : "car.technical.failed"),
style: TextStyle(
color: check.passed ? DriverVault.success : DriverVault.danger,
fontSize: 11,
fontWeight: FontWeight.w600)),
),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
]),
],
),
const SizedBox(height: 4),
// A failed check derives no next date, so the badge is only meaningful
// when there is one.
if (check.nextCheckDate != null)
Row(children: [
Expanded(
child: _sub(
context,
t("car.technical.next",
params: {"date": formatDate(check.nextCheckDate)}))),
_Badge(expiryStatus(check.expiry)),
])
else
_sub(context, t("forms.technical.failedHint")),
if (check.cost > 0 || check.station.isNotEmpty) ...[
const SizedBox(height: 4),
_sub(
context,
[
if (check.cost > 0) formatMoney(check.cost),
if (check.station.isNotEmpty) check.station,
].join(" · ")),
],
_AttachmentLine(path: "/technical-checks", id: check.id, record: check),
if (check.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(check.notes,
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
}
class _MaintenanceTile extends StatelessWidget {
final MaintenanceEntry entry;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _MaintenanceTile({required this.entry, this.onEdit, this.onDelete});
/// An unknown value falls back to the raw key rather than a blank: the
/// server is the authority on this enum, and a value added there should still
/// be legible in an app that has not caught up.
static String _label(String namespace, String value) {
final label = t("enums.$namespace.$value");
return label == "enums.$namespace.$value" ? value : label;
}
@override
Widget build(BuildContext context) {
final warranty = warrantyStatus(entry);
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(entry.description.isEmpty ? "—" : entry.description,
style: const TextStyle(fontWeight: FontWeight.w600)),
),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
],
),
const SizedBox(height: 4),
_sub(
context,
[
formatDate(entry.date),
if (entry.km > 0) formatKm(entry.km), // optional on maintenance
_label("maintenanceType", entry.type),
_label("maintenanceStatus", entry.status),
].join(" · ")),
if (entry.workshop.isNotEmpty || entry.location.isNotEmpty) ...[
const SizedBox(height: 4),
_sub(
context,
[
if (entry.workshop.isNotEmpty) entry.workshop,
if (entry.location.isNotEmpty) entry.location,
].join(" · ")),
],
if (entry.partsUsed.isNotEmpty) ...[
const SizedBox(height: 4),
_sub(context, t("car.maintenance.partsUsed", params: {"parts": entry.partsUsed})),
],
if (entry.totalCost > 0) ...[
const SizedBox(height: 4),
Text(t("forms.maintenance.total", params: {"total": formatMoney(entry.totalCost)}),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
],
if (warranty != null) ...[
const SizedBox(height: 6),
_Badge(warranty),
],
_AttachmentLine(path: "/maintenance", id: entry.id, record: entry),
if (entry.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(entry.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
}
/// The refill history's summary panel. Every figure here is the server's; the
/// nulls it sends for anything it could not derive read as "—".
class _FuelStatsPanel extends StatelessWidget {
final FuelStats stats;
const _FuelStatsPanel({required this.stats});
@override
Widget build(BuildContext context) {
if (stats.entries == 0) return const SizedBox.shrink();
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.fuel.title"), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_stat(context, t("car.fuel.average"), formatConsumption(stats.avgConsumptionL100)),
_stat(context, t("car.fuel.best"), formatConsumption(stats.bestConsumptionL100)),
_stat(context, t("car.fuel.worst"), formatConsumption(stats.worstConsumptionL100)),
_stat(context, t("car.fuel.average"), formatKmPerLiter(stats.avgKmPerLiter)),
_stat(context, t("car.fuel.costPerKm"), formatMoney(stats.costPerKm)),
_stat(context, t("car.fuel.colPerLiter"), formatMoney(stats.avgPricePerLiter)),
_stat(context, t("car.fuel.totalLiters"), formatLiters(stats.totalLiters)),
_stat(context, t("car.fuel.totalSpent"), formatMoney(stats.totalCost)),
_stat(context, t("car.fuel.refills"), "${stats.entries}"),
_stat(context, t("car.fuel.trackedDistance"), formatKm(stats.trackedDistanceKm)),
],
),
const SizedBox(height: 8),
// Without this the tracked distance reads as a mistake whenever the
// history starts or ends on a partial fill.
_sub(context, t("car.fuel.subtitle")),
],
),
);
}
Widget _stat(BuildContext context, String label, String value) => SizedBox(
width: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
Text(value,
style: DriverVault.mono(context,
size: 13,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface)),
],
),
);
}
/// The charging summary, the electric twin of [_FuelStatsPanel]. Same caveat on
/// the tracked distance: it covers the full-charge windows only.
class _ChargingStatsPanel extends StatelessWidget {
final ChargingStats stats;
const _ChargingStatsPanel({required this.stats});
@override
Widget build(BuildContext context) {
if (stats.entries == 0) return const SizedBox.shrink();
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.charging.title"), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_stat(context, t("car.charging.average"),
formatKwhConsumption(stats.avgConsumptionKwh100)),
_stat(context, t("car.charging.best"),
formatKwhConsumption(stats.bestConsumptionKwh100)),
_stat(context, t("car.charging.worst"),
formatKwhConsumption(stats.worstConsumptionKwh100)),
_stat(context, t("car.charging.average"), formatKmPerKwh(stats.avgKmPerKwh)),
_stat(context, t("car.charging.costPerKm"), formatMoney(stats.costPerKm)),
_stat(context, t("car.charging.colPerKwh"), formatMoney(stats.avgPricePerKwh)),
_stat(context, t("car.charging.totalKwh"), formatKwh(stats.totalKwh)),
_stat(context, t("car.charging.totalSpent"), formatMoney(stats.totalCost)),
_stat(context, t("car.charging.sessions"), "${stats.entries}"),
_stat(context, t("car.charging.trackedDistance"), formatKm(stats.trackedDistanceKm)),
],
),
const SizedBox(height: 8),
// Without this the tracked distance reads as a mistake whenever the
// history starts or ends on a partial charge.
_sub(context, t("car.charging.subtitle")),
],
),
);
}
Widget _stat(BuildContext context, String label, String value) => SizedBox(
width: 150,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
Text(value,
style: DriverVault.mono(context,
size: 13,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface)),
],
),
);
}
class _ChargingTile extends StatelessWidget {
final ChargingSession entry;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _ChargingTile({required this.entry, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDate(entry.date), style: const TextStyle(fontWeight: FontWeight.w600)),
Row(children: [
Text(formatKm(entry.km), style: const TextStyle(fontSize: 13)),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
]),
],
),
const SizedBox(height: 4),
_sub(
context,
[
formatKwh(entry.kwh),
if (entry.cost > 0) formatMoney(entry.cost),
if (entry.pricePerKwh != null)
"${entry.pricePerKwh!.toStringAsFixed(3)}/kWh",
].join(" · ")),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [
_tag(context,
t(entry.fullCharge ? "car.charging.fullCharge" : "car.charging.partialCharge"),
muted: !entry.fullCharge),
if (entry.missedSession) _tag(context, t("car.charging.missedBefore"), warn: true),
if (entry.location.isNotEmpty) _tag(context, entry.location, muted: true),
]),
// Consumption exists only on a full charge that closes a computable
// window; anything else has nothing to report.
if (entry.consumptionKwh100 != null) ...[
const SizedBox(height: 6),
Text(
t("car.charging.overDistance", params: {
"consumption": formatKwhConsumption(entry.consumptionKwh100),
"rate": formatKmPerKwh(entry.kmPerKwh),
"distance": formatKm(entry.distanceKm),
}),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
_AttachmentLine(path: "/charging-sessions", id: entry.id, record: entry),
if (entry.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(entry.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
Widget _tag(BuildContext context, String label, {bool muted = false, bool warn = false}) {
final dark = DriverVault.isDark(context);
final Color bg, fg;
if (warn) {
bg = dark ? DriverVault.warningSoftDark : DriverVault.warningSoft;
fg = DriverVault.warning;
} else if (muted) {
bg = dark ? DriverVault.darkSunken : DriverVault.ink50;
fg = DriverVault.muted(context);
} else {
bg = dark ? DriverVault.successSoftDark : DriverVault.successSoft;
fg = DriverVault.success;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.w600)),
);
}
}
class _FuelTile extends StatelessWidget {
final FuelEntry entry;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _FuelTile({required this.entry, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDate(entry.date), style: const TextStyle(fontWeight: FontWeight.w600)),
Row(children: [
Text(formatKm(entry.km), style: const TextStyle(fontSize: 13)),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
]),
],
),
const SizedBox(height: 4),
_sub(
context,
[
formatLiters(entry.liters),
if (entry.cost > 0) formatMoney(entry.cost),
if (entry.pricePerLiter != null)
"${entry.pricePerLiter!.toStringAsFixed(3)}/L",
].join(" · ")),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [
_tag(context, t(entry.fullTank ? "car.fuel.fullTank" : "car.fuel.partialFill"),
muted: !entry.fullTank),
if (entry.missedFill) _tag(context, t("car.fuel.missedBefore"), warn: true),
if (entry.station.isNotEmpty) _tag(context, entry.station, muted: true),
]),
// Consumption exists only on a full tank that closes a computable
// window; anything else has nothing to report.
if (entry.consumptionL100 != null) ...[
const SizedBox(height: 6),
Text(
t("car.fuel.overDistance", params: {
"consumption": formatConsumption(entry.consumptionL100),
"rate": formatKmPerLiter(entry.kmPerLiter),
"distance": formatKm(entry.distanceKm),
}),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
_AttachmentLine(path: "/fuel-entries", id: entry.id, record: entry),
if (entry.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(entry.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
Widget _tag(BuildContext context, String label, {bool muted = false, bool warn = false}) {
final dark = DriverVault.isDark(context);
final Color bg, fg;
if (warn) {
bg = dark ? DriverVault.warningSoftDark : DriverVault.warningSoft;
fg = DriverVault.warning;
} else if (muted) {
bg = dark ? DriverVault.darkSunken : DriverVault.ink50;
fg = DriverVault.muted(context);
} else {
bg = dark ? DriverVault.successSoftDark : DriverVault.successSoft;
fg = DriverVault.success;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label,
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500)),
);
}
}
class _DocumentTile extends StatelessWidget {
final CarDocument doc;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _DocumentTile({required this.doc, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(doc.title.isEmpty ? "—" : doc.title,
style: const TextStyle(fontWeight: FontWeight.w600)),
),
Row(children: [
_Badge(expiryStatus(doc.expiry)),
if (onEdit != null || onDelete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete),
]),
],
),
const SizedBox(height: 4),
_sub(
context,
[
_MaintenanceTile._label("documentType", doc.type),
if (doc.provider.isNotEmpty) doc.provider,
if (doc.reference.isNotEmpty) doc.reference,
].join(" · ")),
const SizedBox(height: 4),
_sub(
context,
t("car.documents.issuedRenews", params: {
"issued": formatDate(doc.issueDate),
"renews": formatDate(doc.expiryDate),
}) +
(doc.cost > 0 ? " · ${formatMoney(doc.cost)}" : "")),
_AttachmentLine(path: "/car-documents", id: doc.id, record: doc),
if (doc.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(doc.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
}
class _ReminderTile extends StatelessWidget {
final Reminder reminder;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final VoidCallback? onComplete;
const _ReminderTile({
required this.reminder,
this.onEdit,
this.onDelete,
this.onComplete,
});
@override
Widget build(BuildContext context) {
final r = reminder;
return _RecordCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
r.title.isEmpty ? "—" : r.title,
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: r.done ? TextDecoration.lineThrough : null,
color: r.done ? DriverVault.muted(context) : null,
),
),
),
Row(children: [
_Badge(reminderStatus(r)),
if (onEdit != null || onDelete != null || onComplete != null)
_RowMenu(onEdit: onEdit, onDelete: onDelete, onComplete: onComplete),
]),
],
),
const SizedBox(height: 4),
_sub(
context,
[
_MaintenanceTile._label("reminderType", r.type),
if (r.dueDate != null)
t("car.reminders.on", params: {"date": formatDate(r.dueDate)}),
if (r.dueKm > 0) t("car.reminders.at", params: {"km": formatKm(r.dueKm)}),
].join(" · ")),
if (r.repeats) ...[
const SizedBox(height: 4),
_sub(
context,
t("car.reminders.repeatsEvery", params: {
"every": [
if (r.repeatDays > 0) "${r.repeatDays}d",
if (r.repeatKm > 0) formatKm(r.repeatKm),
].join(" · ")
})),
],
if (r.auto) ...[
const SizedBox(height: 6),
_sub(context, t("car.reminders.autoHint")),
],
if (r.notes.isNotEmpty) ...[
const SizedBox(height: 6),
Text(r.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
],
],
),
);
}
}
class _Empty extends StatelessWidget {
final String text;
const _Empty(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(child: Text(text, style: const TextStyle(color: Colors.grey))),
);
}
/// Shown on a car that was shared with the current user read-only.
class _ReadOnlyNotice extends StatelessWidget {
const _ReadOnlyNotice();
@override
Widget build(BuildContext context) {
final onTint = DriverVault.brandOnTint(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: DriverVault.brandTint(context),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.visibility_outlined, size: 18, color: onTint),
const SizedBox(width: 8),
Expanded(
child: Text(
t("car.readOnlyNotice"),
style: TextStyle(color: onTint, fontSize: 13),
),
),
],
),
);
}
}
/// Owner-only bottom sheet to manage who a car is shared with: list current
/// grants, add one by email at a chosen permission, or remove one.
class _ShareSheet extends StatefulWidget {
final Car car;
const _ShareSheet({required this.car});
@override
State<_ShareSheet> createState() => _ShareSheetState();
}
class _ShareSheetState extends State<_ShareSheet> {
late Future<List<CarShare>> _future;
final _email = TextEditingController();
String _permission = "read";
bool _submitting = false;
String? _error;
@override
void initState() {
super.initState();
_future = apiClient.listCarShares(widget.car.id);
}
@override
void dispose() {
_email.dispose();
super.dispose();
}
void _reload() => setState(() => _future = apiClient.listCarShares(widget.car.id));
/// The two permission levels, built per call because t() reads the live
/// locale — a const list would freeze the language the sheet opened in.
List<DropdownMenuItem<String>> _permissionItems() => [
DropdownMenuItem(value: "read", child: Text(t("forms.share.read"))),
DropdownMenuItem(value: "write", child: Text(t("forms.share.write"))),
];
Future<void> _add() async {
final email = _email.text.trim();
if (email.isEmpty) return;
setState(() {
_submitting = true;
_error = null;
});
try {
await apiClient.addCarShare(widget.car.id, email, _permission);
_email.clear();
_permission = "read";
_reload();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _setPermission(CarShare s, String value) async {
try {
await apiClient.addCarShare(widget.car.id, s.email, value);
_reload();
} catch (e) {
setState(() => _error = e.toString());
}
}
Future<void> _remove(CarShare s) async {
try {
await apiClient.removeCarShare(widget.car.id, s.userId);
_reload();
} catch (e) {
setState(() => _error = e.toString());
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("forms.share.title", params: {"name": widget.car.name}),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
t("forms.share.body"),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
Row(
children: [
Expanded(
child: TextField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: t("forms.share.userEmail"),
border: const OutlineInputBorder(),
isDense: true,
),
),
),
const SizedBox(width: 8),
DropdownButton<String>(
value: _permission,
onChanged: (v) => setState(() => _permission = v ?? "read"),
items: _permissionItems(),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _add,
child: Text(t(_submitting ? "forms.share.submitting" : "forms.share.submit")),
),
),
const SizedBox(height: 16),
Text(t("forms.share.peopleWithAccess"),
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
FutureBuilder<List<CarShare>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
);
}
final shares = snap.data ?? [];
if (shares.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(t("forms.share.notShared"),
style: const TextStyle(color: Colors.grey)),
);
}
return Column(
children: shares
.map((s) => ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(s.label),
subtitle: s.name.isNotEmpty ? Text(s.email) : null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButton<String>(
value: s.permission,
underline: const SizedBox.shrink(),
onChanged: (v) => v == null ? null : _setPermission(s, v),
items: _permissionItems(),
),
IconButton(
tooltip: t("common.remove"),
icon: const Icon(Icons.close, size: 18),
onPressed: () => _remove(s),
),
],
),
))
.toList(),
);
},
),
],
),
);
}
}
/// Type-to-confirm dialog for deleting a car. The delete button is enabled only
/// once the user types the car's exact name — guarding this cascade delete
/// (which also removes all of the car's service records and parts).
class _DeleteCarDialog extends StatefulWidget {
final Car car;
final _CarDetailData data;
const _DeleteCarDialog({required this.car, required this.data});
@override
State<_DeleteCarDialog> createState() => _DeleteCarDialogState();
}
class _DeleteCarDialogState extends State<_DeleteCarDialog> {
final _confirm = TextEditingController();
bool _deleting = false;
@override
void dispose() {
_confirm.dispose();
super.dispose();
}
bool get _canDelete => _confirm.text.trim() == widget.car.name;
/// One collection's count as prose. The plural form is the translation
/// file's job — Polish needs three of them, which a trailing "s" cannot do.
String _count(String key, int n) => t("car.delete.$key", n: n);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(t("car.delete.title"),
style: const TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t("car.delete.body", params: {
"name": widget.car.name,
"services": _count("services", widget.data.services.length),
"maintenance": _count("maintenance", widget.data.maintenance.length),
"fuel": _count("fuel", widget.data.fuel.length),
"charging": _count("charging", widget.data.charging.length),
"documents": _count("documents", widget.data.documents.length),
"parts": _count("parts", widget.data.parts.length),
})),
const SizedBox(height: 16),
Text(t("car.delete.typeToConfirm", params: {"name": "“${widget.car.name}”"}),
style: const TextStyle(fontSize: 13, color: Colors.grey)),
const SizedBox(height: 6),
TextField(
controller: _confirm,
autofocus: true,
decoration: InputDecoration(hintText: widget.car.name, border: const OutlineInputBorder()),
onChanged: (_) => setState(() {}),
),
],
),
actions: [
TextButton(
onPressed: _deleting ? null : () => Navigator.pop(context, false),
child: Text(t("common.cancel")),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFDC2626)),
onPressed: (!_canDelete || _deleting)
? null
: () {
setState(() => _deleting = true);
Navigator.pop(context, true);
},
child: Text(t(_deleting ? "car.delete.deleting" : "car.delete.confirm")),
),
],
);
}
}
/// Bottom sheet for adding or editing a service record.
class _ServiceSheet extends StatefulWidget {
final String carId;
final Car car;
final ServiceRecord? record; // null => create
const _ServiceSheet({required this.carId, required this.car, this.record});
@override
State<_ServiceSheet> createState() => _ServiceSheetState();
}
class _ServiceSheetState extends State<_ServiceSheet> {
late DateTime _date;
late final TextEditingController _km;
late final TextEditingController _notes;
/// Which parts this record says were changed, keyed the way [kServiceParts]
/// keys them, so a part added to that list turns up in this sheet without a
/// second edit here.
late final Map<String, bool> _changed;
final _pending = PendingAttachment();
bool _saving = false;
String? _error;
bool get _isEdit => widget.record != null;
@override
void initState() {
super.initState();
final r = widget.record;
_date = r?.date ?? DateTime.now();
_km = TextEditingController(text: r == null ? "" : "${r.km}");
_notes = TextEditingController(text: r?.notes ?? "");
// An existing record is read through the part's own accessor; a new one
// starts from the part's default, which is why an oil change comes ticked.
_changed = {
for (final part in kServiceParts)
part.key: r == null ? part.initial : part.changed(r),
};
}
@override
void dispose() {
_km.dispose();
_notes.dispose();
super.dispose();
}
Future<void> _save() async {
final km = int.tryParse(_km.text.trim()) ?? 0;
if (_km.text.trim().isEmpty || km < 0) {
setState(() => _error = t("forms.validation.odometer"));
return;
}
setState(() {
_saving = true;
_error = null;
});
final payload = {
"car": widget.carId,
"date": _date.toUtc().toIso8601String(),
"km": km,
for (final part in kServiceParts) part.field: _changed[part.key],
"notes": _notes.text.trim(),
};
try {
final saved = _isEdit
? await apiClient.updateService(widget.record!.id, payload)
: await apiClient.createService(payload);
if (!_pending.isEmpty) {
try {
await applyAttachment("/service-records", saved.id, _pending);
} catch (e) {
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
}
}
if (mounted) Navigator.pop(context, true);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t(_isEdit ? "forms.service.editTitle" : "forms.service.addTitle"),
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)),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(formatDate(_date)),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _date = picked);
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _km,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t("forms.service.odometer"),
border: const OutlineInputBorder()),
),
),
],
),
const SizedBox(height: 8),
for (final part in kServiceParts)
CheckboxListTile(
value: _changed[part.key] ?? false,
onChanged: (v) => setState(() => _changed[part.key] = v ?? false),
title: Text(t(part.label)),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
TextField(
controller: _notes,
decoration: InputDecoration(
labelText: t("forms.service.notes"), border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
AttachmentField(
path: "/service-records",
record: widget.record,
recordId: widget.record?.id,
pending: _pending,
onChanged: () => setState(() {}),
legend: t("forms.service.attachmentLegend"),
),
const SizedBox(height: 8),
Text(
t("forms.service.autoHint", params: {
"days": widget.car.serviceIntervalDays,
"km": formatKm(widget.car.serviceIntervalKm),
}),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_saving
? t("common.saving")
: t(_isEdit ? "common.saveChanges" : "forms.service.submit")),
),
),
),
],
),
);
}
}
/// Bottom sheet for adding or editing a part.
class _PartSheet extends StatefulWidget {
final String carId;
final Part? part; // null => create
const _PartSheet({required this.carId, this.part});
@override
State<_PartSheet> createState() => _PartSheetState();
}
class _PartSheetState extends State<_PartSheet> {
late final TextEditingController _name;
late final TextEditingController _partNumber;
late final TextEditingController _notes;
final _pending = PendingAttachment();
bool _saving = false;
String? _error;
bool get _isEdit => widget.part != null;
@override
void initState() {
super.initState();
_name = TextEditingController(text: widget.part?.name ?? "");
_partNumber = TextEditingController(text: widget.part?.partNumber ?? "");
_notes = TextEditingController(text: widget.part?.notes ?? "");
}
@override
void dispose() {
_name.dispose();
_partNumber.dispose();
_notes.dispose();
super.dispose();
}
Future<void> _save() async {
final name = _name.text.trim();
if (name.isEmpty) {
setState(() => _error = t("forms.validation.partName"));
return;
}
setState(() {
_saving = true;
_error = null;
});
final payload = {
"car": widget.carId,
"name": name,
"partNumber": _partNumber.text.trim(),
"notes": _notes.text.trim(),
};
try {
final saved = _isEdit
? await apiClient.updatePart(widget.part!.id, payload)
: await apiClient.createPart(payload);
if (!_pending.isEmpty) {
try {
await applyAttachment("/parts", saved.id, _pending);
} catch (e) {
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
}
}
if (mounted) Navigator.pop(context, true);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t(_isEdit ? "forms.part.editTitle" : "forms.part.addTitle"),
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)),
),
TextField(
controller: _name,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: t("forms.part.name"),
hintText: t("forms.part.namePlaceholder"),
border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
TextField(
controller: _partNumber,
decoration: InputDecoration(
labelText: t("forms.part.partNumber"),
hintText: "04152-YZZA7",
border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
TextField(
controller: _notes,
decoration: InputDecoration(
labelText: t("forms.part.notes"),
hintText: t("forms.part.notesPlaceholder"),
border: const OutlineInputBorder()),
),
const SizedBox(height: 8),
AttachmentField(
path: "/parts",
record: widget.part,
recordId: widget.part?.id,
pending: _pending,
onChanged: () => setState(() {}),
legend: t("forms.part.attachmentLegend"),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_saving
? t("common.saving")
: t(_isEdit ? "common.saveChanges" : "forms.part.submit")),
),
),
),
],
),
);
}
}