Files
DriverVault/Phone App/lib/screens/car_detail_screen.dart
T
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.

It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.

There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.

A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.

The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.

Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.

Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.

Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.

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

2303 lines
83 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();
final parts = visibleParts(car);
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,
parts: parts,
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;
/// The parts this car still records. Passed in rather than read off the car
/// for the same reason the columns are: every row shows the same ones.
final List<ServicePart> parts;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const _ServiceTile({
required this.record,
required this.columns,
required this.parts,
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, parts);
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.
///
/// Every part, not only the shown ones: an edit has to send back what a part
/// this car has switched off already said, because the API rewrites all of the
/// booleans from the body and an omitted one would come back false.
late final Map<String, bool> _changed;
/// The parts this car still records, and so the only ones with a checkbox.
late final List<ServicePart> _parts;
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 ?? "");
_parts = visibleParts(widget.car);
// 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 —
// unless this car has switched that part off, since ticking a box nobody was
// shown is not a default, it's a guess.
_changed = {
for (final part in kServiceParts)
part.key: r == null ? part.initial && _parts.contains(part) : 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 _parts)
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")),
),
),
),
],
),
);
}
}