Phone App: take the car screen off hardcoded English
The previous commit left the car screen half translated: its tab labels went
through t(), and everything underneath them did not. A Polish user opening a
car got translated tabs over English tiles, English forms and English
dialogs, which is worse than either extreme because it reads as a bug rather
than as a missing translation.
So the whole screen and everything it opens now reads from the language
files: the record tiles, the share and delete-car dialogs, the service and
part sheets it hosts, record_form_sheets.dart, car_form_sheet.dart, and the
attachment field whose buttons surface inside all of them.
Almost none of these strings are new. The Web App has said all of this in
three languages since b6bb6b1, so forms.*, enums.*, attachment.* and errors.*
are copied out of its language files the same way car.* was, and Polish and
Danish arrive complete. What is written here is only what the phone alone
needs, and the categories are worth naming because they are the reason the
two apps' files are not identical: tooltips, because the web labels its
buttons; the tiles' running prose, because the web lays the same data out as
table columns; client-side validation, because the web leans on the browser's
`required`; and the snackbars.
Three things changed shape rather than just wording.
The per-record delete prompts were one template with a noun slotted in -
"Delete this $what?" - which does not survive translation into a language
that inflects the noun. Each collection now names its own confirmation
string, which is what the web already had.
The delete-car dialog counted with a hand-rolled `"$n $noun${n == 1 ? '' :
's'}"`. Polish has three plural forms, so that could not be translated at
all; it now goes through the CLDR plurals in car.delete.*. It also only ever
named service records and parts, while the cascade takes maintenance, fuel,
charges and documents too - the translated body names all six, so it is now
passed the whole data set rather than two counts.
The enum labels (fuel types, maintenance type/status, document and reminder
types) were four const maps duplicated between the tiles and the pickers.
They are one lookup against enums.* now, with an unknown value falling back
to the raw key rather than a blank - the server owns that enum, and a value
added there should stay legible in an app that has not caught up.
Found and fixed while testing: the view picker rendered the literal string
"car.tabs.provider" as a row label on an unlinked car. That key does not
exist by design - a linked car's tab is named after the service, an unlinked
one falls back to car.tabs.connected - and the picker was the one caller that
did not know it.
Verified by flutter analyze (clean), flutter test - 19 pass, 7 of them new -
and flutter build apk --debug. The new tests cover what the analyzer cannot
see: the lookups built from a key at render time (car.tabs.$key,
enums.fuelType.$v, the delete dialog's plural counts, the connected service's
readings) are checked to have a real label in all three languages, so a
catalogue entry with no translation fails a test instead of reaching a screen
as a raw key path. That is the check that caught the bug above. A one-off
script also confirmed all 550 static t() keys resolve in en.json.
Not verified: still nothing run against a live API Server or on a device.
Known gaps, deliberately left: admin_users_screen.dart is still English, and
settings.integrations.* / charging.control.* exist in en.json only. The
second one is not the phone's alone - the Web App has exactly the same gap,
so translating that OCPP and connector vocabulary belongs to both apps in one
pass rather than letting the phone run ahead of the app the strings are
copied from. Both are now recorded in TRANSLATIONS.md, which had claimed the
car screen as untranslated and the web app as complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e249c2f4d8
commit
a2d9efec7e
@@ -145,7 +145,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _deleteService(ServiceRecord record) async {
|
||||
final ok = await _confirm("Delete this service record?");
|
||||
final ok = await _confirm(t("car.services.confirmDelete"));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.deleteService(record.id);
|
||||
@@ -183,7 +183,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _deletePart(Part part) async {
|
||||
final ok = await _confirm("Delete this part?");
|
||||
final ok = await _confirm(t("car.parts.confirmDelete"));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.deletePart(part.id);
|
||||
@@ -204,15 +204,18 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
if (saved == true) _reload();
|
||||
}
|
||||
|
||||
/// Confirms, deletes, then reloads. [what] names the record in the prompt.
|
||||
Future<void> _deleteRecord(String what, Future<void> Function() delete) async {
|
||||
final ok = await _confirm("Delete this $what?");
|
||||
/// 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("Delete failed: $e");
|
||||
_snack(t("errors.deleteFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +224,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
await apiClient.completeReminder(r.id);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_snack("Could not complete: $e");
|
||||
_snack(t("errors.completeFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +234,12 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Delete"),
|
||||
child: Text(t("common.delete")),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -260,7 +264,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Current odometer"),
|
||||
title: Text(t("car.actions.odometer")),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
@@ -268,7 +272,8 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await apiClient.updateCar(
|
||||
@@ -277,7 +282,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
);
|
||||
if (ctx.mounted) Navigator.pop(ctx, true);
|
||||
},
|
||||
child: const Text("Save"),
|
||||
child: Text(t("common.save")),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -285,14 +290,10 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
if (saved == true) _reload();
|
||||
}
|
||||
|
||||
Future<void> _deleteCar(Car car, int serviceCount, int partCount) async {
|
||||
Future<void> _deleteCar(Car car, _CarDetailData data) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => _DeleteCarDialog(
|
||||
car: car,
|
||||
serviceCount: serviceCount,
|
||||
partCount: partCount,
|
||||
),
|
||||
builder: (_) => _DeleteCarDialog(car: car, data: data),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
@@ -301,7 +302,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text("Delete failed: $e")));
|
||||
.showSnackBar(SnackBar(content: Text(t("errors.deleteFailed", params: {"error": e}))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +342,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
actions: [
|
||||
if (car.isOwner)
|
||||
IconButton(
|
||||
tooltip: "Share car",
|
||||
tooltip: t("car.actions.share"),
|
||||
icon: const Icon(Icons.person_add_alt),
|
||||
onPressed: () => _shareCar(car),
|
||||
),
|
||||
@@ -353,22 +354,21 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
),
|
||||
if (car.canWrite)
|
||||
IconButton(
|
||||
tooltip: "Edit car",
|
||||
tooltip: t("car.actions.edit"),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _editCar(car),
|
||||
),
|
||||
if (car.canWrite)
|
||||
IconButton(
|
||||
tooltip: "Update odometer",
|
||||
tooltip: t("car.actions.odometer"),
|
||||
icon: const Icon(Icons.speed),
|
||||
onPressed: () => _editOdometer(car),
|
||||
),
|
||||
if (car.isOwner)
|
||||
IconButton(
|
||||
tooltip: "Delete car",
|
||||
tooltip: t("car.actions.delete"),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () =>
|
||||
_deleteCar(car, data.services.length, data.parts.length),
|
||||
onPressed: () => _deleteCar(car, data),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
@@ -465,9 +465,9 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "services":
|
||||
return _TabList(
|
||||
empty: data.services.isEmpty ? "No service records yet." : null,
|
||||
empty: data.services.isEmpty ? t("car.services.empty") : null,
|
||||
onAdd: car.canWrite ? () => _addService(car) : null,
|
||||
addLabel: "Add service",
|
||||
addLabel: t("car.services.add"),
|
||||
children: data.services
|
||||
.map((s) => _ServiceTile(
|
||||
record: s,
|
||||
@@ -479,9 +479,9 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "technical":
|
||||
return _TabList(
|
||||
empty: data.technicalChecks.isEmpty ? "No technical checks yet." : null,
|
||||
empty: data.technicalChecks.isEmpty ? t("car.technical.empty") : null,
|
||||
onAdd: car.canWrite ? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car)) : null,
|
||||
addLabel: "Add check",
|
||||
addLabel: t("car.technical.add"),
|
||||
children: data.technicalChecks
|
||||
.map((c) => _TechnicalCheckTile(
|
||||
check: c,
|
||||
@@ -489,8 +489,8 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car, check: c))
|
||||
: null,
|
||||
onDelete: car.canWrite
|
||||
? () => _deleteRecord(
|
||||
"technical check", () => apiClient.deleteTechnicalCheck(c.id))
|
||||
? () => _deleteRecord("car.technical.confirmDelete",
|
||||
() => apiClient.deleteTechnicalCheck(c.id))
|
||||
: null,
|
||||
))
|
||||
.toList(),
|
||||
@@ -498,9 +498,9 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "maintenance":
|
||||
return _TabList(
|
||||
empty: data.maintenance.isEmpty ? "No workshop visits yet." : null,
|
||||
empty: data.maintenance.isEmpty ? t("car.maintenance.empty") : null,
|
||||
onAdd: car.canWrite ? () => _sheet(MaintenanceSheet(carId: car.id)) : null,
|
||||
addLabel: "Log visit",
|
||||
addLabel: t("car.maintenance.add"),
|
||||
children: data.maintenance
|
||||
.map((m) => _MaintenanceTile(
|
||||
entry: m,
|
||||
@@ -508,8 +508,8 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
? () => _sheet(MaintenanceSheet(carId: car.id, entry: m))
|
||||
: null,
|
||||
onDelete: car.canWrite
|
||||
? () => _deleteRecord(
|
||||
"workshop visit", () => apiClient.deleteMaintenance(m.id))
|
||||
? () => _deleteRecord("car.maintenance.confirmDelete",
|
||||
() => apiClient.deleteMaintenance(m.id))
|
||||
: null,
|
||||
))
|
||||
.toList(),
|
||||
@@ -520,18 +520,19 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
return _TabList(
|
||||
empty: null,
|
||||
onAdd: car.canWrite ? () => _sheet(FuelSheet(carId: car.id)) : null,
|
||||
addLabel: "Log refill",
|
||||
addLabel: t("car.fuel.add"),
|
||||
children: [
|
||||
_FuelStatsPanel(stats: data.fuelStats),
|
||||
const SizedBox(height: 8),
|
||||
if (data.fuel.isEmpty)
|
||||
const _Empty("No refills yet.")
|
||||
_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("refill", () => apiClient.deleteFuelEntry(f.id))
|
||||
? () => _deleteRecord(
|
||||
"car.fuel.confirmDelete", () => apiClient.deleteFuelEntry(f.id))
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
@@ -555,8 +556,8 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
? () => _sheet(ChargingSheet(carId: car.id, entry: c))
|
||||
: null,
|
||||
onDelete: car.canWrite
|
||||
? () => _deleteRecord(
|
||||
"charge", () => apiClient.deleteChargingSession(c.id))
|
||||
? () => _deleteRecord("car.charging.confirmDelete",
|
||||
() => apiClient.deleteChargingSession(c.id))
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
@@ -564,16 +565,17 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "documents":
|
||||
return _TabList(
|
||||
empty: data.documents.isEmpty ? "No documents yet." : null,
|
||||
empty: data.documents.isEmpty ? t("car.documents.empty") : null,
|
||||
onAdd: car.canWrite ? () => _sheet(DocumentSheet(carId: car.id)) : null,
|
||||
addLabel: "Add document",
|
||||
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("document", () => apiClient.deleteDocument(d.id))
|
||||
? () => _deleteRecord(
|
||||
"car.documents.confirmDelete", () => apiClient.deleteDocument(d.id))
|
||||
: null,
|
||||
))
|
||||
.toList(),
|
||||
@@ -581,9 +583,9 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "parts":
|
||||
return _TabList(
|
||||
empty: data.parts.isEmpty ? "No parts yet." : null,
|
||||
empty: data.parts.isEmpty ? t("car.parts.empty") : null,
|
||||
onAdd: car.canWrite ? () => _addPart(car) : null,
|
||||
addLabel: "Add part",
|
||||
addLabel: t("car.parts.add"),
|
||||
children: data.parts
|
||||
.map((p) => _PartTile(
|
||||
part: p,
|
||||
@@ -595,9 +597,9 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
|
||||
case "reminders":
|
||||
return _TabList(
|
||||
empty: data.reminders.isEmpty ? "No reminders yet." : null,
|
||||
empty: data.reminders.isEmpty ? t("car.reminders.empty") : null,
|
||||
onAdd: car.canWrite ? () => _sheet(ReminderSheet(carId: car.id, car: car)) : null,
|
||||
addLabel: "Add reminder",
|
||||
addLabel: t("car.reminders.add"),
|
||||
children: data.reminders
|
||||
.map((r) => _ReminderTile(
|
||||
reminder: r,
|
||||
@@ -608,7 +610,8 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
? () => _sheet(ReminderSheet(carId: car.id, car: car, reminder: r))
|
||||
: null,
|
||||
onDelete: car.canWrite && !r.auto
|
||||
? () => _deleteRecord("reminder", () => apiClient.deleteReminder(r.id))
|
||||
? () => _deleteRecord(
|
||||
"car.reminders.confirmDelete", () => apiClient.deleteReminder(r.id))
|
||||
: null,
|
||||
onComplete: car.canWrite && !r.auto && !r.done
|
||||
? () => _completeReminder(r)
|
||||
@@ -717,7 +720,7 @@ class _InfoTab extends StatelessWidget {
|
||||
"coolant": _orDash(car.coolantSpec),
|
||||
"odometer": formatKm(car.currentKm),
|
||||
"serviceInterval":
|
||||
"${car.serviceIntervalDays} days · ${formatKm(car.serviceIntervalKm)}",
|
||||
"${_days(car.serviceIntervalDays)} · ${formatKm(car.serviceIntervalKm)}",
|
||||
"nextDue":
|
||||
"${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}",
|
||||
"registrationPlate": _orDash(car.registration),
|
||||
@@ -762,7 +765,9 @@ class _InfoTab extends StatelessWidget {
|
||||
_kv(
|
||||
context,
|
||||
t("car.info.technicalCheckInterval"),
|
||||
"${car.technicalCheckIntervalDays > 0 ? car.technicalCheckIntervalDays : 365} days",
|
||||
_days(car.technicalCheckIntervalDays > 0
|
||||
? car.technicalCheckIntervalDays
|
||||
: 365),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -775,16 +780,12 @@ class _InfoTab extends StatelessWidget {
|
||||
|
||||
static String _orDash(String v) => v.isEmpty ? "—" : v;
|
||||
|
||||
static const Map<String, String> _fuelLabels = {
|
||||
"petrol": "Petrol (gasoline)",
|
||||
"petrol_lpg": "Petrol (gasoline) + LPG",
|
||||
"diesel": "Diesel",
|
||||
"diesel_lpg": "Diesel + LPG",
|
||||
"hybrid": "Hybrid",
|
||||
"electric": "Electric",
|
||||
"hydrogen": "Hydrogen",
|
||||
};
|
||||
static String _fuelLabel(String v) => _fuelLabels[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);
|
||||
@@ -813,9 +814,9 @@ class _ServiceTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chips = <Widget>[
|
||||
if (record.changedOil) _chip(context, "Oil & filter"),
|
||||
if (record.changedEngineAirFilter) _chip(context, "Engine air"),
|
||||
if (record.changedCabinAirFilter) _chip(context, "Cabin air"),
|
||||
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,
|
||||
@@ -841,7 +842,11 @@ class _ServiceTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text("Next: ${formatDate(record.nextServiceDate)} · ${formatKm(record.nextServiceKm)}",
|
||||
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),
|
||||
@@ -925,11 +930,13 @@ class _RowMenu extends StatelessWidget {
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (onComplete != null)
|
||||
const PopupMenuItem(value: "complete", child: Text("Mark done")),
|
||||
if (onEdit != null) const PopupMenuItem(value: "edit", child: Text("Edit")),
|
||||
PopupMenuItem(value: "complete", child: Text(t("car.reminders.markDone"))),
|
||||
if (onEdit != null) PopupMenuItem(value: "edit", child: Text(t("common.edit"))),
|
||||
if (onDelete != null)
|
||||
const PopupMenuItem(
|
||||
value: "delete", child: Text("Delete", style: TextStyle(color: DriverVault.danger))),
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
child: Text(t("common.delete"),
|
||||
style: const TextStyle(color: DriverVault.danger))),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1036,7 +1043,7 @@ class _TechnicalCheckTile extends StatelessWidget {
|
||||
: (dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(check.passed ? "Passed" : "Failed",
|
||||
child: Text(t(check.passed ? "car.technical.passed" : "car.technical.failed"),
|
||||
style: TextStyle(
|
||||
color: check.passed ? DriverVault.success : DriverVault.danger,
|
||||
fontSize: 11,
|
||||
@@ -1052,11 +1059,15 @@ class _TechnicalCheckTile extends StatelessWidget {
|
||||
// when there is one.
|
||||
if (check.nextCheckDate != null)
|
||||
Row(children: [
|
||||
Expanded(child: _sub(context, "Next: ${formatDate(check.nextCheckDate)}")),
|
||||
Expanded(
|
||||
child: _sub(
|
||||
context,
|
||||
t("car.technical.next",
|
||||
params: {"date": formatDate(check.nextCheckDate)}))),
|
||||
_Badge(expiryStatus(check.expiry)),
|
||||
])
|
||||
else
|
||||
_sub(context, "No next date derived from a failed check."),
|
||||
_sub(context, t("forms.technical.failedHint")),
|
||||
if (check.cost > 0 || check.station.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_sub(
|
||||
@@ -1084,21 +1095,13 @@ class _MaintenanceTile extends StatelessWidget {
|
||||
final VoidCallback? onDelete;
|
||||
const _MaintenanceTile({required this.entry, this.onEdit, this.onDelete});
|
||||
|
||||
static const _typeLabels = {
|
||||
"repair": "Repair",
|
||||
"inspection": "Inspection",
|
||||
"bodywork": "Bodywork",
|
||||
"tyres": "Tyres",
|
||||
"diagnostics": "Diagnostics",
|
||||
"recall": "Recall",
|
||||
"warranty": "Warranty work",
|
||||
"other": "Other",
|
||||
};
|
||||
static const _statusLabels = {
|
||||
"scheduled": "Scheduled",
|
||||
"in_progress": "In progress",
|
||||
"completed": "Completed",
|
||||
};
|
||||
/// 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) {
|
||||
@@ -1124,8 +1127,8 @@ class _MaintenanceTile extends StatelessWidget {
|
||||
[
|
||||
formatDate(entry.date),
|
||||
if (entry.km > 0) formatKm(entry.km), // optional on maintenance
|
||||
_typeLabels[entry.type] ?? entry.type,
|
||||
_statusLabels[entry.status] ?? entry.status,
|
||||
_label("maintenanceType", entry.type),
|
||||
_label("maintenanceStatus", entry.status),
|
||||
].join(" · ")),
|
||||
if (entry.workshop.isNotEmpty || entry.location.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
@@ -1138,11 +1141,11 @@ class _MaintenanceTile extends StatelessWidget {
|
||||
],
|
||||
if (entry.partsUsed.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_sub(context, "Parts: ${entry.partsUsed}"),
|
||||
_sub(context, t("car.maintenance.partsUsed", params: {"parts": entry.partsUsed})),
|
||||
],
|
||||
if (entry.totalCost > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text("Total: ${formatMoney(entry.totalCost)}",
|
||||
Text(t("forms.maintenance.total", params: {"total": formatMoney(entry.totalCost)}),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
if (warranty != null) ...[
|
||||
@@ -1173,31 +1176,28 @@ class _FuelStatsPanel extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Fuel summary", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(t("car.fuel.title"), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_stat(context, "Average", formatConsumption(stats.avgConsumptionL100)),
|
||||
_stat(context, "Best", formatConsumption(stats.bestConsumptionL100)),
|
||||
_stat(context, "Worst", formatConsumption(stats.worstConsumptionL100)),
|
||||
_stat(context, "Average", formatKmPerLiter(stats.avgKmPerLiter)),
|
||||
_stat(context, "Cost per km", formatMoney(stats.costPerKm)),
|
||||
_stat(context, "Price per litre", formatMoney(stats.avgPricePerLiter)),
|
||||
_stat(context, "Total litres", formatLiters(stats.totalLiters)),
|
||||
_stat(context, "Total cost", formatMoney(stats.totalCost)),
|
||||
_stat(context, "Refills", "${stats.entries}"),
|
||||
_stat(context, "Tracked distance", formatKm(stats.trackedDistanceKm)),
|
||||
_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,
|
||||
"Averages cover the distance between full tanks — the stretches the litres on"
|
||||
" record actually account for."),
|
||||
_sub(context, t("car.fuel.subtitle")),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1312,9 +1312,10 @@ class _ChargingTile extends StatelessWidget {
|
||||
].join(" · ")),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
_tag(context, entry.fullCharge ? t("forms.charging.fullCharge") : t("car.charging.partial"),
|
||||
_tag(context,
|
||||
t(entry.fullCharge ? "car.charging.fullCharge" : "car.charging.partialCharge"),
|
||||
muted: !entry.fullCharge),
|
||||
if (entry.missedSession) _tag(context, t("car.charging.gap"), warn: true),
|
||||
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
|
||||
@@ -1322,8 +1323,11 @@ class _ChargingTile extends StatelessWidget {
|
||||
if (entry.consumptionKwh100 != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"${formatKwhConsumption(entry.consumptionKwh100)} · ${formatKmPerKwh(entry.kmPerKwh)}"
|
||||
" over ${formatKm(entry.distanceKm)}",
|
||||
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),
|
||||
),
|
||||
],
|
||||
@@ -1392,9 +1396,9 @@ class _FuelTile extends StatelessWidget {
|
||||
].join(" · ")),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
_tag(context, entry.fullTank ? "Full tank" : "Partial fill",
|
||||
_tag(context, t(entry.fullTank ? "car.fuel.fullTank" : "car.fuel.partialFill"),
|
||||
muted: !entry.fullTank),
|
||||
if (entry.missedFill) _tag(context, "Missed fill before", warn: true),
|
||||
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
|
||||
@@ -1402,8 +1406,11 @@ class _FuelTile extends StatelessWidget {
|
||||
if (entry.consumptionL100 != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"${formatConsumption(entry.consumptionL100)} · ${formatKmPerLiter(entry.kmPerLiter)}"
|
||||
" over ${formatKm(entry.distanceKm)}",
|
||||
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),
|
||||
),
|
||||
],
|
||||
@@ -1445,16 +1452,6 @@ class _DocumentTile extends StatelessWidget {
|
||||
final VoidCallback? onDelete;
|
||||
const _DocumentTile({required this.doc, this.onEdit, this.onDelete});
|
||||
|
||||
static const _typeLabels = {
|
||||
"insurance": "Insurance",
|
||||
"pollution": "Pollution certificate",
|
||||
"registration": "Registration",
|
||||
"inspection": "Inspection",
|
||||
"roadTax": "Road tax",
|
||||
"warranty": "Warranty",
|
||||
"other": "Other",
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RecordCard(
|
||||
@@ -1479,15 +1476,18 @@ class _DocumentTile extends StatelessWidget {
|
||||
_sub(
|
||||
context,
|
||||
[
|
||||
_typeLabels[doc.type] ?? doc.type,
|
||||
_MaintenanceTile._label("documentType", doc.type),
|
||||
if (doc.provider.isNotEmpty) doc.provider,
|
||||
if (doc.reference.isNotEmpty) doc.reference,
|
||||
].join(" · ")),
|
||||
const SizedBox(height: 4),
|
||||
_sub(
|
||||
context,
|
||||
"Issued ${formatDate(doc.issueDate)} · Renews ${formatDate(doc.expiryDate)}"
|
||||
"${doc.cost > 0 ? " · ${formatMoney(doc.cost)}" : ""}"),
|
||||
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),
|
||||
@@ -1511,14 +1511,6 @@ class _ReminderTile extends StatelessWidget {
|
||||
this.onComplete,
|
||||
});
|
||||
|
||||
static const _typeLabels = {
|
||||
"maintenance": "Maintenance",
|
||||
"document": "Document renewal",
|
||||
"service": "Service",
|
||||
"inspection": "Inspection",
|
||||
"other": "Other",
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = reminder;
|
||||
@@ -1550,22 +1542,25 @@ class _ReminderTile extends StatelessWidget {
|
||||
_sub(
|
||||
context,
|
||||
[
|
||||
_typeLabels[r.type] ?? r.type,
|
||||
if (r.dueDate != null) "on ${formatDate(r.dueDate)}",
|
||||
if (r.dueKm > 0) "at ${formatKm(r.dueKm)}",
|
||||
_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,
|
||||
"Repeats every ${[
|
||||
if (r.repeatDays > 0) "${r.repeatDays}d",
|
||||
if (r.repeatKm > 0) formatKm(r.repeatKm),
|
||||
].join(" · ")}"),
|
||||
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, "Added automatically — edit the record it came from to change it."),
|
||||
_sub(context, t("car.reminders.autoHint")),
|
||||
],
|
||||
if (r.notes.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
@@ -1605,7 +1600,7 @@ class _ReadOnlyNotice extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Shared with you (read-only). You can't make changes.",
|
||||
t("car.readOnlyNotice"),
|
||||
style: TextStyle(color: onTint, fontSize: 13),
|
||||
),
|
||||
),
|
||||
@@ -1645,6 +1640,13 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
|
||||
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;
|
||||
@@ -1695,12 +1697,12 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Share ${widget.car.name}",
|
||||
Text(t("forms.share.title", params: {"name": widget.car.name}),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
"Read-only lets them view; read & write also lets them edit the car and its records.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
Text(
|
||||
t("forms.share.body"),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
@@ -1714,9 +1716,9 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
child: TextField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "User email",
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.share.userEmail"),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
@@ -1725,10 +1727,7 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
DropdownButton<String>(
|
||||
value: _permission,
|
||||
onChanged: (v) => setState(() => _permission = v ?? "read"),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "read", child: Text("Read")),
|
||||
DropdownMenuItem(value: "write", child: Text("Write")),
|
||||
],
|
||||
items: _permissionItems(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1737,12 +1736,12 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _submitting ? null : _add,
|
||||
child: Text(_submitting ? "Sharing…" : "Share"),
|
||||
child: Text(t(_submitting ? "forms.share.submitting" : "forms.share.submit")),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("People with access",
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(t("forms.share.peopleWithAccess"),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<CarShare>>(
|
||||
future: _future,
|
||||
@@ -1755,10 +1754,10 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
}
|
||||
final shares = snap.data ?? [];
|
||||
if (shares.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text("Not shared with anyone yet.",
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(t("forms.share.notShared"),
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
@@ -1775,13 +1774,10 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
value: s.permission,
|
||||
underline: const SizedBox.shrink(),
|
||||
onChanged: (v) => v == null ? null : _setPermission(s, v),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "read", child: Text("Read")),
|
||||
DropdownMenuItem(value: "write", child: Text("Write")),
|
||||
],
|
||||
items: _permissionItems(),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Remove",
|
||||
tooltip: t("common.remove"),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () => _remove(s),
|
||||
),
|
||||
@@ -1803,13 +1799,8 @@ class _ShareSheetState extends State<_ShareSheet> {
|
||||
/// (which also removes all of the car's service records and parts).
|
||||
class _DeleteCarDialog extends StatefulWidget {
|
||||
final Car car;
|
||||
final int serviceCount;
|
||||
final int partCount;
|
||||
const _DeleteCarDialog({
|
||||
required this.car,
|
||||
required this.serviceCount,
|
||||
required this.partCount,
|
||||
});
|
||||
final _CarDetailData data;
|
||||
const _DeleteCarDialog({required this.car, required this.data});
|
||||
@override
|
||||
State<_DeleteCarDialog> createState() => _DeleteCarDialogState();
|
||||
}
|
||||
@@ -1826,23 +1817,30 @@ class _DeleteCarDialogState extends State<_DeleteCarDialog> {
|
||||
|
||||
bool get _canDelete => _confirm.text.trim() == widget.car.name;
|
||||
|
||||
String _plural(int n, String noun) => "$n $noun${n == 1 ? '' : 's'}";
|
||||
/// 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: const Text("Delete this car?", style: TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w700)),
|
||||
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(
|
||||
"This permanently deletes ${widget.car.name} and all of its "
|
||||
"${_plural(widget.serviceCount, 'service record')} and "
|
||||
"${_plural(widget.partCount, 'part')}. This cannot be undone.",
|
||||
),
|
||||
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("Type “${widget.car.name}” to confirm",
|
||||
Text(t("car.delete.typeToConfirm", params: {"name": "“${widget.car.name}”"}),
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
@@ -1856,7 +1854,7 @@ class _DeleteCarDialogState extends State<_DeleteCarDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _deleting ? null : () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
child: Text(t("common.cancel")),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFDC2626)),
|
||||
@@ -1866,7 +1864,7 @@ class _DeleteCarDialogState extends State<_DeleteCarDialog> {
|
||||
setState(() => _deleting = true);
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: Text(_deleting ? "Deleting…" : "Delete permanently"),
|
||||
child: Text(t(_deleting ? "car.delete.deleting" : "car.delete.confirm")),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1916,7 +1914,7 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
Future<void> _save() async {
|
||||
final km = int.tryParse(_km.text.trim()) ?? 0;
|
||||
if (_km.text.trim().isEmpty || km < 0) {
|
||||
setState(() => _error = "Odometer is required.");
|
||||
setState(() => _error = t("forms.validation.odometer"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -1940,7 +1938,7 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
try {
|
||||
await applyAttachment("/service-records", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Record saved, but the receipt did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -1964,7 +1962,7 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_isEdit ? "Edit service record" : "Add service record",
|
||||
Text(t(_isEdit ? "forms.service.editTitle" : "forms.service.addTitle"),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
@@ -1994,7 +1992,9 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
child: TextField(
|
||||
controller: _km,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: "Odometer (km) *", border: OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.service.odometer"),
|
||||
border: const OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2003,27 +2003,28 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
CheckboxListTile(
|
||||
value: _oil,
|
||||
onChanged: (v) => setState(() => _oil = v ?? false),
|
||||
title: const Text("Oil & oil filter"),
|
||||
title: Text(t("forms.service.oil")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: _engine,
|
||||
onChanged: (v) => setState(() => _engine = v ?? false),
|
||||
title: const Text("Engine air filter"),
|
||||
title: Text(t("forms.service.engineFilter")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: _cabin,
|
||||
onChanged: (v) => setState(() => _cabin = v ?? false),
|
||||
title: const Text("Cabin air filter"),
|
||||
title: Text(t("forms.service.cabinFilter")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
TextField(
|
||||
controller: _notes,
|
||||
decoration: const InputDecoration(labelText: "Notes", border: OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.service.notes"), border: const OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AttachmentField(
|
||||
@@ -2032,11 +2033,14 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
recordId: widget.record?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Receipt or service-book page",
|
||||
legend: t("forms.service.attachmentLegend"),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Next service (+${widget.car.serviceIntervalDays}d / +${formatKm(widget.car.serviceIntervalKm)}) is computed automatically.",
|
||||
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),
|
||||
@@ -2046,7 +2050,9 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add service")),
|
||||
child: Text(_saving
|
||||
? t("common.saving")
|
||||
: t(_isEdit ? "common.saveChanges" : "forms.service.submit")),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2094,7 +2100,7 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
Future<void> _save() async {
|
||||
final name = _name.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = "Part name is required.");
|
||||
setState(() => _error = t("forms.validation.partName"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -2115,7 +2121,7 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
try {
|
||||
await applyAttachment("/parts", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Part saved, but the photo did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -2139,7 +2145,7 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_isEdit ? "Edit part" : "Add part",
|
||||
Text(t(_isEdit ? "forms.part.editTitle" : "forms.part.addTitle"),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
@@ -2150,21 +2156,26 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
TextField(
|
||||
controller: _name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: "Part name *", border: OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.part.name"),
|
||||
hintText: t("forms.part.namePlaceholder"),
|
||||
border: const OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _partNumber,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Part number", hintText: "04152-YZZA7", border: OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.part.partNumber"),
|
||||
hintText: "04152-YZZA7",
|
||||
border: const OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notes,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Notes",
|
||||
hintText: "Fits 2015–2020 · buy in pairs",
|
||||
border: OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.part.notes"),
|
||||
hintText: t("forms.part.notesPlaceholder"),
|
||||
border: const OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AttachmentField(
|
||||
@@ -2173,7 +2184,7 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
recordId: widget.part?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Photo or spec sheet",
|
||||
legend: t("forms.part.attachmentLegend"),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
@@ -2182,7 +2193,9 @@ class _PartSheetState extends State<_PartSheet> {
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add part")),
|
||||
child: Text(_saving
|
||||
? t("common.saving")
|
||||
: t(_isEdit ? "common.saveChanges" : "forms.part.submit")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user