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
@@ -115,6 +115,11 @@ tag the phone cannot render can still arrive from the web. `format.dart` resolve
|
||||
through a supported-language check and falls back to `en-US` instead of throwing;
|
||||
`test/models_format_test.dart` covers it.
|
||||
|
||||
The car screen and every sheet it opens read their text from the language files.
|
||||
What is left in English is the **admin users** screen, and the Integrations and
|
||||
charger-control strings — the latter being a gap the web app shares, so it
|
||||
belongs to both apps at once. See [TRANSLATIONS.md](../TRANSLATIONS.md).
|
||||
|
||||
## Biometric / face sign-in & app lock
|
||||
|
||||
Fingerprint and face-recognition sign-in via `local_auth`, with credentials kept
|
||||
|
||||
+274
-12
@@ -366,7 +366,11 @@
|
||||
"fuelType": "Brændstoftype",
|
||||
"buildDate": "Produktionsdato",
|
||||
"firstRegistration": "Første registrering",
|
||||
"technicalCheckInterval": "Synsinterval"
|
||||
"technicalCheckInterval": "Synsinterval",
|
||||
"daysValue": {
|
||||
"one": "{n} dag",
|
||||
"other": "{n} dage"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicehistorik",
|
||||
@@ -381,7 +385,11 @@
|
||||
"colCabinFilter": "Kabinefilter",
|
||||
"colNotes": "Noter",
|
||||
"colFile": "Fil",
|
||||
"confirmDelete": "Slet denne servicepost?"
|
||||
"confirmDelete": "Slet denne servicepost?",
|
||||
"next": "Næste: {date} · {km}",
|
||||
"chipOil": "Olie og filter",
|
||||
"chipEngineFilter": "Motorluft",
|
||||
"chipCabinFilter": "Kabineluft"
|
||||
},
|
||||
"technical": {
|
||||
"title": "Synshistorik",
|
||||
@@ -398,7 +406,8 @@
|
||||
"colFile": "Fil",
|
||||
"passed": "Godkendt",
|
||||
"failed": "Ikke godkendt",
|
||||
"confirmDelete": "Slet dette syn?"
|
||||
"confirmDelete": "Slet dette syn?",
|
||||
"next": "Næste: {date}"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Værksted",
|
||||
@@ -414,7 +423,8 @@
|
||||
"colCost": "Pris",
|
||||
"colFile": "Fil",
|
||||
"underWarranty": "Under garanti · {days} dage tilbage",
|
||||
"confirmDelete": "Slet dette værkstedsbesøg?"
|
||||
"confirmDelete": "Slet dette værkstedsbesøg?",
|
||||
"partsUsed": "Dele: {parts}"
|
||||
},
|
||||
"fuel": {
|
||||
"title": "Brændstofudgifter",
|
||||
@@ -442,7 +452,11 @@
|
||||
"colFile": "Fil",
|
||||
"partial": "delvis",
|
||||
"gap": "hul",
|
||||
"confirmDelete": "Slet denne tankning?"
|
||||
"confirmDelete": "Slet denne tankning?",
|
||||
"fullTank": "Fuld tank",
|
||||
"partialFill": "Delvis tankning",
|
||||
"missedBefore": "Manglende tankning før",
|
||||
"overDistance": "{consumption} · {rate} over {distance}"
|
||||
},
|
||||
"charging": {
|
||||
"title": "Opladningsudgifter",
|
||||
@@ -470,7 +484,11 @@
|
||||
"colFile": "Fil",
|
||||
"partial": "delvis",
|
||||
"gap": "hul",
|
||||
"confirmDelete": "Slet denne opladning?"
|
||||
"confirmDelete": "Slet denne opladning?",
|
||||
"fullCharge": "Fuld opladning",
|
||||
"partialCharge": "Delvis opladning",
|
||||
"missedBefore": "Manglende opladning før",
|
||||
"overDistance": "{consumption} · {rate} over {distance}"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumenter",
|
||||
@@ -484,7 +502,8 @@
|
||||
"colRenewal": "Fornyelse",
|
||||
"colStatus": "Status",
|
||||
"colFile": "Fil",
|
||||
"confirmDelete": "Slet dette dokument?"
|
||||
"confirmDelete": "Slet dette dokument?",
|
||||
"issuedRenews": "Udstedt {issued} · Fornyes {renews}"
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Påmindelser",
|
||||
@@ -497,7 +516,10 @@
|
||||
"doneRollForward": "Færdig · flyt frem",
|
||||
"markDone": "Markér som færdig",
|
||||
"reopen": "Genåbn",
|
||||
"confirmDelete": "Slet denne påmindelse?"
|
||||
"confirmDelete": "Slet denne påmindelse?",
|
||||
"on": "den {date}",
|
||||
"repeatsEvery": "Gentages hver {every}",
|
||||
"autoHint": "Tilføjet automatisk — rediger den post, den stammer fra, for at ændre den."
|
||||
},
|
||||
"parts": {
|
||||
"title": "Reservedelskatalog",
|
||||
@@ -539,7 +561,14 @@
|
||||
"typeToConfirm": "Skriv {name} for at bekræfte",
|
||||
"deleting": "Sletter…",
|
||||
"confirm": "Slet permanent"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"share": "Del bil",
|
||||
"edit": "Rediger bil",
|
||||
"odometer": "Opdater kilometerstand",
|
||||
"delete": "Slet bil"
|
||||
},
|
||||
"readOnlyNotice": "Delt med dig (skrivebeskyttet). Du kan ikke ændre noget."
|
||||
},
|
||||
"forms": {
|
||||
"charging": {
|
||||
@@ -558,9 +587,7 @@
|
||||
"locationPlaceholder": "Hjemme",
|
||||
"notes": "Noter",
|
||||
"attachmentLegend": "Kvittering",
|
||||
"submit": "Gem opladning",
|
||||
"odometerRequired": "Kilometerstand er påkrævet.",
|
||||
"kwhRequired": "Energi (kWh) er påkrævet."
|
||||
"submit": "Gem opladning"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importér en bil",
|
||||
@@ -583,6 +610,241 @@
|
||||
"importing": "Importerer…",
|
||||
"warningOdometer": "Tjenesten oplyste ingen kilometerstand — indtast den selv på bilen.",
|
||||
"moreData": "Resten af det, tjenesten oplyser, er fortsat tilgængeligt på bilens {label}-fane."
|
||||
},
|
||||
"car": {
|
||||
"addTitle": "Tilføj en bil",
|
||||
"editTitle": "Rediger bil",
|
||||
"name": "Navn *",
|
||||
"make": "Mærke",
|
||||
"model": "Model",
|
||||
"year": "Årgang",
|
||||
"registration": "Nummerplade",
|
||||
"registrationCountry": "Registreringsland",
|
||||
"registrationCountryPlaceholder": "Danmark",
|
||||
"vin": "Stelnummer",
|
||||
"vinPlaceholder": "Køretøjets stelnummer",
|
||||
"fuelType": "Brændstoftype",
|
||||
"buildDate": "Produktionsdato",
|
||||
"firstRegistration": "Første registrering",
|
||||
"oilSpec": "Motorolie-specifikation",
|
||||
"currentKm": "Nuværende kilometerstand (km)",
|
||||
"transmissionOilSpec": "Gearolie-specifikation",
|
||||
"differentialOilSpec": "Differentialeolie-specifikation",
|
||||
"brakeFluidSpec": "Bremsevæske-specifikation",
|
||||
"coolantSpec": "Kølervæske-specifikation",
|
||||
"serviceIntervalDays": "Serviceinterval (dage)",
|
||||
"serviceIntervalKm": "Serviceinterval (km)",
|
||||
"technicalCheckIntervalDays": "Synsinterval (dage)",
|
||||
"technicalCheckHint": "Udfylder på forhånd hvert syns næste forfaldsdato. Ethvert syn kan tilsidesætte den med datoen på attesten.",
|
||||
"submit": "Tilføj bil"
|
||||
},
|
||||
"service": {
|
||||
"addTitle": "Tilføj servicepost",
|
||||
"editTitle": "Rediger servicepost",
|
||||
"date": "Dato *",
|
||||
"odometer": "Kilometerstand (km)",
|
||||
"changedParts": "Udskiftede dele",
|
||||
"oil": "Olie og oliefilter",
|
||||
"engineFilter": "Luftfilter",
|
||||
"cabinFilter": "Kabinefilter",
|
||||
"attachmentLegend": "Kvittering eller side fra servicebogen",
|
||||
"notes": "Noter",
|
||||
"autoHint": "Næste servicedato (+{days} dage) og km (+{km}) beregnes automatisk.",
|
||||
"submit": "Tilføj service"
|
||||
},
|
||||
"technical": {
|
||||
"addTitle": "Tilføj syn",
|
||||
"editTitle": "Rediger syn",
|
||||
"date": "Synsdato *",
|
||||
"result": "Resultat *",
|
||||
"passed": "Godkendt",
|
||||
"failed": "Ikke godkendt",
|
||||
"validUntil": "Gyldig til",
|
||||
"failedHint": "Et ikke-godkendt syn attesterer ingenting, så der udledes ingen næste dato af det.",
|
||||
"derivedHint": "Lad feltet stå tomt for at bruge bilens interval (+{days} dage → {date}). Indtast datoen på attesten, hvis den afviger.",
|
||||
"cost": "Pris",
|
||||
"station": "Synssted",
|
||||
"stationPlaceholder": "Synshal",
|
||||
"attachmentLegend": "Synsattest",
|
||||
"notes": "Noter",
|
||||
"submit": "Tilføj syn"
|
||||
},
|
||||
"part": {
|
||||
"addTitle": "Tilføj reservedel",
|
||||
"editTitle": "Rediger reservedel",
|
||||
"name": "Reservedelens navn *",
|
||||
"namePlaceholder": "Oliefilter",
|
||||
"partNumber": "Varenummer",
|
||||
"notes": "Noter",
|
||||
"notesPlaceholder": "Passer til 2015–2020 · køb parvis",
|
||||
"attachmentLegend": "Billede eller datablad",
|
||||
"submit": "Tilføj reservedel"
|
||||
},
|
||||
"fuel": {
|
||||
"addTitle": "Registrér tankning",
|
||||
"editTitle": "Rediger tankning",
|
||||
"date": "Dato *",
|
||||
"odometer": "Kilometerstand (km) *",
|
||||
"liters": "Liter *",
|
||||
"cost": "Samlet pris",
|
||||
"pricePerLiter": "Pris pr. liter: {price}",
|
||||
"tank": "Tank",
|
||||
"fullTank": "Fyldt helt op",
|
||||
"missedFill": "Jeg glemte at registrere en tankning før denne",
|
||||
"tankHint": "Forbruget måles mellem fulde tanke, så delvise tankninger tæller med i den næste fulde. At markere en glemt tankning holder den strækning ude af tallene i stedet for at vise et urealistisk lavt forbrug.",
|
||||
"station": "Tankstation",
|
||||
"notes": "Noter",
|
||||
"attachmentLegend": "Kvittering",
|
||||
"submit": "Registrér tankning",
|
||||
"stationPlaceholder": "Orlen"
|
||||
},
|
||||
"maintenance": {
|
||||
"addTitle": "Registrér værkstedsbesøg",
|
||||
"editTitle": "Rediger værkstedsbesøg",
|
||||
"date": "Dato *",
|
||||
"odometer": "Kilometerstand (km)",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"description": "Hvad blev der lavet *",
|
||||
"descriptionPlaceholder": "Udskiftede generator og drivrem",
|
||||
"workshop": "Værksted",
|
||||
"location": "Sted",
|
||||
"partsUsed": "Udskiftede dele",
|
||||
"partsUsedPlaceholder": "Generator 27060-0T010, rem 90916-02660",
|
||||
"laborCost": "Arbejdsløn",
|
||||
"partsCost": "Pris for dele",
|
||||
"total": "I alt: {total}",
|
||||
"invoiceNumber": "Fakturanummer",
|
||||
"warrantyUntil": "Garanti til",
|
||||
"attachmentLegend": "Faktura",
|
||||
"notes": "Noter",
|
||||
"submit": "Registrér besøg",
|
||||
"workshopPlaceholder": "Kowalski Auto Service",
|
||||
"locationPlaceholder": "Kraków"
|
||||
},
|
||||
"document": {
|
||||
"addTitle": "Tilføj dokument",
|
||||
"editTitle": "Rediger dokument",
|
||||
"type": "Type",
|
||||
"title": "Titel *",
|
||||
"titlePlaceholder": "Ansvarsforsikring 2026",
|
||||
"provider": "Udbyder",
|
||||
"reference": "Police- / attestnummer",
|
||||
"issued": "Udstedt",
|
||||
"renewalDate": "Fornyelsesdato",
|
||||
"renewalHint": "Lad fornyelsesdatoen stå tom for et dokument, der aldrig udløber. Angives den, tilføjes der automatisk en påmindelse.",
|
||||
"cost": "Pris",
|
||||
"attachmentLegend": "Scan eller billede",
|
||||
"notes": "Noter",
|
||||
"submit": "Tilføj dokument",
|
||||
"providerPlaceholder": "PZU"
|
||||
},
|
||||
"reminder": {
|
||||
"addTitle": "Tilføj påmindelse",
|
||||
"editTitle": "Rediger påmindelse",
|
||||
"title": "Titel *",
|
||||
"titlePlaceholder": "Skift til vinterdæk",
|
||||
"type": "Type",
|
||||
"remindMe": "Mind mig om",
|
||||
"onDate": "På dato",
|
||||
"atOdometer": "Ved kilometerstand (km)",
|
||||
"triggerHint": "Angiv det ene eller begge — med begge gælder det, der indtræffer først.",
|
||||
"currentKm": "Bilen står på {km} nu.",
|
||||
"repeat": "Gentagelse (valgfrit)",
|
||||
"everyDays": "Hver … dage",
|
||||
"everyKm": "Hver … km",
|
||||
"recurringHint": "Markeres den som færdig, flyttes den frem i stedet for at blive lukket.",
|
||||
"oneOffHint": "Lad feltet stå tomt for en engangspåmindelse, der lukkes, når du markerer den som færdig.",
|
||||
"notes": "Noter",
|
||||
"noTrigger": "Angiv en forfaldsdato, en kilometerstand eller begge.",
|
||||
"submit": "Tilføj påmindelse"
|
||||
},
|
||||
"share": {
|
||||
"title": "Del {name}",
|
||||
"body": "Giv en anden bruger adgang til denne bil. Skrivebeskyttet giver adgang til at se; læs og skriv giver også adgang til at redigere bilen samt dens serviceposter og reservedele.",
|
||||
"userEmail": "Brugerens e-mail",
|
||||
"read": "Skrivebeskyttet",
|
||||
"write": "Læs og skriv",
|
||||
"submit": "Del",
|
||||
"peopleWithAccess": "Personer med adgang",
|
||||
"notShared": "Endnu ikke delt med nogen.",
|
||||
"submitting": "Deler…"
|
||||
},
|
||||
"validation": {
|
||||
"odometer": "Kilometerstand er påkrævet.",
|
||||
"liters": "Antal liter er påkrævet.",
|
||||
"kwh": "Energi (kWh) er påkrævet.",
|
||||
"name": "Navn er påkrævet.",
|
||||
"partName": "Delnavn er påkrævet.",
|
||||
"description": "Beskriv, hvad der blev udført.",
|
||||
"title": "Titel er påkrævet."
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"fuelType": {
|
||||
"petrol": "Benzin",
|
||||
"petrol_lpg": "Benzin + LPG",
|
||||
"diesel": "Diesel",
|
||||
"diesel_lpg": "Diesel + LPG",
|
||||
"hybrid": "Hybrid",
|
||||
"electric": "El",
|
||||
"hydrogen": "Brint"
|
||||
},
|
||||
"maintenanceType": {
|
||||
"repair": "Reparation",
|
||||
"inspection": "Eftersyn",
|
||||
"bodywork": "Karrosseri",
|
||||
"tyres": "Dæk",
|
||||
"diagnostics": "Fejlsøgning",
|
||||
"recall": "Tilbagekaldelse",
|
||||
"warranty": "Garantiarbejde",
|
||||
"other": "Andet"
|
||||
},
|
||||
"maintenanceStatus": {
|
||||
"scheduled": "Planlagt",
|
||||
"in_progress": "I gang",
|
||||
"completed": "Fuldført"
|
||||
},
|
||||
"documentType": {
|
||||
"insurance": "Forsikring",
|
||||
"pollution": "Miljøattest",
|
||||
"registration": "Registreringsattest",
|
||||
"inspection": "Eftersyn",
|
||||
"roadTax": "Vægtafgift",
|
||||
"warranty": "Garanti",
|
||||
"other": "Andet"
|
||||
},
|
||||
"reminderTypeShort": {
|
||||
"maintenance": "Vedligehold",
|
||||
"document": "Dokument",
|
||||
"service": "Service",
|
||||
"inspection": "Eftersyn",
|
||||
"other": "Andet"
|
||||
},
|
||||
"reminderType": {
|
||||
"maintenance": "Vedligehold",
|
||||
"document": "Fornyelse af dokument",
|
||||
"service": "Service",
|
||||
"inspection": "Eftersyn",
|
||||
"other": "Andet"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"legend": "Vedhæftet fil",
|
||||
"hint": "PDF eller billede, op til 10 MB.",
|
||||
"attached": "Vedhæftet: {name}",
|
||||
"willBeRemoved": "Den vedhæftede fil fjernes, når der gemmes.",
|
||||
"choose": "Vælg fil",
|
||||
"replace": "Erstat",
|
||||
"view": "Vis",
|
||||
"clear": "Ryd"
|
||||
},
|
||||
"errors": {
|
||||
"sessionExpired": "Sessionen er udløbet — log ind igen.",
|
||||
"deleteFailed": "Sletning mislykkedes: {error}",
|
||||
"completeFailed": "Kunne ikke fuldføres: {error}",
|
||||
"attachmentFailed": "Gemt, men filen blev ikke uploadet: {error}",
|
||||
"openFailed": "Filen kunne ikke åbnes: {error}",
|
||||
"noFile": "Ingen fil vedhæftet."
|
||||
}
|
||||
}
|
||||
|
||||
+274
-12
@@ -440,7 +440,11 @@
|
||||
"fuelType": "Fuel type",
|
||||
"buildDate": "Build date",
|
||||
"firstRegistration": "First registration",
|
||||
"technicalCheckInterval": "Technical check interval"
|
||||
"technicalCheckInterval": "Technical check interval",
|
||||
"daysValue": {
|
||||
"one": "{n} day",
|
||||
"other": "{n} days"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Service history",
|
||||
@@ -455,7 +459,11 @@
|
||||
"colCabinFilter": "Cabin air filter",
|
||||
"colNotes": "Notes",
|
||||
"colFile": "File",
|
||||
"confirmDelete": "Delete this service record?"
|
||||
"confirmDelete": "Delete this service record?",
|
||||
"next": "Next: {date} · {km}",
|
||||
"chipOil": "Oil & filter",
|
||||
"chipEngineFilter": "Engine air",
|
||||
"chipCabinFilter": "Cabin air"
|
||||
},
|
||||
"technical": {
|
||||
"title": "Technical check history",
|
||||
@@ -472,7 +480,8 @@
|
||||
"colFile": "File",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed",
|
||||
"confirmDelete": "Delete this technical check?"
|
||||
"confirmDelete": "Delete this technical check?",
|
||||
"next": "Next: {date}"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance",
|
||||
@@ -488,7 +497,8 @@
|
||||
"colCost": "Cost",
|
||||
"colFile": "File",
|
||||
"underWarranty": "Under warranty · {days}d left",
|
||||
"confirmDelete": "Delete this workshop visit?"
|
||||
"confirmDelete": "Delete this workshop visit?",
|
||||
"partsUsed": "Parts: {parts}"
|
||||
},
|
||||
"fuel": {
|
||||
"title": "Fuel cost",
|
||||
@@ -516,7 +526,11 @@
|
||||
"colFile": "File",
|
||||
"partial": "partial",
|
||||
"gap": "gap",
|
||||
"confirmDelete": "Delete this refill?"
|
||||
"confirmDelete": "Delete this refill?",
|
||||
"fullTank": "Full tank",
|
||||
"partialFill": "Partial fill",
|
||||
"missedBefore": "Missed fill before",
|
||||
"overDistance": "{consumption} · {rate} over {distance}"
|
||||
},
|
||||
"charging": {
|
||||
"title": "Charging cost",
|
||||
@@ -544,7 +558,11 @@
|
||||
"colFile": "File",
|
||||
"partial": "partial",
|
||||
"gap": "gap",
|
||||
"confirmDelete": "Delete this charge?"
|
||||
"confirmDelete": "Delete this charge?",
|
||||
"fullCharge": "Full charge",
|
||||
"partialCharge": "Partial charge",
|
||||
"missedBefore": "Missed charge before",
|
||||
"overDistance": "{consumption} · {rate} over {distance}"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
@@ -558,7 +576,8 @@
|
||||
"colRenewal": "Renewal",
|
||||
"colStatus": "Status",
|
||||
"colFile": "File",
|
||||
"confirmDelete": "Delete this document?"
|
||||
"confirmDelete": "Delete this document?",
|
||||
"issuedRenews": "Issued {issued} · Renews {renews}"
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Reminders",
|
||||
@@ -571,7 +590,10 @@
|
||||
"doneRollForward": "Done · roll forward",
|
||||
"markDone": "Mark done",
|
||||
"reopen": "Reopen",
|
||||
"confirmDelete": "Delete this reminder?"
|
||||
"confirmDelete": "Delete this reminder?",
|
||||
"on": "on {date}",
|
||||
"repeatsEvery": "Repeats every {every}",
|
||||
"autoHint": "Added automatically — edit the record it came from to change it."
|
||||
},
|
||||
"parts": {
|
||||
"title": "Parts catalog",
|
||||
@@ -613,7 +635,14 @@
|
||||
"typeToConfirm": "Type {name} to confirm",
|
||||
"deleting": "Deleting…",
|
||||
"confirm": "Delete permanently"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"share": "Share car",
|
||||
"edit": "Edit car",
|
||||
"odometer": "Update odometer",
|
||||
"delete": "Delete car"
|
||||
},
|
||||
"readOnlyNotice": "Shared with you (read-only). You can't make changes."
|
||||
},
|
||||
"forms": {
|
||||
"charging": {
|
||||
@@ -632,9 +661,7 @@
|
||||
"locationPlaceholder": "Home",
|
||||
"notes": "Notes",
|
||||
"attachmentLegend": "Receipt",
|
||||
"submit": "Save charge",
|
||||
"odometerRequired": "Odometer is required.",
|
||||
"kwhRequired": "Energy (kWh) is required."
|
||||
"submit": "Save charge"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import a car",
|
||||
@@ -657,6 +684,241 @@
|
||||
"importing": "Importing…",
|
||||
"warningOdometer": "The service did not report an odometer reading — enter it yourself on the car.",
|
||||
"moreData": "The rest of what this service reports stays available on the car's {label} tab."
|
||||
},
|
||||
"car": {
|
||||
"addTitle": "Add a car",
|
||||
"editTitle": "Edit car",
|
||||
"name": "Name *",
|
||||
"make": "Make",
|
||||
"model": "Model",
|
||||
"year": "Year",
|
||||
"registration": "Registration",
|
||||
"registrationCountry": "Registration country",
|
||||
"registrationCountryPlaceholder": "Poland",
|
||||
"vin": "VIN",
|
||||
"vinPlaceholder": "Vehicle Identification Number",
|
||||
"fuelType": "Fuel type",
|
||||
"buildDate": "Build date",
|
||||
"firstRegistration": "First registration",
|
||||
"oilSpec": "Engine oil spec",
|
||||
"currentKm": "Current odometer (km)",
|
||||
"transmissionOilSpec": "Transmission oil spec",
|
||||
"differentialOilSpec": "Differential oil spec",
|
||||
"brakeFluidSpec": "Brake fluid spec",
|
||||
"coolantSpec": "Coolant spec",
|
||||
"serviceIntervalDays": "Service interval (days)",
|
||||
"serviceIntervalKm": "Service interval (km)",
|
||||
"technicalCheckIntervalDays": "Technical check interval (days)",
|
||||
"technicalCheckHint": "Prefills each check's next-due date. Any check can override it with the date printed on its certificate.",
|
||||
"submit": "Add car"
|
||||
},
|
||||
"service": {
|
||||
"addTitle": "Add service record",
|
||||
"editTitle": "Edit service record",
|
||||
"date": "Date *",
|
||||
"odometer": "Odometer (km)",
|
||||
"changedParts": "Changed parts",
|
||||
"oil": "Oil & Oil filter",
|
||||
"engineFilter": "Engine air filter",
|
||||
"cabinFilter": "Cabin air filter",
|
||||
"attachmentLegend": "Receipt or service-book page",
|
||||
"notes": "Notes",
|
||||
"autoHint": "Next service date (+{days}d) and km (+{km}) are computed automatically.",
|
||||
"submit": "Add service"
|
||||
},
|
||||
"technical": {
|
||||
"addTitle": "Add technical check",
|
||||
"editTitle": "Edit technical check",
|
||||
"date": "Check date *",
|
||||
"result": "Result *",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed",
|
||||
"validUntil": "Valid until",
|
||||
"failedHint": "A failed check certifies nothing, so no next date is derived from it.",
|
||||
"derivedHint": "Leave blank to use the car's interval (+{days}d → {date}). Enter the date on the certificate when it differs.",
|
||||
"cost": "Cost",
|
||||
"station": "Station",
|
||||
"stationPlaceholder": "Stacja Kontroli Pojazdów",
|
||||
"attachmentLegend": "Inspection certificate",
|
||||
"notes": "Notes",
|
||||
"submit": "Add check"
|
||||
},
|
||||
"part": {
|
||||
"addTitle": "Add part",
|
||||
"editTitle": "Edit part",
|
||||
"name": "Part name *",
|
||||
"namePlaceholder": "Oil Filter",
|
||||
"partNumber": "Part number",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Fits 2015–2020 · buy in pairs",
|
||||
"attachmentLegend": "Photo or spec sheet",
|
||||
"submit": "Add part"
|
||||
},
|
||||
"fuel": {
|
||||
"addTitle": "Log refill",
|
||||
"editTitle": "Edit refill",
|
||||
"date": "Date *",
|
||||
"odometer": "Odometer (km) *",
|
||||
"liters": "Litres *",
|
||||
"cost": "Total cost",
|
||||
"pricePerLiter": "Price per litre: {price}",
|
||||
"tank": "Tank",
|
||||
"fullTank": "Filled to full",
|
||||
"missedFill": "I missed logging a refill before this one",
|
||||
"tankHint": "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.",
|
||||
"station": "Station",
|
||||
"notes": "Notes",
|
||||
"attachmentLegend": "Receipt",
|
||||
"submit": "Log refill",
|
||||
"stationPlaceholder": "Orlen"
|
||||
},
|
||||
"maintenance": {
|
||||
"addTitle": "Log workshop visit",
|
||||
"editTitle": "Edit workshop visit",
|
||||
"date": "Date *",
|
||||
"odometer": "Odometer (km)",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"description": "What was done *",
|
||||
"descriptionPlaceholder": "Replaced alternator and drive belt",
|
||||
"workshop": "Workshop",
|
||||
"location": "Location",
|
||||
"partsUsed": "Parts replaced",
|
||||
"partsUsedPlaceholder": "Alternator 27060-0T010, belt 90916-02660",
|
||||
"laborCost": "Labour cost",
|
||||
"partsCost": "Parts cost",
|
||||
"total": "Total: {total}",
|
||||
"invoiceNumber": "Invoice number",
|
||||
"warrantyUntil": "Warranty until",
|
||||
"attachmentLegend": "Invoice",
|
||||
"notes": "Notes",
|
||||
"submit": "Log visit",
|
||||
"workshopPlaceholder": "Kowalski Auto Service",
|
||||
"locationPlaceholder": "Kraków"
|
||||
},
|
||||
"document": {
|
||||
"addTitle": "Add document",
|
||||
"editTitle": "Edit document",
|
||||
"type": "Type",
|
||||
"title": "Title *",
|
||||
"titlePlaceholder": "Third-party liability 2026",
|
||||
"provider": "Provider",
|
||||
"reference": "Policy / certificate no.",
|
||||
"issued": "Issued",
|
||||
"renewalDate": "Renewal date",
|
||||
"renewalHint": "Leave the renewal date blank for a document that never expires. Setting it adds a reminder automatically.",
|
||||
"cost": "Cost",
|
||||
"attachmentLegend": "Scan or photo",
|
||||
"notes": "Notes",
|
||||
"submit": "Add document",
|
||||
"providerPlaceholder": "PZU"
|
||||
},
|
||||
"reminder": {
|
||||
"addTitle": "Add reminder",
|
||||
"editTitle": "Edit reminder",
|
||||
"title": "Title *",
|
||||
"titlePlaceholder": "Swap to winter tyres",
|
||||
"type": "Type",
|
||||
"remindMe": "Remind me",
|
||||
"onDate": "On date",
|
||||
"atOdometer": "At odometer (km)",
|
||||
"triggerHint": "Set either or both — with both, whichever comes first wins.",
|
||||
"currentKm": "The car is at {km} now.",
|
||||
"repeat": "Repeat (optional)",
|
||||
"everyDays": "Every … days",
|
||||
"everyKm": "Every … km",
|
||||
"recurringHint": "Marking this done will roll it forward instead of closing it.",
|
||||
"oneOffHint": "Leave blank for a one-off reminder that closes when you mark it done.",
|
||||
"notes": "Notes",
|
||||
"noTrigger": "Set a due date, a due odometer reading, or both.",
|
||||
"submit": "Add reminder"
|
||||
},
|
||||
"share": {
|
||||
"title": "Share {name}",
|
||||
"body": "Give another user access to this car. Read-only lets them view; read & write also lets them edit the car and its service records and parts.",
|
||||
"userEmail": "User email",
|
||||
"read": "Read-only",
|
||||
"write": "Read & write",
|
||||
"submit": "Share",
|
||||
"peopleWithAccess": "People with access",
|
||||
"notShared": "Not shared with anyone yet.",
|
||||
"submitting": "Sharing…"
|
||||
},
|
||||
"validation": {
|
||||
"odometer": "Odometer is required.",
|
||||
"liters": "Litres are required.",
|
||||
"kwh": "Energy (kWh) is required.",
|
||||
"name": "Name is required.",
|
||||
"partName": "Part name is required.",
|
||||
"description": "Describe what was done.",
|
||||
"title": "Title is required."
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"fuelType": {
|
||||
"petrol": "Petrol (gasoline)",
|
||||
"petrol_lpg": "Petrol (gasoline) + LPG",
|
||||
"diesel": "Diesel",
|
||||
"diesel_lpg": "Diesel + LPG",
|
||||
"hybrid": "Hybrid",
|
||||
"electric": "Electric",
|
||||
"hydrogen": "Hydrogen"
|
||||
},
|
||||
"maintenanceType": {
|
||||
"repair": "Repair",
|
||||
"inspection": "Inspection",
|
||||
"bodywork": "Bodywork",
|
||||
"tyres": "Tyres",
|
||||
"diagnostics": "Diagnostics",
|
||||
"recall": "Recall",
|
||||
"warranty": "Warranty work",
|
||||
"other": "Other"
|
||||
},
|
||||
"maintenanceStatus": {
|
||||
"scheduled": "Scheduled",
|
||||
"in_progress": "In progress",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"documentType": {
|
||||
"insurance": "Insurance",
|
||||
"pollution": "Pollution certificate",
|
||||
"registration": "Registration",
|
||||
"inspection": "Inspection",
|
||||
"roadTax": "Road tax",
|
||||
"warranty": "Warranty",
|
||||
"other": "Other"
|
||||
},
|
||||
"reminderTypeShort": {
|
||||
"maintenance": "Maintenance",
|
||||
"document": "Document",
|
||||
"service": "Service",
|
||||
"inspection": "Inspection",
|
||||
"other": "Other"
|
||||
},
|
||||
"reminderType": {
|
||||
"maintenance": "Maintenance",
|
||||
"document": "Document renewal",
|
||||
"service": "Service",
|
||||
"inspection": "Inspection",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"legend": "Attachment",
|
||||
"hint": "PDF or image, up to 10MB.",
|
||||
"attached": "Attached: {name}",
|
||||
"willBeRemoved": "Attachment will be removed on save.",
|
||||
"choose": "Choose file",
|
||||
"replace": "Replace",
|
||||
"view": "View",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"errors": {
|
||||
"sessionExpired": "Session expired — please log in again.",
|
||||
"deleteFailed": "Delete failed: {error}",
|
||||
"completeFailed": "Could not complete: {error}",
|
||||
"attachmentFailed": "Saved, but the file did not upload: {error}",
|
||||
"openFailed": "Could not open the file: {error}",
|
||||
"noFile": "No file attached."
|
||||
}
|
||||
}
|
||||
|
||||
+276
-12
@@ -370,7 +370,13 @@
|
||||
"fuelType": "Rodzaj paliwa",
|
||||
"buildDate": "Data produkcji",
|
||||
"firstRegistration": "Pierwsza rejestracja",
|
||||
"technicalCheckInterval": "Interwał przeglądów technicznych"
|
||||
"technicalCheckInterval": "Interwał przeglądów technicznych",
|
||||
"daysValue": {
|
||||
"one": "{n} dzień",
|
||||
"few": "{n} dni",
|
||||
"many": "{n} dni",
|
||||
"other": "{n} dni"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"title": "Historia serwisowa",
|
||||
@@ -385,7 +391,11 @@
|
||||
"colCabinFilter": "Filtr kabinowy",
|
||||
"colNotes": "Notatki",
|
||||
"colFile": "Plik",
|
||||
"confirmDelete": "Usunąć ten wpis serwisowy?"
|
||||
"confirmDelete": "Usunąć ten wpis serwisowy?",
|
||||
"next": "Następny: {date} · {km}",
|
||||
"chipOil": "Olej i filtr",
|
||||
"chipEngineFilter": "Filtr powietrza",
|
||||
"chipCabinFilter": "Filtr kabinowy"
|
||||
},
|
||||
"technical": {
|
||||
"title": "Historia przeglądów technicznych",
|
||||
@@ -402,7 +412,8 @@
|
||||
"colFile": "Plik",
|
||||
"passed": "Pozytywny",
|
||||
"failed": "Negatywny",
|
||||
"confirmDelete": "Usunąć ten przegląd techniczny?"
|
||||
"confirmDelete": "Usunąć ten przegląd techniczny?",
|
||||
"next": "Następny: {date}"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Naprawy",
|
||||
@@ -418,7 +429,8 @@
|
||||
"colCost": "Koszt",
|
||||
"colFile": "Plik",
|
||||
"underWarranty": "Na gwarancji · pozostało {days} dni",
|
||||
"confirmDelete": "Usunąć tę wizytę w warsztacie?"
|
||||
"confirmDelete": "Usunąć tę wizytę w warsztacie?",
|
||||
"partsUsed": "Części: {parts}"
|
||||
},
|
||||
"fuel": {
|
||||
"title": "Koszty paliwa",
|
||||
@@ -446,7 +458,11 @@
|
||||
"colFile": "Plik",
|
||||
"partial": "częściowe",
|
||||
"gap": "luka",
|
||||
"confirmDelete": "Usunąć to tankowanie?"
|
||||
"confirmDelete": "Usunąć to tankowanie?",
|
||||
"fullTank": "Pełny bak",
|
||||
"partialFill": "Tankowanie częściowe",
|
||||
"missedBefore": "Pominięte tankowanie",
|
||||
"overDistance": "{consumption} · {rate} na dystansie {distance}"
|
||||
},
|
||||
"charging": {
|
||||
"title": "Koszty ładowania",
|
||||
@@ -474,7 +490,11 @@
|
||||
"colFile": "Plik",
|
||||
"partial": "częściowe",
|
||||
"gap": "przerwa",
|
||||
"confirmDelete": "Usunąć to ładowanie?"
|
||||
"confirmDelete": "Usunąć to ładowanie?",
|
||||
"fullCharge": "Pełne ładowanie",
|
||||
"partialCharge": "Ładowanie częściowe",
|
||||
"missedBefore": "Pominięte ładowanie",
|
||||
"overDistance": "{consumption} · {rate} na dystansie {distance}"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumenty",
|
||||
@@ -488,7 +508,8 @@
|
||||
"colRenewal": "Odnowienie",
|
||||
"colStatus": "Status",
|
||||
"colFile": "Plik",
|
||||
"confirmDelete": "Usunąć ten dokument?"
|
||||
"confirmDelete": "Usunąć ten dokument?",
|
||||
"issuedRenews": "Wydano {issued} · Odnowienie {renews}"
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Przypomnienia",
|
||||
@@ -501,7 +522,10 @@
|
||||
"doneRollForward": "Gotowe · przenieś dalej",
|
||||
"markDone": "Oznacz jako gotowe",
|
||||
"reopen": "Otwórz ponownie",
|
||||
"confirmDelete": "Usunąć to przypomnienie?"
|
||||
"confirmDelete": "Usunąć to przypomnienie?",
|
||||
"on": "dnia {date}",
|
||||
"repeatsEvery": "Powtarza się co {every}",
|
||||
"autoHint": "Dodane automatycznie — aby to zmienić, edytuj wpis, z którego pochodzi."
|
||||
},
|
||||
"parts": {
|
||||
"title": "Katalog części",
|
||||
@@ -553,7 +577,14 @@
|
||||
"typeToConfirm": "Wpisz {name}, aby potwierdzić",
|
||||
"deleting": "Usuwanie…",
|
||||
"confirm": "Usuń trwale"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"share": "Udostępnij samochód",
|
||||
"edit": "Edytuj samochód",
|
||||
"odometer": "Zaktualizuj przebieg",
|
||||
"delete": "Usuń samochód"
|
||||
},
|
||||
"readOnlyNotice": "Udostępnione Tobie (tylko do odczytu). Nie możesz wprowadzać zmian."
|
||||
},
|
||||
"forms": {
|
||||
"charging": {
|
||||
@@ -572,9 +603,7 @@
|
||||
"locationPlaceholder": "Dom",
|
||||
"notes": "Notatki",
|
||||
"attachmentLegend": "Paragon",
|
||||
"submit": "Zapisz ładowanie",
|
||||
"odometerRequired": "Przebieg jest wymagany.",
|
||||
"kwhRequired": "Energia (kWh) jest wymagana."
|
||||
"submit": "Zapisz ładowanie"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importuj samochód",
|
||||
@@ -597,6 +626,241 @@
|
||||
"importing": "Importowanie…",
|
||||
"warningOdometer": "Usługa nie podała przebiegu — wpisz go samodzielnie w samochodzie.",
|
||||
"moreData": "Pozostałe dane z tej usługi pozostają dostępne w karcie {label} samochodu."
|
||||
},
|
||||
"car": {
|
||||
"addTitle": "Dodaj samochód",
|
||||
"editTitle": "Edytuj samochód",
|
||||
"name": "Nazwa *",
|
||||
"make": "Marka",
|
||||
"model": "Model",
|
||||
"year": "Rok",
|
||||
"registration": "Numer rejestracyjny",
|
||||
"registrationCountry": "Kraj rejestracji",
|
||||
"registrationCountryPlaceholder": "Polska",
|
||||
"vin": "VIN",
|
||||
"vinPlaceholder": "Numer identyfikacyjny pojazdu",
|
||||
"fuelType": "Rodzaj paliwa",
|
||||
"buildDate": "Data produkcji",
|
||||
"firstRegistration": "Pierwsza rejestracja",
|
||||
"oilSpec": "Specyfikacja oleju silnikowego",
|
||||
"currentKm": "Aktualny przebieg (km)",
|
||||
"transmissionOilSpec": "Specyfikacja oleju przekładniowego",
|
||||
"differentialOilSpec": "Specyfikacja oleju mostu napędowego",
|
||||
"brakeFluidSpec": "Specyfikacja płynu hamulcowego",
|
||||
"coolantSpec": "Specyfikacja płynu chłodniczego",
|
||||
"serviceIntervalDays": "Interwał serwisowy (dni)",
|
||||
"serviceIntervalKm": "Interwał serwisowy (km)",
|
||||
"technicalCheckIntervalDays": "Interwał przeglądu technicznego (dni)",
|
||||
"technicalCheckHint": "Wstępnie wypełnia termin następnego przeglądu. Każdy przegląd może go nadpisać datą z zaświadczenia.",
|
||||
"submit": "Dodaj samochód"
|
||||
},
|
||||
"service": {
|
||||
"addTitle": "Dodaj wpis serwisowy",
|
||||
"editTitle": "Edytuj wpis serwisowy",
|
||||
"date": "Data *",
|
||||
"odometer": "Przebieg (km)",
|
||||
"changedParts": "Wymienione części",
|
||||
"oil": "Olej i filtr oleju",
|
||||
"engineFilter": "Filtr powietrza silnika",
|
||||
"cabinFilter": "Filtr kabinowy",
|
||||
"attachmentLegend": "Paragon lub strona książki serwisowej",
|
||||
"notes": "Notatki",
|
||||
"autoHint": "Data (+{days} dni) i przebieg (+{km}) następnego serwisu są obliczane automatycznie.",
|
||||
"submit": "Dodaj serwis"
|
||||
},
|
||||
"technical": {
|
||||
"addTitle": "Dodaj przegląd techniczny",
|
||||
"editTitle": "Edytuj przegląd techniczny",
|
||||
"date": "Data przeglądu *",
|
||||
"result": "Wynik *",
|
||||
"passed": "Pozytywny",
|
||||
"failed": "Negatywny",
|
||||
"validUntil": "Ważny do",
|
||||
"failedHint": "Negatywny przegląd niczego nie potwierdza, więc nie wyznacza następnego terminu.",
|
||||
"derivedHint": "Pozostaw puste, aby użyć interwału samochodu (+{days} dni → {date}). Wpisz datę z zaświadczenia, jeśli jest inna.",
|
||||
"cost": "Koszt",
|
||||
"station": "Stacja",
|
||||
"stationPlaceholder": "Stacja Kontroli Pojazdów",
|
||||
"attachmentLegend": "Zaświadczenie o przeglądzie",
|
||||
"notes": "Notatki",
|
||||
"submit": "Dodaj przegląd"
|
||||
},
|
||||
"part": {
|
||||
"addTitle": "Dodaj część",
|
||||
"editTitle": "Edytuj część",
|
||||
"name": "Nazwa części *",
|
||||
"namePlaceholder": "Filtr oleju",
|
||||
"partNumber": "Numer części",
|
||||
"notes": "Notatki",
|
||||
"notesPlaceholder": "Pasuje do 2015–2020 · kupować parami",
|
||||
"attachmentLegend": "Zdjęcie lub karta katalogowa",
|
||||
"submit": "Dodaj część"
|
||||
},
|
||||
"fuel": {
|
||||
"addTitle": "Zapisz tankowanie",
|
||||
"editTitle": "Edytuj tankowanie",
|
||||
"date": "Data *",
|
||||
"odometer": "Przebieg (km) *",
|
||||
"liters": "Litry *",
|
||||
"cost": "Koszt całkowity",
|
||||
"pricePerLiter": "Cena za litr: {price}",
|
||||
"tank": "Bak",
|
||||
"fullTank": "Zatankowano do pełna",
|
||||
"missedFill": "Nie zapisałem tankowania przed tym",
|
||||
"tankHint": "Zużycie liczone jest między pełnymi bakami, więc tankowania częściowe wliczają się do następnego pełnego. Oznaczenie pominiętego tankowania wyklucza ten odcinek z obliczeń, zamiast pokazywać nierealnie niskie spalanie.",
|
||||
"station": "Stacja",
|
||||
"notes": "Notatki",
|
||||
"attachmentLegend": "Paragon",
|
||||
"submit": "Zapisz tankowanie",
|
||||
"stationPlaceholder": "Orlen"
|
||||
},
|
||||
"maintenance": {
|
||||
"addTitle": "Zapisz wizytę w warsztacie",
|
||||
"editTitle": "Edytuj wizytę w warsztacie",
|
||||
"date": "Data *",
|
||||
"odometer": "Przebieg (km)",
|
||||
"type": "Rodzaj",
|
||||
"status": "Status",
|
||||
"description": "Co zostało zrobione *",
|
||||
"descriptionPlaceholder": "Wymiana alternatora i paska napędowego",
|
||||
"workshop": "Warsztat",
|
||||
"location": "Lokalizacja",
|
||||
"partsUsed": "Wymienione części",
|
||||
"partsUsedPlaceholder": "Alternator 27060-0T010, pasek 90916-02660",
|
||||
"laborCost": "Koszt robocizny",
|
||||
"partsCost": "Koszt części",
|
||||
"total": "Razem: {total}",
|
||||
"invoiceNumber": "Numer faktury",
|
||||
"warrantyUntil": "Gwarancja do",
|
||||
"attachmentLegend": "Faktura",
|
||||
"notes": "Notatki",
|
||||
"submit": "Zapisz wizytę",
|
||||
"workshopPlaceholder": "Auto Serwis Kowalski",
|
||||
"locationPlaceholder": "Kraków"
|
||||
},
|
||||
"document": {
|
||||
"addTitle": "Dodaj dokument",
|
||||
"editTitle": "Edytuj dokument",
|
||||
"type": "Rodzaj",
|
||||
"title": "Nazwa *",
|
||||
"titlePlaceholder": "OC 2026",
|
||||
"provider": "Wystawca",
|
||||
"reference": "Nr polisy / zaświadczenia",
|
||||
"issued": "Wystawiono",
|
||||
"renewalDate": "Data odnowienia",
|
||||
"renewalHint": "Pozostaw datę odnowienia pustą dla dokumentu bezterminowego. Ustawienie jej automatycznie doda przypomnienie.",
|
||||
"cost": "Koszt",
|
||||
"attachmentLegend": "Skan lub zdjęcie",
|
||||
"notes": "Notatki",
|
||||
"submit": "Dodaj dokument",
|
||||
"providerPlaceholder": "PZU"
|
||||
},
|
||||
"reminder": {
|
||||
"addTitle": "Dodaj przypomnienie",
|
||||
"editTitle": "Edytuj przypomnienie",
|
||||
"title": "Nazwa *",
|
||||
"titlePlaceholder": "Zmiana na opony zimowe",
|
||||
"type": "Rodzaj",
|
||||
"remindMe": "Przypomnij mi",
|
||||
"onDate": "W dniu",
|
||||
"atOdometer": "Przy przebiegu (km)",
|
||||
"triggerHint": "Ustaw jedno lub oba — przy obu liczy się to, co nastąpi wcześniej.",
|
||||
"currentKm": "Samochód ma teraz {km}.",
|
||||
"repeat": "Powtarzanie (opcjonalnie)",
|
||||
"everyDays": "Co … dni",
|
||||
"everyKm": "Co … km",
|
||||
"recurringHint": "Oznaczenie jako gotowe przeniesie je dalej, zamiast zamknąć.",
|
||||
"oneOffHint": "Pozostaw puste dla jednorazowego przypomnienia, które zamknie się po oznaczeniu jako gotowe.",
|
||||
"notes": "Notatki",
|
||||
"noTrigger": "Ustaw datę, przebieg lub oba.",
|
||||
"submit": "Dodaj przypomnienie"
|
||||
},
|
||||
"share": {
|
||||
"title": "Udostępnij {name}",
|
||||
"body": "Daj innemu użytkownikowi dostęp do tego samochodu. Tylko do odczytu pozwala na podgląd; odczyt i zapis pozwala też edytować samochód oraz jego wpisy serwisowe i części.",
|
||||
"userEmail": "E-mail użytkownika",
|
||||
"read": "Tylko do odczytu",
|
||||
"write": "Odczyt i zapis",
|
||||
"submit": "Udostępnij",
|
||||
"peopleWithAccess": "Osoby z dostępem",
|
||||
"notShared": "Jeszcze nikomu nie udostępniono.",
|
||||
"submitting": "Udostępnianie…"
|
||||
},
|
||||
"validation": {
|
||||
"odometer": "Przebieg jest wymagany.",
|
||||
"liters": "Liczba litrów jest wymagana.",
|
||||
"kwh": "Energia (kWh) jest wymagana.",
|
||||
"name": "Nazwa jest wymagana.",
|
||||
"partName": "Nazwa części jest wymagana.",
|
||||
"description": "Opisz, co zostało zrobione.",
|
||||
"title": "Tytuł jest wymagany."
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"fuelType": {
|
||||
"petrol": "Benzyna",
|
||||
"petrol_lpg": "Benzyna + LPG",
|
||||
"diesel": "Diesel",
|
||||
"diesel_lpg": "Diesel + LPG",
|
||||
"hybrid": "Hybryda",
|
||||
"electric": "Elektryczny",
|
||||
"hydrogen": "Wodór"
|
||||
},
|
||||
"maintenanceType": {
|
||||
"repair": "Naprawa",
|
||||
"inspection": "Przegląd",
|
||||
"bodywork": "Blacharka",
|
||||
"tyres": "Opony",
|
||||
"diagnostics": "Diagnostyka",
|
||||
"recall": "Akcja serwisowa",
|
||||
"warranty": "Naprawa gwarancyjna",
|
||||
"other": "Inne"
|
||||
},
|
||||
"maintenanceStatus": {
|
||||
"scheduled": "Zaplanowana",
|
||||
"in_progress": "W trakcie",
|
||||
"completed": "Zakończona"
|
||||
},
|
||||
"documentType": {
|
||||
"insurance": "Ubezpieczenie",
|
||||
"pollution": "Zaświadczenie o emisji spalin",
|
||||
"registration": "Dowód rejestracyjny",
|
||||
"inspection": "Przegląd",
|
||||
"roadTax": "Podatek drogowy",
|
||||
"warranty": "Gwarancja",
|
||||
"other": "Inne"
|
||||
},
|
||||
"reminderTypeShort": {
|
||||
"maintenance": "Naprawa",
|
||||
"document": "Dokument",
|
||||
"service": "Serwis",
|
||||
"inspection": "Przegląd",
|
||||
"other": "Inne"
|
||||
},
|
||||
"reminderType": {
|
||||
"maintenance": "Naprawa",
|
||||
"document": "Odnowienie dokumentu",
|
||||
"service": "Serwis",
|
||||
"inspection": "Przegląd",
|
||||
"other": "Inne"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
"legend": "Załącznik",
|
||||
"hint": "PDF lub obraz, do 10 MB.",
|
||||
"attached": "Załączono: {name}",
|
||||
"willBeRemoved": "Załącznik zostanie usunięty przy zapisie.",
|
||||
"choose": "Wybierz plik",
|
||||
"replace": "Zamień",
|
||||
"view": "Podgląd",
|
||||
"clear": "Wyczyść"
|
||||
},
|
||||
"errors": {
|
||||
"sessionExpired": "Sesja wygasła — zaloguj się ponownie.",
|
||||
"deleteFailed": "Usuwanie nie powiodło się: {error}",
|
||||
"completeFailed": "Nie udało się ukończyć: {error}",
|
||||
"attachmentFailed": "Zapisano, ale plik nie został przesłany: {error}",
|
||||
"openFailed": "Nie udało się otworzyć pliku: {error}",
|
||||
"noFile": "Brak załączonego pliku."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ${[
|
||||
t("car.reminders.repeatsEvery", params: {
|
||||
"every": [
|
||||
if (r.repeatDays > 0) "${r.repeatDays}d",
|
||||
if (r.repeatKm > 0) formatKm(r.repeatKm),
|
||||
].join(" · ")}"),
|
||||
].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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import "package:drivervault_phone/format.dart";
|
||||
import "package:drivervault_phone/i18n.dart";
|
||||
import "package:drivervault_phone/main.dart";
|
||||
import "package:drivervault_phone/models.dart";
|
||||
import "package:drivervault_phone/screens/car_form_sheet.dart";
|
||||
import "package:drivervault_phone/screens/car_view_sheet.dart";
|
||||
|
||||
void main() {
|
||||
@@ -284,6 +285,95 @@ void main() {
|
||||
expect(open.fetchedAt, isNotNull);
|
||||
});
|
||||
|
||||
// The car screen looks these up by key at render time — t("car.tabs.$key"),
|
||||
// t("enums.fuelType.$v") and the rest — so a missing entry is invisible to the
|
||||
// analyzer and shows up as a raw key path on the screen. Every catalogue the
|
||||
// UI iterates is checked here, in every language the app ships.
|
||||
group("every catalogue key has a label", () {
|
||||
void expectLabelled(String key) {
|
||||
for (final lang in translatedLanguages) {
|
||||
appSettings.locale = "$lang-${lang.toUpperCase()}";
|
||||
expect(t(key), isNot(key), reason: "$key is missing from $lang.json");
|
||||
expect(t(key).trim(), isNotEmpty, reason: "$key is blank in $lang.json");
|
||||
}
|
||||
}
|
||||
|
||||
tearDown(() => appSettings.locale = "pl-PL");
|
||||
|
||||
test("car tabs", () {
|
||||
for (final key in kCarTabKeys) {
|
||||
// "provider" has no key of its own by design: a linked car's tab is
|
||||
// named after the service ("MyToyota"), and an unlinked one falls back
|
||||
// to car.tabs.connected. Both apps label it that way.
|
||||
if (key == "provider") continue;
|
||||
expectLabelled("car.tabs.$key");
|
||||
}
|
||||
expectLabelled("car.tabs.connected");
|
||||
});
|
||||
|
||||
test("Information rows", () {
|
||||
for (final key in kCarInfoFieldKeys) {
|
||||
expectLabelled("car.info.$key");
|
||||
}
|
||||
expectLabelled("car.info.technicalCheckInterval");
|
||||
});
|
||||
|
||||
test("the delete dialog's per-collection counts", () {
|
||||
// Plural objects rather than plain strings, so they are checked through
|
||||
// the plural path instead of expectLabelled.
|
||||
for (final key in ["services", "maintenance", "fuel", "charging", "documents", "parts"]) {
|
||||
for (final lang in translatedLanguages) {
|
||||
appSettings.locale = "$lang-${lang.toUpperCase()}";
|
||||
final one = t("car.delete.$key", n: 1);
|
||||
final many = t("car.delete.$key", n: 5);
|
||||
expect(one, isNot("car.delete.$key"), reason: "car.delete.$key missing from $lang.json");
|
||||
expect(one, contains("1"));
|
||||
expect(many, contains("5"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the enums the pickers and tiles share", () {
|
||||
for (final key in kFuelTypes) {
|
||||
expectLabelled("enums.fuelType.$key");
|
||||
}
|
||||
for (final key in ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall",
|
||||
"warranty", "other"]) {
|
||||
expectLabelled("enums.maintenanceType.$key");
|
||||
}
|
||||
for (final key in ["scheduled", "in_progress", "completed"]) {
|
||||
expectLabelled("enums.maintenanceStatus.$key");
|
||||
}
|
||||
for (final key in ["insurance", "pollution", "registration", "inspection", "roadTax",
|
||||
"warranty", "other"]) {
|
||||
expectLabelled("enums.documentType.$key");
|
||||
}
|
||||
for (final key in ["maintenance", "document", "service", "inspection", "other"]) {
|
||||
expectLabelled("enums.reminderType.$key");
|
||||
}
|
||||
});
|
||||
|
||||
test("the connected service's headline readings", () {
|
||||
// Mirrors headlineMetricSpecs + unmeasuredMetricKeys in the API's
|
||||
// vehicleproviders.go: every reading it can report needs a label here.
|
||||
for (final key in ["odometer", "fuelLevel", "fuelRange", "batteryLevel", "evRange",
|
||||
"evRangeWithAc", "chargingStatus", "location"]) {
|
||||
expectLabelled("car.provider.metrics.$key");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("a day count reads as prose in each language's own plural forms", () {
|
||||
appSettings.locale = "en-GB";
|
||||
expect(t("car.info.daysValue", n: 1), "1 day");
|
||||
expect(t("car.info.daysValue", n: 365), "365 days");
|
||||
// Polish splits one/few/many, which an English-style n==1 test would miss.
|
||||
appSettings.locale = "pl-PL";
|
||||
expect(t("car.info.daysValue", n: 1), "1 dzień");
|
||||
expect(t("car.info.daysValue", n: 365), "365 dni");
|
||||
appSettings.locale = "pl-PL";
|
||||
});
|
||||
|
||||
test("ImportResult reads the server's per-collection counts", () {
|
||||
final res = ImportResult.fromJson(
|
||||
{"carsImported": 2, "servicesImported": 11, "partsImported": 4});
|
||||
|
||||
+25
-7
@@ -82,14 +82,32 @@ No screen code changes are needed to add a language.
|
||||
## Coverage
|
||||
|
||||
- **Web App** — fully translated (every view, component, form, and the status
|
||||
labels in `lib/format.js`).
|
||||
labels in `lib/format.js`), except the Integrations settings and the OCPP
|
||||
charger-control card: `settings.integrations.*` and `charging.control.*` exist
|
||||
in `en.json` only, so both fall back to English in Polish and Danish.
|
||||
- **API Server panel** — UI chrome, cards, login, status, and the API section
|
||||
titles are translated. The individual REST endpoint **descriptions** in the
|
||||
API reference table are intentionally left in English as developer reference
|
||||
documentation.
|
||||
- **Phone App** — the i18n system plus the core flows are translated: navigation,
|
||||
login, lock screen, dashboard, the full Settings panel (including the language
|
||||
picker), and all status/badge wording in `lib/format.dart`. The remaining
|
||||
detail screens (car detail, record form sheets, admin users, car form sheet,
|
||||
attachment field) still render in English via the fallback until their strings
|
||||
are extracted — the pattern to follow is identical to the screens already done.
|
||||
- **Phone App** — navigation, login, lock screen, dashboard, the full Settings
|
||||
panel (including the language picker), the status/badge wording in
|
||||
`lib/format.dart`, and the whole car screen: its tabs, every record tile, the
|
||||
share and delete-car dialogs, and all of the form sheets (`car_form_sheet`,
|
||||
`record_form_sheets`, `attachment_field`).
|
||||
|
||||
The strings the two apps share are **copied out of `Web App/web/src/i18n/`**
|
||||
rather than retyped, so a phrase has one translation across both and cannot
|
||||
drift. Only what the phone alone needs is written here: tooltips (the web
|
||||
labels its buttons), the record tiles' running prose (the web lays the same
|
||||
data out as table columns), client-side validation (the web leans on the
|
||||
browser's `required`), and the snackbars.
|
||||
|
||||
Still English: **admin users** (`admin_users_screen.dart`), and the same
|
||||
`settings.integrations.*` / `charging.control.*` gap the web app has, which is
|
||||
worth closing in both at once rather than letting the phone run ahead.
|
||||
|
||||
`test/models_format_test.dart` guards the lookups the analyzer cannot see —
|
||||
the ones built from a key at render time (`car.tabs.$key`,
|
||||
`enums.fuelType.$v`, the delete dialog's plural counts, the connected
|
||||
service's readings). A catalogue entry with no label fails the test rather
|
||||
than reaching a screen as a raw key path.
|
||||
|
||||
Reference in New Issue
Block a user