Both found by driving the installed app on a phone rather than by reading the code, which is worth noting: the second one is invisible in a simulator with gesture navigation turned off. The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric range (A/C off) 103.9 km". The long number is a reading converted out of miles: headlineMetrics multiplied by 1.609344 and printed whatever came out, so a range estimate claimed to know the distance to the metre, and the two readings disagreed about their own precision on the same card. Distances now keep one decimal and percentages none, applied by the reading's kind rather than by whether it was converted - a provider reporting 99.744 km natively gets the same treatment. Anything else is left alone, because without knowing what it measures there is no safe place to cut. The odometer already rounded to a whole number on its own path; this only changes the headline readings. The Add-user sheet's "Create user" button sat underneath the system navigation bar. Every one of these sheets padded its bottom with viewInsets.bottom, which is the keyboard - correct while typing and wrong the rest of the time, because with the keyboard down that inset is zero and the navigation bar is still there. They take the larger of the keyboard and the navigation bar now, since a raised keyboard covers the bar and the two must not be added. One helper on DriverVault rather than the same expression in six files, which is how the six drifted into being identical and identically wrong. Verified: go build, go vet and go test ./... pass, with a new test covering the conversion (62 mi reads 99.8 km), a native over-precise reading, a percentage, and the odometer's whole number surviving. flutter analyze clean, 21 tests pass, and the rebuilt release APK was installed on the phone - the Create user button now sits clear of the navigation bar, where the screenshot that prompted this showed it clipped. Not verified: the rounding is not visible on the phone yet. It talks to a deployed API Server that has not been rebuilt from this commit, so that tab will keep reading 99.744 until the server is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2207 lines
79 KiB
Dart
2207 lines
79 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../api.dart";
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../models.dart";
|
|
import "../format.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":
|
|
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,
|
|
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),
|
|
"buildDate": _dateOrDash(car.buildDate),
|
|
"firstRegistration": _dateOrDash(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") : "—";
|
|
|
|
static String _dateOrDash(String iso) {
|
|
final d = DateTime.tryParse(iso);
|
|
return d == null ? "—" : formatDate(d);
|
|
}
|
|
|
|
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))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ServiceTile extends StatelessWidget {
|
|
final ServiceRecord record;
|
|
final VoidCallback? onEdit;
|
|
final VoidCallback? onDelete;
|
|
const _ServiceTile({required this.record, this.onEdit, this.onDelete});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final chips = <Widget>[
|
|
if (record.changedOil) _chip(context, t("car.services.chipOil")),
|
|
if (record.changedEngineAirFilter) _chip(context, t("car.services.chipEngineFilter")),
|
|
if (record.changedCabinAirFilter) _chip(context, t("car.services.chipCabinFilter")),
|
|
];
|
|
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: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(formatDate(record.date), style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
Row(children: [
|
|
Text(formatKm(record.km)),
|
|
if (onEdit != null || onDelete != null)
|
|
_RowMenu(onEdit: onEdit, onDelete: onDelete),
|
|
]),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
t("car.services.next", params: {
|
|
"date": formatDate(record.nextServiceDate),
|
|
"km": formatKm(record.nextServiceKm),
|
|
}),
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
|
if (chips.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Wrap(spacing: 6, runSpacing: 6, children: chips),
|
|
],
|
|
_AttachmentLine(path: "/service-records", id: record.id, record: record),
|
|
if (record.notes.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
Text(record.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
const _AttachmentLine({required this.path, required this.id, required this.record});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!record.hasFile) return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
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;
|
|
late bool _oil, _engine, _cabin;
|
|
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 ?? "");
|
|
_oil = r?.changedOil ?? true;
|
|
_engine = r?.changedEngineAirFilter ?? false;
|
|
_cabin = r?.changedCabinAirFilter ?? false;
|
|
}
|
|
|
|
@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,
|
|
"changedOil": _oil,
|
|
"changedEngineAirFilter": _engine,
|
|
"changedCabinAirFilter": _cabin,
|
|
"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),
|
|
CheckboxListTile(
|
|
value: _oil,
|
|
onChanged: (v) => setState(() => _oil = v ?? false),
|
|
title: Text(t("forms.service.oil")),
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
),
|
|
CheckboxListTile(
|
|
value: _engine,
|
|
onChanged: (v) => setState(() => _engine = v ?? false),
|
|
title: Text(t("forms.service.engineFilter")),
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
),
|
|
CheckboxListTile(
|
|
value: _cabin,
|
|
onChanged: (v) => setState(() => _cabin = v ?? false),
|
|
title: Text(t("forms.service.cabinFilter")),
|
|
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")),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|