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 _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 createState() => _CarDetailScreenState(); } class _CarDetailData { final Car car; final List services; final List technicalChecks; final List maintenance; final List fuel; final FuelStats fuelStats; final List charging; final ChargingStats chargingStats; final List documents; final List parts; final List 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 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 { 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((_) => []), ]); return _CarDetailData( car: results[0] as Car, services: results[1] as List, technicalChecks: results[2] as List, maintenance: results[3] as List, fuel: results[4] as List, fuelStats: results[5] as FuelStats, charging: results[6] as List, chargingStats: results[7] as ChargingStats, documents: results[8] as List, parts: results[9] as List, reminders: results[10] as List, providers: results[11] as List, ); } void _reload() => setState(() => _future = _load()); Future _addService(Car car) async { final added = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ServiceSheet(carId: car.id, car: car), ); if (added == true) _reload(); } Future _editService(Car car, ServiceRecord record) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ServiceSheet(carId: car.id, car: car, record: record), ); if (saved == true) _reload(); } Future _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 _editCar(Car car) async { final updated = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => CarFormSheet(car: car), ); if (updated != null) _reload(); } Future _addPart(Car car) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _PartSheet(carId: car.id), ); if (saved == true) _reload(); } Future _editPart(Car car, Part part) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _PartSheet(carId: car.id, part: part), ); if (saved == true) _reload(); } Future _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 _sheet(Widget sheet) async { final saved = await showModalBottomSheet( 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 _deleteRecord(String confirmKey, Future 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 _completeReminder(Reminder r) async { try { await apiClient.completeReminder(r.id); _reload(); } catch (e) { _snack(t("errors.completeFailed", params: {"error": e})); } } Future _confirm(String message) async { final res = await showDialog( 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 _shareCar(Car car) async { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ShareSheet(car: car), ); } Future _editOdometer(Car car) async { final controller = TextEditingController(text: "${car.currentKm}"); final saved = await showDialog( 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 _deleteCar(Car car, _CarDetailData data) async { final confirmed = await showDialog( 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 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 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 _tabKeys(Car car, List 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 providers) => key == "provider" ? _providerLabel(car, providers) : t("car.tabs.$key"); Future _openViewPicker(Car car, List providers) async { final updated = await showModalBottomSheet( 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 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 _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 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 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( 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> _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> _permissionItems() => [ DropdownMenuItem(value: "read", child: Text(t("forms.share.read"))), DropdownMenuItem(value: "write", child: Text(t("forms.share.write"))), ]; Future _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 _setPermission(CarShare s, String value) async { try { await apiClient.addCarShare(widget.car.id, s.email, value); _reload(); } catch (e) { setState(() => _error = e.toString()); } } Future _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( 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>( 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( 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 _changed; /// The parts this car still records, and so the only ones with a checkbox. late final List _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 _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 _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")), ), ), ), ], ), ); } }