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:
1 parent
e249c2f4d8
commit
a2d9efec7e
11 files changed
+1380
-426
No files matched your search
@@ -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")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// The fuel types a car can carry, in the order the API's models.go lists them.
|
||||
/// Keys only: the labels live in enums.fuelType.*, which the car's Information
|
||||
/// tab reads as well, so the two cannot disagree.
|
||||
const List<String> kFuelTypes = [
|
||||
"petrol",
|
||||
"petrol_lpg",
|
||||
"diesel",
|
||||
"diesel_lpg",
|
||||
"hybrid",
|
||||
"electric",
|
||||
"hydrogen",
|
||||
];
|
||||
|
||||
/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal.
|
||||
/// Pops with the saved [Car] on success, or null if cancelled.
|
||||
class CarFormSheet extends StatefulWidget {
|
||||
@@ -68,7 +82,7 @@ class _CarFormSheetState extends State<CarFormSheet> {
|
||||
Future<void> _save() async {
|
||||
final name = _c["name"]!.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = "Name is required.");
|
||||
setState(() => _error = t("forms.validation.name"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -180,7 +194,7 @@ class _CarFormSheetState extends State<CarFormSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_isEdit ? "Edit car" : "Add a car",
|
||||
Text(t(_isEdit ? "forms.car.editTitle" : "forms.car.addTitle"),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
@@ -188,75 +202,75 @@ class _CarFormSheetState extends State<CarFormSheet> {
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
_field("name", "Name *", caps: TextCapitalization.words),
|
||||
_field("name", t("forms.car.name"), caps: TextCapitalization.words),
|
||||
Row(children: [
|
||||
Expanded(child: _field("make", "Make")),
|
||||
Expanded(child: _field("make", t("forms.car.make"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("model", "Model")),
|
||||
Expanded(child: _field("model", t("forms.car.model"))),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("year", "Year", keyboard: TextInputType.number)),
|
||||
Expanded(child: _field("year", t("forms.car.year"), keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("registration", "Registration")),
|
||||
Expanded(child: _field("registration", t("forms.car.registration"))),
|
||||
]),
|
||||
_field("registrationCountry", "Registration country", caps: TextCapitalization.words),
|
||||
_field("vin", "VIN"),
|
||||
_field("registrationCountry", t("forms.car.registrationCountry"),
|
||||
caps: TextCapitalization.words),
|
||||
_field("vin", t("forms.car.vin")),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _fuelType.isEmpty ? null : _fuelType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fuel type", border: OutlineInputBorder(), isDense: true),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")),
|
||||
DropdownMenuItem(value: "petrol_lpg", child: Text("Petrol (gasoline) + LPG")),
|
||||
DropdownMenuItem(value: "diesel", child: Text("Diesel")),
|
||||
DropdownMenuItem(value: "diesel_lpg", child: Text("Diesel + LPG")),
|
||||
DropdownMenuItem(value: "hybrid", child: Text("Hybrid")),
|
||||
DropdownMenuItem(value: "electric", child: Text("Electric")),
|
||||
DropdownMenuItem(value: "hydrogen", child: Text("Hydrogen")),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("forms.car.fuelType"),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true),
|
||||
items: [
|
||||
for (final key in kFuelTypes)
|
||||
DropdownMenuItem(value: key, child: Text(t("enums.fuelType.$key"))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _fuelType = v ?? ""),
|
||||
),
|
||||
),
|
||||
Row(children: [
|
||||
Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)),
|
||||
Expanded(
|
||||
child: _dateField(t("forms.car.buildDate"), _buildDate, (v) => _buildDate = v)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dateField("First registration", _firstRegistrationDate,
|
||||
child: _dateField(t("forms.car.firstRegistration"), _firstRegistrationDate,
|
||||
(v) => _firstRegistrationDate = v)),
|
||||
]),
|
||||
_field("oilSpec", "Engine oil spec"),
|
||||
_field("oilSpec", t("forms.car.oilSpec")),
|
||||
Row(children: [
|
||||
Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")),
|
||||
Expanded(child: _field("transmissionOilSpec", t("forms.car.transmissionOilSpec"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("differentialOilSpec", "Differential oil spec")),
|
||||
Expanded(child: _field("differentialOilSpec", t("forms.car.differentialOilSpec"))),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")),
|
||||
Expanded(child: _field("brakeFluidSpec", t("forms.car.brakeFluidSpec"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field("coolantSpec", "Coolant spec")),
|
||||
Expanded(child: _field("coolantSpec", t("forms.car.coolantSpec"))),
|
||||
]),
|
||||
_field("currentKm", "Current odometer (km)", keyboard: TextInputType.number),
|
||||
_field("currentKm", t("forms.car.currentKm"), keyboard: TextInputType.number),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field("serviceIntervalDays", "Service interval (days)",
|
||||
child: _field("serviceIntervalDays", t("forms.car.serviceIntervalDays"),
|
||||
keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field("serviceIntervalKm", "Service interval (km)",
|
||||
child: _field("serviceIntervalKm", t("forms.car.serviceIntervalKm"),
|
||||
keyboard: TextInputType.number)),
|
||||
]),
|
||||
_field("technicalCheckIntervalDays", "Technical check interval (days)",
|
||||
_field("technicalCheckIntervalDays", t("forms.car.technicalCheckIntervalDays"),
|
||||
keyboard: TextInputType.number,
|
||||
helper: "Prefills each check's next-due date. Any check can override it with the"
|
||||
" date printed on its certificate."),
|
||||
helper: t("forms.car.technicalCheckHint")),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")),
|
||||
child: Text(_saving
|
||||
? t("common.saving")
|
||||
: t(_isEdit ? "common.saveChanges" : "forms.car.submit")),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -129,9 +129,14 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
/// The tab rows on offer, in the car's arrangement.
|
||||
List<String> get _tabRows => _tabOrder.where(_isRow).toList();
|
||||
|
||||
String _tabLabel(String key) => key == "provider" && widget.providerLabel.isNotEmpty
|
||||
? widget.providerLabel
|
||||
: t("car.tabs.$key");
|
||||
/// The connected-service row is named after the service once the car is
|
||||
/// linked to one ("MyToyota"); before that there is no name to use, so it
|
||||
/// falls back to the generic label rather than to car.tabs.provider, which is
|
||||
/// deliberately not a key — the web app labels that tab the same way.
|
||||
String _tabLabel(String key) {
|
||||
if (key != "provider") return t("car.tabs.$key");
|
||||
return widget.providerLabel.isEmpty ? t("car.tabs.connected") : widget.providerLabel;
|
||||
}
|
||||
|
||||
/// Applies a drag over the rows to the full arrangement, which holds keys the
|
||||
/// rows do not offer. The movable slots are refilled from the new row
|
||||
|
||||
@@ -67,7 +67,7 @@ class _SheetScaffold extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: saving ? null : onSave,
|
||||
child: Text(saving ? "Saving…" : saveLabel),
|
||||
child: Text(saving ? t("common.saving") : saveLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -235,14 +235,14 @@ class _TechnicalCheckSheetState extends State<TechnicalCheckSheet> {
|
||||
/// effect of leaving it empty is visible before saving rather than after.
|
||||
String? get _derivedHint {
|
||||
if (_result == "failed") {
|
||||
return "A failed check certifies nothing, so no next date is derived from it.";
|
||||
return t("forms.technical.failedHint");
|
||||
}
|
||||
final days = widget.car.technicalCheckIntervalDays > 0
|
||||
? widget.car.technicalCheckIntervalDays
|
||||
: 365;
|
||||
final next = _date.add(Duration(days: days));
|
||||
return "Leave blank to use the car's interval (+${days}d → ${formatDate(next)})."
|
||||
" Enter the date on the certificate when it differs.";
|
||||
return t("forms.technical.derivedHint",
|
||||
params: {"days": days, "date": formatDate(next)});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
@@ -281,37 +281,48 @@ class _TechnicalCheckSheetState extends State<TechnicalCheckSheet> {
|
||||
try {
|
||||
await applyAttachment("/technical-checks", id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Check saved, but the certificate did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SheetScaffold(
|
||||
title: _isEdit ? "Edit technical check" : "Add technical check",
|
||||
title: t(_isEdit ? "forms.technical.editTitle" : "forms.technical.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: _isEdit ? "Save changes" : "Add check",
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.technical.submit"),
|
||||
onSave: _save,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _dateField(context, "Check date *", _date,
|
||||
child: _dateField(context, t("forms.technical.date"), _date,
|
||||
(v) => setState(() => _date = v ?? _date),
|
||||
clearable: false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dropdown("Result *", _result, const [("passed", "Passed"), ("failed", "Failed")],
|
||||
child: _dropdown(
|
||||
t("forms.technical.result"),
|
||||
_result,
|
||||
[
|
||||
("passed", t("forms.technical.passed")),
|
||||
("failed", t("forms.technical.failed")),
|
||||
],
|
||||
(v) => setState(() => _result = v ?? "passed")),
|
||||
),
|
||||
]),
|
||||
_dateField(context, "Valid until", _validUntil, (v) => setState(() => _validUntil = v),
|
||||
_dateField(context, t("forms.technical.validUntil"), _validUntil,
|
||||
(v) => setState(() => _validUntil = v),
|
||||
helper: _derivedHint),
|
||||
Row(children: [
|
||||
Expanded(child: _field(_cost, "Cost", keyboard: const TextInputType.numberWithOptions(decimal: true))),
|
||||
Expanded(
|
||||
child: _field(_cost, t("forms.technical.cost"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field(_station, "Station", hint: "Stacja Kontroli Pojazdów")),
|
||||
Expanded(
|
||||
child: _field(_station, t("forms.technical.station"),
|
||||
hint: t("forms.technical.stationPlaceholder"))),
|
||||
]),
|
||||
AttachmentField(
|
||||
path: "/technical-checks",
|
||||
@@ -319,10 +330,10 @@ class _TechnicalCheckSheetState extends State<TechnicalCheckSheet> {
|
||||
recordId: widget.check?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Inspection certificate",
|
||||
legend: t("forms.technical.attachmentLegend"),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_field(_notes, "Notes"),
|
||||
_field(_notes, t("forms.technical.notes")),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -385,11 +396,11 @@ class _FuelSheetState extends State<FuelSheet> {
|
||||
final km = _int(_km);
|
||||
final liters = _num(_liters);
|
||||
if (_km.text.trim().isEmpty || km < 0) {
|
||||
setState(() => _error = "Odometer is required.");
|
||||
setState(() => _error = t("forms.validation.odometer"));
|
||||
return;
|
||||
}
|
||||
if (liters <= 0) {
|
||||
setState(() => _error = "Litres are required.");
|
||||
setState(() => _error = t("forms.validation.liters"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -415,7 +426,7 @@ class _FuelSheetState extends State<FuelSheet> {
|
||||
try {
|
||||
await applyAttachment("/fuel-entries", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Refill saved, but the receipt did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -430,44 +441,46 @@ class _FuelSheetState extends State<FuelSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final ppl = _pricePerLiter;
|
||||
return _SheetScaffold(
|
||||
title: _isEdit ? "Edit refill" : "Log refill",
|
||||
title: t(_isEdit ? "forms.fuel.editTitle" : "forms.fuel.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: _isEdit ? "Save changes" : "Log refill",
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.fuel.submit"),
|
||||
onSave: _save,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _dateField(context, "Date *", _date, (v) => setState(() => _date = v ?? _date),
|
||||
child: _dateField(context, t("forms.fuel.date"), _date,
|
||||
(v) => setState(() => _date = v ?? _date),
|
||||
clearable: false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field(_km, "Odometer (km) *",
|
||||
child: _field(_km, t("forms.fuel.odometer"),
|
||||
keyboard: TextInputType.number, hint: "16138")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field(_liters, "Litres *",
|
||||
child: _field(_liters, t("forms.fuel.liters"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "42.5")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field(_cost, "Total cost",
|
||||
child: _field(_cost, t("forms.fuel.cost"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "285.00")),
|
||||
]),
|
||||
if (ppl != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text("Price per litre: $ppl", style: Theme.of(context).textTheme.bodySmall),
|
||||
child: Text(t("forms.fuel.pricePerLiter", params: {"price": ppl}),
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
_group(
|
||||
context,
|
||||
"Tank",
|
||||
t("forms.fuel.tank"),
|
||||
[
|
||||
CheckboxListTile(
|
||||
value: _fullTank,
|
||||
onChanged: (v) => setState(() => _fullTank = v ?? false),
|
||||
title: const Text("Filled to full"),
|
||||
title: Text(t("forms.fuel.fullTank")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
dense: true,
|
||||
@@ -475,20 +488,19 @@ class _FuelSheetState extends State<FuelSheet> {
|
||||
CheckboxListTile(
|
||||
value: _missedFill,
|
||||
onChanged: (v) => setState(() => _missedFill = v ?? false),
|
||||
title: const Text("I missed logging a refill before this one"),
|
||||
title: Text(t("forms.fuel.missedFill")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
hint: "Consumption is measured between full tanks, so partial fills count towards the"
|
||||
" next full one. Flagging a missed refill leaves that stretch out of the figures"
|
||||
" instead of reporting it as unrealistically economical.",
|
||||
hint: t("forms.fuel.tankHint"),
|
||||
),
|
||||
Row(children: [
|
||||
Expanded(child: _field(_station, "Station", hint: "Orlen")),
|
||||
Expanded(child: _field(_station, t("forms.fuel.station"),
|
||||
hint: t("forms.fuel.stationPlaceholder"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field(_notes, "Notes")),
|
||||
Expanded(child: _field(_notes, t("forms.fuel.notes"))),
|
||||
]),
|
||||
AttachmentField(
|
||||
path: "/fuel-entries",
|
||||
@@ -496,7 +508,7 @@ class _FuelSheetState extends State<FuelSheet> {
|
||||
recordId: widget.entry?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Receipt",
|
||||
legend: t("forms.fuel.attachmentLegend"),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -562,11 +574,11 @@ class _ChargingSheetState extends State<ChargingSheet> {
|
||||
final km = _int(_km);
|
||||
final kwh = _num(_kwh);
|
||||
if (_km.text.trim().isEmpty || km < 0) {
|
||||
setState(() => _error = t("forms.charging.odometerRequired"));
|
||||
setState(() => _error = t("forms.validation.odometer"));
|
||||
return;
|
||||
}
|
||||
if (kwh <= 0) {
|
||||
setState(() => _error = t("forms.charging.kwhRequired"));
|
||||
setState(() => _error = t("forms.validation.kwh"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -592,7 +604,7 @@ class _ChargingSheetState extends State<ChargingSheet> {
|
||||
try {
|
||||
await applyAttachment("/charging-sessions", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Charge saved, but the receipt did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -610,7 +622,7 @@ class _ChargingSheetState extends State<ChargingSheet> {
|
||||
title: _isEdit ? t("forms.charging.editTitle") : t("forms.charging.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: t("forms.charging.submit"),
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.charging.submit"),
|
||||
onSave: _save,
|
||||
children: [
|
||||
Row(children: [
|
||||
@@ -699,22 +711,17 @@ class MaintenanceSheet extends StatefulWidget {
|
||||
State<MaintenanceSheet> createState() => _MaintenanceSheetState();
|
||||
}
|
||||
|
||||
const _maintenanceTypes = [
|
||||
("repair", "Repair"),
|
||||
("inspection", "Inspection"),
|
||||
("bodywork", "Bodywork"),
|
||||
("tyres", "Tyres"),
|
||||
("diagnostics", "Diagnostics"),
|
||||
("recall", "Recall"),
|
||||
("warranty", "Warranty work"),
|
||||
("other", "Other"),
|
||||
// Keys only, in the order the API's models.go lists them; the labels are
|
||||
// looked up per build so the picker follows the language picker. The same
|
||||
// enums.* entries label these records on the car screen.
|
||||
const _maintenanceTypeKeys = [
|
||||
"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other",
|
||||
];
|
||||
|
||||
const _maintenanceStatuses = [
|
||||
("scheduled", "Scheduled"),
|
||||
("in_progress", "In progress"),
|
||||
("completed", "Completed"),
|
||||
];
|
||||
const _maintenanceStatusKeys = ["scheduled", "in_progress", "completed"];
|
||||
|
||||
List<(String, String)> _options(String namespace, List<String> keys) =>
|
||||
[for (final key in keys) (key, t("enums.$namespace.$key"))];
|
||||
|
||||
class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
late DateTime _date;
|
||||
@@ -777,7 +784,7 @@ class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
Future<void> _save() async {
|
||||
final description = _description.text.trim();
|
||||
if (description.isEmpty) {
|
||||
setState(() => _error = "Describe what was done.");
|
||||
setState(() => _error = t("forms.validation.description"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -808,7 +815,7 @@ class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
try {
|
||||
await applyAttachment("/maintenance", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Visit saved, but the invoice did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -823,57 +830,68 @@ class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final total = _num(_laborCost) + _num(_partsCost);
|
||||
return _SheetScaffold(
|
||||
title: _isEdit ? "Edit workshop visit" : "Log workshop visit",
|
||||
title: t(_isEdit ? "forms.maintenance.editTitle" : "forms.maintenance.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: _isEdit ? "Save changes" : "Log visit",
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.maintenance.submit"),
|
||||
onSave: _save,
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _dateField(context, "Date *", _date, (v) => setState(() => _date = v ?? _date),
|
||||
child: _dateField(context, t("forms.maintenance.date"), _date,
|
||||
(v) => setState(() => _date = v ?? _date),
|
||||
clearable: false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field(_km, "Odometer (km)", keyboard: TextInputType.number, hint: "16138")),
|
||||
child: _field(_km, t("forms.maintenance.odometer"),
|
||||
keyboard: TextInputType.number, hint: "16138")),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _dropdown("Type", _type, _maintenanceTypes,
|
||||
child: _dropdown(t("forms.maintenance.type"), _type,
|
||||
_options("maintenanceType", _maintenanceTypeKeys),
|
||||
(v) => setState(() => _type = v ?? "repair"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dropdown("Status", _status, _maintenanceStatuses,
|
||||
child: _dropdown(t("forms.maintenance.status"), _status,
|
||||
_options("maintenanceStatus", _maintenanceStatusKeys),
|
||||
(v) => setState(() => _status = v ?? "completed"))),
|
||||
]),
|
||||
_field(_description, "What was done *", hint: "Replaced alternator and drive belt"),
|
||||
Row(children: [
|
||||
Expanded(child: _field(_workshop, "Workshop", hint: "Auto Serwis Kowalski")),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field(_location, "Location", hint: "Kraków")),
|
||||
]),
|
||||
_field(_partsUsed, "Parts replaced", hint: "Alternator 27060-0T010, belt 90916-02660"),
|
||||
_field(_description, t("forms.maintenance.description"),
|
||||
hint: t("forms.maintenance.descriptionPlaceholder")),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field(_laborCost, "Labour cost",
|
||||
child: _field(_workshop, t("forms.maintenance.workshop"),
|
||||
hint: t("forms.maintenance.workshopPlaceholder"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field(_location, t("forms.maintenance.location"),
|
||||
hint: t("forms.maintenance.locationPlaceholder"))),
|
||||
]),
|
||||
_field(_partsUsed, t("forms.maintenance.partsUsed"),
|
||||
hint: t("forms.maintenance.partsUsedPlaceholder")),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _field(_laborCost, t("forms.maintenance.laborCost"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _field(_partsCost, "Parts cost",
|
||||
child: _field(_partsCost, t("forms.maintenance.partsCost"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true))),
|
||||
]),
|
||||
if (total > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child:
|
||||
Text("Total: ${formatMoney(total)}", style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(t("forms.maintenance.total", params: {"total": formatMoney(total)}),
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
Row(children: [
|
||||
Expanded(child: _field(_invoiceNumber, "Invoice number")),
|
||||
Expanded(child: _field(_invoiceNumber, t("forms.maintenance.invoiceNumber"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dateField(context, "Warranty until", _warrantyUntil,
|
||||
child: _dateField(context, t("forms.maintenance.warrantyUntil"), _warrantyUntil,
|
||||
(v) => setState(() => _warrantyUntil = v)),
|
||||
),
|
||||
]),
|
||||
@@ -883,10 +901,10 @@ class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
recordId: widget.entry?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Invoice",
|
||||
legend: t("forms.maintenance.attachmentLegend"),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_field(_notes, "Notes"),
|
||||
_field(_notes, t("forms.maintenance.notes")),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -894,14 +912,8 @@ class _MaintenanceSheetState extends State<MaintenanceSheet> {
|
||||
|
||||
// --- documents ---
|
||||
|
||||
const _documentTypes = [
|
||||
("insurance", "Insurance"),
|
||||
("pollution", "Pollution certificate"),
|
||||
("registration", "Registration"),
|
||||
("inspection", "Inspection"),
|
||||
("roadTax", "Road tax"),
|
||||
("warranty", "Warranty"),
|
||||
("other", "Other"),
|
||||
const _documentTypeKeys = [
|
||||
"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other",
|
||||
];
|
||||
|
||||
/// Mirrors the web DocumentFormModal.
|
||||
@@ -949,7 +961,7 @@ class _DocumentSheetState extends State<DocumentSheet> {
|
||||
Future<void> _save() async {
|
||||
final title = _title.text.trim();
|
||||
if (title.isEmpty) {
|
||||
setState(() => _error = "Title is required.");
|
||||
setState(() => _error = t("forms.validation.title"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -975,7 +987,7 @@ class _DocumentSheetState extends State<DocumentSheet> {
|
||||
try {
|
||||
await applyAttachment("/car-documents", saved.id, _pending);
|
||||
} catch (e) {
|
||||
throw ApiException(0, "Document saved, but the scan did not upload: $e");
|
||||
throw ApiException(0, t("errors.attachmentFailed", params: {"error": e}));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
@@ -989,47 +1001,50 @@ class _DocumentSheetState extends State<DocumentSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SheetScaffold(
|
||||
title: _isEdit ? "Edit document" : "Add document",
|
||||
title: t(_isEdit ? "forms.document.editTitle" : "forms.document.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: _isEdit ? "Save changes" : "Add document",
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.document.submit"),
|
||||
onSave: _save,
|
||||
children: [
|
||||
_dropdown("Type", _type, _documentTypes, (v) => setState(() => _type = v ?? "insurance")),
|
||||
_field(_title, "Title *", hint: "Third-party liability 2026"),
|
||||
_dropdown(t("forms.document.type"), _type,
|
||||
_options("documentType", _documentTypeKeys),
|
||||
(v) => setState(() => _type = v ?? "insurance")),
|
||||
_field(_title, t("forms.document.title"),
|
||||
hint: t("forms.document.titlePlaceholder")),
|
||||
Row(children: [
|
||||
Expanded(child: _field(_provider, "Provider", hint: "PZU")),
|
||||
Expanded(
|
||||
child: _field(_provider, t("forms.document.provider"),
|
||||
hint: t("forms.document.providerPlaceholder"))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _field(_reference, "Policy / certificate no.")),
|
||||
Expanded(child: _field(_reference, t("forms.document.reference"))),
|
||||
]),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _dateField(
|
||||
context, "Issued", _issueDate, (v) => setState(() => _issueDate = v))),
|
||||
child: _dateField(context, t("forms.document.issued"), _issueDate,
|
||||
(v) => setState(() => _issueDate = v))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _dateField(
|
||||
context, "Renewal date", _expiryDate, (v) => setState(() => _expiryDate = v))),
|
||||
child: _dateField(context, t("forms.document.renewalDate"), _expiryDate,
|
||||
(v) => setState(() => _expiryDate = v))),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Leave the renewal date blank for a document that never expires. Setting it adds a"
|
||||
" reminder automatically.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
child: Text(t("forms.document.renewalHint"),
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
_field(_cost, "Cost", keyboard: const TextInputType.numberWithOptions(decimal: true)),
|
||||
_field(_cost, t("forms.document.cost"),
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true)),
|
||||
AttachmentField(
|
||||
path: "/car-documents",
|
||||
record: widget.doc,
|
||||
recordId: widget.doc?.id,
|
||||
pending: _pending,
|
||||
onChanged: () => setState(() {}),
|
||||
legend: "Scan or photo",
|
||||
legend: t("forms.document.attachmentLegend"),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_field(_notes, "Notes"),
|
||||
_field(_notes, t("forms.document.notes")),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1037,13 +1052,7 @@ class _DocumentSheetState extends State<DocumentSheet> {
|
||||
|
||||
// --- reminders ---
|
||||
|
||||
const _reminderTypes = [
|
||||
("maintenance", "Maintenance"),
|
||||
("document", "Document renewal"),
|
||||
("service", "Service"),
|
||||
("inspection", "Inspection"),
|
||||
("other", "Other"),
|
||||
];
|
||||
const _reminderTypeKeys = ["maintenance", "document", "service", "inspection", "other"];
|
||||
|
||||
/// Mirrors the web ReminderFormModal. Reminders carry no attachment.
|
||||
class ReminderSheet extends StatefulWidget {
|
||||
@@ -1093,11 +1102,11 @@ class _ReminderSheetState extends State<ReminderSheet> {
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_title.text.trim().isEmpty) {
|
||||
setState(() => _error = "Title is required.");
|
||||
setState(() => _error = t("forms.validation.title"));
|
||||
return;
|
||||
}
|
||||
if (!_hasTrigger) {
|
||||
setState(() => _error = "Set a due date, a due odometer reading, or both.");
|
||||
setState(() => _error = t("forms.reminder.noTrigger"));
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -1132,37 +1141,41 @@ class _ReminderSheetState extends State<ReminderSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final atKm = " The car is at ${formatKm(widget.car.currentKm)} now.";
|
||||
final atKm = t("forms.reminder.currentKm", params: {"km": formatKm(widget.car.currentKm)});
|
||||
return _SheetScaffold(
|
||||
title: _isEdit ? "Edit reminder" : "Add reminder",
|
||||
title: t(_isEdit ? "forms.reminder.editTitle" : "forms.reminder.addTitle"),
|
||||
error: _error,
|
||||
saving: _saving,
|
||||
saveLabel: _isEdit ? "Save changes" : "Add reminder",
|
||||
saveLabel: t(_isEdit ? "common.saveChanges" : "forms.reminder.submit"),
|
||||
onSave: _hasTrigger ? _save : null,
|
||||
children: [
|
||||
_field(_title, "Title *", hint: "Swap to winter tyres"),
|
||||
_dropdown("Type", _type, _reminderTypes, (v) => setState(() => _type = v ?? "maintenance")),
|
||||
_field(_title, t("forms.reminder.title"), hint: t("forms.reminder.titlePlaceholder")),
|
||||
_dropdown(t("forms.reminder.type"), _type,
|
||||
_options("reminderType", _reminderTypeKeys),
|
||||
(v) => setState(() => _type = v ?? "maintenance")),
|
||||
_group(
|
||||
context,
|
||||
"Remind me",
|
||||
t("forms.reminder.remindMe"),
|
||||
[
|
||||
_dateField(context, "On date", _dueDate, (v) => setState(() => _dueDate = v)),
|
||||
_field(_dueKm, "At odometer (km)", keyboard: TextInputType.number, hint: "30000"),
|
||||
_dateField(context, t("forms.reminder.onDate"), _dueDate,
|
||||
(v) => setState(() => _dueDate = v)),
|
||||
_field(_dueKm, t("forms.reminder.atOdometer"),
|
||||
keyboard: TextInputType.number, hint: "30000"),
|
||||
],
|
||||
hint: "Set either or both — with both, whichever comes first wins.$atKm",
|
||||
hint: "${t("forms.reminder.triggerHint")} $atKm",
|
||||
),
|
||||
_group(
|
||||
context,
|
||||
"Repeat (optional)",
|
||||
t("forms.reminder.repeat"),
|
||||
[
|
||||
_field(_repeatDays, "Every … days", keyboard: TextInputType.number, hint: "365"),
|
||||
_field(_repeatKm, "Every … km", keyboard: TextInputType.number, hint: "15000"),
|
||||
_field(_repeatDays, t("forms.reminder.everyDays"),
|
||||
keyboard: TextInputType.number, hint: "365"),
|
||||
_field(_repeatKm, t("forms.reminder.everyKm"),
|
||||
keyboard: TextInputType.number, hint: "15000"),
|
||||
],
|
||||
hint: _isRecurring
|
||||
? "Marking this done will roll it forward instead of closing it."
|
||||
: "Leave blank for a one-off reminder that closes when you mark it done.",
|
||||
hint: t(_isRecurring ? "forms.reminder.recurringHint" : "forms.reminder.oneOffHint"),
|
||||
),
|
||||
_field(_notes, "Notes"),
|
||||
_field(_notes, t("forms.reminder.notes")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import "package:open_filex/open_filex.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
||||
import "../api.dart";
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../theme.dart";
|
||||
@@ -36,7 +37,7 @@ Future<void> applyAttachment(String path, String id, PendingAttachment pending)
|
||||
if (picked != null) {
|
||||
final bytes = picked.bytes ??
|
||||
(picked.path != null ? await File(picked.path!).readAsBytes() : null);
|
||||
if (bytes == null) throw ApiException(0, "could not read the picked file");
|
||||
if (bytes == null) throw ApiException(0, t("errors.noFile"));
|
||||
await apiClient.uploadAttachment(path, id, bytes, picked.name);
|
||||
return;
|
||||
}
|
||||
@@ -56,7 +57,7 @@ Future<void> openAttachment(
|
||||
try {
|
||||
final bytes = await apiClient.getAttachmentBytes(path, id);
|
||||
if (bytes == null) {
|
||||
messenger.showSnackBar(const SnackBar(content: Text("No file attached.")));
|
||||
messenger.showSnackBar(SnackBar(content: Text(t("errors.noFile"))));
|
||||
return;
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
@@ -65,10 +66,12 @@ Future<void> openAttachment(
|
||||
await f.writeAsBytes(bytes);
|
||||
final res = await OpenFilex.open(f.path);
|
||||
if (res.type != ResultType.done) {
|
||||
messenger.showSnackBar(SnackBar(content: Text("Could not open: ${res.message}")));
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(t("errors.openFailed", params: {"error": res.message}))));
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text("Could not open attachment: $e")));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(t("errors.openFailed", params: {"error": e}))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +94,9 @@ class AttachmentField extends StatefulWidget {
|
||||
|
||||
final PendingAttachment pending;
|
||||
final VoidCallback onChanged;
|
||||
/// Blank means the shared wording (attachment.legend / attachment.hint);
|
||||
/// callers with a more specific one — "Receipt", "Photo or spec sheet" —
|
||||
/// pass it translated.
|
||||
final String legend;
|
||||
final String hint;
|
||||
|
||||
@@ -101,8 +107,8 @@ class AttachmentField extends StatefulWidget {
|
||||
required this.onChanged,
|
||||
this.record,
|
||||
this.recordId,
|
||||
this.legend = "Attachment",
|
||||
this.hint = "PDF or image, up to 10MB.",
|
||||
this.legend = "",
|
||||
this.hint = "",
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -148,9 +154,9 @@ class _AttachmentFieldState extends State<AttachmentField> {
|
||||
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.legend,
|
||||
labelText: widget.legend.isEmpty ? t("attachment.legend") : widget.legend,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: widget.hint,
|
||||
helperText: widget.hint.isEmpty ? t("attachment.hint") : widget.hint,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -161,7 +167,8 @@ class _AttachmentFieldState extends State<AttachmentField> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pick,
|
||||
icon: const Icon(Icons.attach_file, size: 18),
|
||||
label: Text(hasExisting || picked != null ? "Replace" : "Choose file"),
|
||||
label: Text(
|
||||
t(hasExisting || picked != null ? "attachment.replace" : "attachment.choose")),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (picked != null)
|
||||
@@ -176,7 +183,7 @@ class _AttachmentFieldState extends State<AttachmentField> {
|
||||
IconButton(
|
||||
onPressed: _clearPick,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
tooltip: "Clear",
|
||||
tooltip: t("attachment.clear"),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -187,15 +194,16 @@ class _AttachmentFieldState extends State<AttachmentField> {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text("Attached: ${existing!.fileName}", style: theme.textTheme.bodySmall),
|
||||
Text(t("attachment.attached", params: {"name": existing!.fileName}),
|
||||
style: theme.textTheme.bodySmall),
|
||||
if (widget.recordId != null)
|
||||
_LinkButton(
|
||||
label: "View",
|
||||
label: t("attachment.view"),
|
||||
onPressed: () => openAttachment(
|
||||
context, widget.path, widget.recordId!, existing.fileName),
|
||||
),
|
||||
_LinkButton(
|
||||
label: "Remove",
|
||||
label: t("common.remove"),
|
||||
color: DriverVault.danger,
|
||||
onPressed: () => _setRemove(true),
|
||||
),
|
||||
@@ -209,9 +217,9 @@ class _AttachmentFieldState extends State<AttachmentField> {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text("Attachment will be removed on save.",
|
||||
Text(t("attachment.willBeRemoved"),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted)),
|
||||
_LinkButton(label: "Undo", onPressed: () => _setRemove(false)),
|
||||
_LinkButton(label: t("common.undo"), onPressed: () => _setRemove(false)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user