diff --git a/Phone App/README.md b/Phone App/README.md index 410ec9c..92b30e7 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -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 diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 5b1d88f..68a4055 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -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." } } diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index f19f6d2..4d683f3 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -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." } } diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index f56ef5e..6d592f8 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -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." } } diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index 2213872..85cdab8 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -145,7 +145,7 @@ class _CarDetailScreenState extends State { } Future _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 { } Future _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 { if (saved == true) _reload(); } - /// Confirms, deletes, then reloads. [what] names the record in the prompt. - Future _deleteRecord(String what, Future 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 _deleteRecord(String confirmKey, Future 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 { 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 { 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 { final saved = await showDialog( 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 { 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 { ); if (ctx.mounted) Navigator.pop(ctx, true); }, - child: const Text("Save"), + child: Text(t("common.save")), ), ], ), @@ -285,14 +290,10 @@ class _CarDetailScreenState extends State { if (saved == true) _reload(); } - Future _deleteCar(Car car, int serviceCount, int partCount) async { + Future _deleteCar(Car car, _CarDetailData data) async { final confirmed = await showDialog( 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 { } 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 { 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 { ), 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 { 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 { 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 { ? () => _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 { 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 { ? () => _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 { 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 { ? () => _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 { 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 { 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 { 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 { ? () => _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 _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 = [ - if (record.changedOil) _chip(context, "Oil & filter"), - if (record.changedEngineAirFilter) _chip(context, "Engine air"), - if (record.changedCabinAirFilter) _chip(context, "Cabin air"), + if (record.changedOil) _chip(context, t("car.services.chipOil")), + if (record.changedEngineAirFilter) _chip(context, t("car.services.chipEngineFilter")), + if (record.changedCabinAirFilter) _chip(context, t("car.services.chipCabinFilter")), ]; return Card( elevation: 0, @@ -841,7 +842,11 @@ class _ServiceTile extends StatelessWidget { ], ), const SizedBox(height: 4), - Text("Next: ${formatDate(record.nextServiceDate)} · ${formatKm(record.nextServiceKm)}", + Text( + t("car.services.next", params: { + "date": formatDate(record.nextServiceDate), + "km": formatKm(record.nextServiceKm), + }), style: const TextStyle(color: Colors.grey, fontSize: 12)), if (chips.isNotEmpty) ...[ const SizedBox(height: 8), @@ -925,11 +930,13 @@ class _RowMenu extends StatelessWidget { }, itemBuilder: (_) => [ if (onComplete != null) - const PopupMenuItem(value: "complete", child: Text("Mark done")), - if (onEdit != null) const PopupMenuItem(value: "edit", child: Text("Edit")), + PopupMenuItem(value: "complete", child: Text(t("car.reminders.markDone"))), + if (onEdit != null) PopupMenuItem(value: "edit", child: Text(t("common.edit"))), if (onDelete != null) - const PopupMenuItem( - value: "delete", child: Text("Delete", style: TextStyle(color: DriverVault.danger))), + PopupMenuItem( + value: "delete", + child: Text(t("common.delete"), + style: const TextStyle(color: DriverVault.danger))), ], ); } @@ -1036,7 +1043,7 @@ class _TechnicalCheckTile extends StatelessWidget { : (dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft), borderRadius: BorderRadius.circular(6), ), - child: Text(check.passed ? "Passed" : "Failed", + child: Text(t(check.passed ? "car.technical.passed" : "car.technical.failed"), style: TextStyle( color: check.passed ? DriverVault.success : DriverVault.danger, fontSize: 11, @@ -1052,11 +1059,15 @@ class _TechnicalCheckTile extends StatelessWidget { // when there is one. if (check.nextCheckDate != null) Row(children: [ - Expanded(child: _sub(context, "Next: ${formatDate(check.nextCheckDate)}")), + Expanded( + child: _sub( + context, + t("car.technical.next", + params: {"date": formatDate(check.nextCheckDate)}))), _Badge(expiryStatus(check.expiry)), ]) else - _sub(context, "No next date derived from a failed check."), + _sub(context, t("forms.technical.failedHint")), if (check.cost > 0 || check.station.isNotEmpty) ...[ const SizedBox(height: 4), _sub( @@ -1084,21 +1095,13 @@ class _MaintenanceTile extends StatelessWidget { final VoidCallback? onDelete; const _MaintenanceTile({required this.entry, this.onEdit, this.onDelete}); - static const _typeLabels = { - "repair": "Repair", - "inspection": "Inspection", - "bodywork": "Bodywork", - "tyres": "Tyres", - "diagnostics": "Diagnostics", - "recall": "Recall", - "warranty": "Warranty work", - "other": "Other", - }; - static const _statusLabels = { - "scheduled": "Scheduled", - "in_progress": "In progress", - "completed": "Completed", - }; + /// An unknown value falls back to the raw key rather than a blank: the + /// server is the authority on this enum, and a value added there should still + /// be legible in an app that has not caught up. + static String _label(String namespace, String value) { + final label = t("enums.$namespace.$value"); + return label == "enums.$namespace.$value" ? value : label; + } @override Widget build(BuildContext context) { @@ -1124,8 +1127,8 @@ class _MaintenanceTile extends StatelessWidget { [ formatDate(entry.date), if (entry.km > 0) formatKm(entry.km), // optional on maintenance - _typeLabels[entry.type] ?? entry.type, - _statusLabels[entry.status] ?? entry.status, + _label("maintenanceType", entry.type), + _label("maintenanceStatus", entry.status), ].join(" · ")), if (entry.workshop.isNotEmpty || entry.location.isNotEmpty) ...[ const SizedBox(height: 4), @@ -1138,11 +1141,11 @@ class _MaintenanceTile extends StatelessWidget { ], if (entry.partsUsed.isNotEmpty) ...[ const SizedBox(height: 4), - _sub(context, "Parts: ${entry.partsUsed}"), + _sub(context, t("car.maintenance.partsUsed", params: {"parts": entry.partsUsed})), ], if (entry.totalCost > 0) ...[ const SizedBox(height: 4), - Text("Total: ${formatMoney(entry.totalCost)}", + Text(t("forms.maintenance.total", params: {"total": formatMoney(entry.totalCost)}), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), ], if (warranty != null) ...[ @@ -1173,31 +1176,28 @@ class _FuelStatsPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Fuel summary", style: TextStyle(fontWeight: FontWeight.w600)), + Text(t("car.fuel.title"), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Wrap( spacing: 16, runSpacing: 8, children: [ - _stat(context, "Average", formatConsumption(stats.avgConsumptionL100)), - _stat(context, "Best", formatConsumption(stats.bestConsumptionL100)), - _stat(context, "Worst", formatConsumption(stats.worstConsumptionL100)), - _stat(context, "Average", formatKmPerLiter(stats.avgKmPerLiter)), - _stat(context, "Cost per km", formatMoney(stats.costPerKm)), - _stat(context, "Price per litre", formatMoney(stats.avgPricePerLiter)), - _stat(context, "Total litres", formatLiters(stats.totalLiters)), - _stat(context, "Total cost", formatMoney(stats.totalCost)), - _stat(context, "Refills", "${stats.entries}"), - _stat(context, "Tracked distance", formatKm(stats.trackedDistanceKm)), + _stat(context, t("car.fuel.average"), formatConsumption(stats.avgConsumptionL100)), + _stat(context, t("car.fuel.best"), formatConsumption(stats.bestConsumptionL100)), + _stat(context, t("car.fuel.worst"), formatConsumption(stats.worstConsumptionL100)), + _stat(context, t("car.fuel.average"), formatKmPerLiter(stats.avgKmPerLiter)), + _stat(context, t("car.fuel.costPerKm"), formatMoney(stats.costPerKm)), + _stat(context, t("car.fuel.colPerLiter"), formatMoney(stats.avgPricePerLiter)), + _stat(context, t("car.fuel.totalLiters"), formatLiters(stats.totalLiters)), + _stat(context, t("car.fuel.totalSpent"), formatMoney(stats.totalCost)), + _stat(context, t("car.fuel.refills"), "${stats.entries}"), + _stat(context, t("car.fuel.trackedDistance"), formatKm(stats.trackedDistanceKm)), ], ), const SizedBox(height: 8), // Without this the tracked distance reads as a mistake whenever the // history starts or ends on a partial fill. - _sub( - context, - "Averages cover the distance between full tanks — the stretches the litres on" - " record actually account for."), + _sub(context, t("car.fuel.subtitle")), ], ), ); @@ -1312,9 +1312,10 @@ class _ChargingTile extends StatelessWidget { ].join(" · ")), const SizedBox(height: 6), Wrap(spacing: 6, runSpacing: 6, children: [ - _tag(context, entry.fullCharge ? t("forms.charging.fullCharge") : t("car.charging.partial"), + _tag(context, + t(entry.fullCharge ? "car.charging.fullCharge" : "car.charging.partialCharge"), muted: !entry.fullCharge), - if (entry.missedSession) _tag(context, t("car.charging.gap"), warn: true), + if (entry.missedSession) _tag(context, t("car.charging.missedBefore"), warn: true), if (entry.location.isNotEmpty) _tag(context, entry.location, muted: true), ]), // Consumption exists only on a full charge that closes a computable @@ -1322,8 +1323,11 @@ class _ChargingTile extends StatelessWidget { if (entry.consumptionKwh100 != null) ...[ const SizedBox(height: 6), Text( - "${formatKwhConsumption(entry.consumptionKwh100)} · ${formatKmPerKwh(entry.kmPerKwh)}" - " over ${formatKm(entry.distanceKm)}", + t("car.charging.overDistance", params: { + "consumption": formatKwhConsumption(entry.consumptionKwh100), + "rate": formatKmPerKwh(entry.kmPerKwh), + "distance": formatKm(entry.distanceKm), + }), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ], @@ -1392,9 +1396,9 @@ class _FuelTile extends StatelessWidget { ].join(" · ")), const SizedBox(height: 6), Wrap(spacing: 6, runSpacing: 6, children: [ - _tag(context, entry.fullTank ? "Full tank" : "Partial fill", + _tag(context, t(entry.fullTank ? "car.fuel.fullTank" : "car.fuel.partialFill"), muted: !entry.fullTank), - if (entry.missedFill) _tag(context, "Missed fill before", warn: true), + if (entry.missedFill) _tag(context, t("car.fuel.missedBefore"), warn: true), if (entry.station.isNotEmpty) _tag(context, entry.station, muted: true), ]), // Consumption exists only on a full tank that closes a computable @@ -1402,8 +1406,11 @@ class _FuelTile extends StatelessWidget { if (entry.consumptionL100 != null) ...[ const SizedBox(height: 6), Text( - "${formatConsumption(entry.consumptionL100)} · ${formatKmPerLiter(entry.kmPerLiter)}" - " over ${formatKm(entry.distanceKm)}", + t("car.fuel.overDistance", params: { + "consumption": formatConsumption(entry.consumptionL100), + "rate": formatKmPerLiter(entry.kmPerLiter), + "distance": formatKm(entry.distanceKm), + }), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ], @@ -1445,16 +1452,6 @@ class _DocumentTile extends StatelessWidget { final VoidCallback? onDelete; const _DocumentTile({required this.doc, this.onEdit, this.onDelete}); - static const _typeLabels = { - "insurance": "Insurance", - "pollution": "Pollution certificate", - "registration": "Registration", - "inspection": "Inspection", - "roadTax": "Road tax", - "warranty": "Warranty", - "other": "Other", - }; - @override Widget build(BuildContext context) { return _RecordCard( @@ -1479,15 +1476,18 @@ class _DocumentTile extends StatelessWidget { _sub( context, [ - _typeLabels[doc.type] ?? doc.type, + _MaintenanceTile._label("documentType", doc.type), if (doc.provider.isNotEmpty) doc.provider, if (doc.reference.isNotEmpty) doc.reference, ].join(" · ")), const SizedBox(height: 4), _sub( context, - "Issued ${formatDate(doc.issueDate)} · Renews ${formatDate(doc.expiryDate)}" - "${doc.cost > 0 ? " · ${formatMoney(doc.cost)}" : ""}"), + t("car.documents.issuedRenews", params: { + "issued": formatDate(doc.issueDate), + "renews": formatDate(doc.expiryDate), + }) + + (doc.cost > 0 ? " · ${formatMoney(doc.cost)}" : "")), _AttachmentLine(path: "/car-documents", id: doc.id, record: doc), if (doc.notes.isNotEmpty) ...[ const SizedBox(height: 6), @@ -1511,14 +1511,6 @@ class _ReminderTile extends StatelessWidget { this.onComplete, }); - static const _typeLabels = { - "maintenance": "Maintenance", - "document": "Document renewal", - "service": "Service", - "inspection": "Inspection", - "other": "Other", - }; - @override Widget build(BuildContext context) { final r = reminder; @@ -1550,22 +1542,25 @@ class _ReminderTile extends StatelessWidget { _sub( context, [ - _typeLabels[r.type] ?? r.type, - if (r.dueDate != null) "on ${formatDate(r.dueDate)}", - if (r.dueKm > 0) "at ${formatKm(r.dueKm)}", + _MaintenanceTile._label("reminderType", r.type), + if (r.dueDate != null) + t("car.reminders.on", params: {"date": formatDate(r.dueDate)}), + if (r.dueKm > 0) t("car.reminders.at", params: {"km": formatKm(r.dueKm)}), ].join(" · ")), if (r.repeats) ...[ const SizedBox(height: 4), _sub( context, - "Repeats every ${[ - if (r.repeatDays > 0) "${r.repeatDays}d", - if (r.repeatKm > 0) formatKm(r.repeatKm), - ].join(" · ")}"), + t("car.reminders.repeatsEvery", params: { + "every": [ + if (r.repeatDays > 0) "${r.repeatDays}d", + if (r.repeatKm > 0) formatKm(r.repeatKm), + ].join(" · ") + })), ], if (r.auto) ...[ const SizedBox(height: 6), - _sub(context, "Added automatically — edit the record it came from to change it."), + _sub(context, t("car.reminders.autoHint")), ], if (r.notes.isNotEmpty) ...[ const SizedBox(height: 6), @@ -1605,7 +1600,7 @@ class _ReadOnlyNotice extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - "Shared with you (read-only). You can't make changes.", + t("car.readOnlyNotice"), style: TextStyle(color: onTint, fontSize: 13), ), ), @@ -1645,6 +1640,13 @@ class _ShareSheetState extends State<_ShareSheet> { void _reload() => setState(() => _future = apiClient.listCarShares(widget.car.id)); + /// The two permission levels, built per call because t() reads the live + /// locale — a const list would freeze the language the sheet opened in. + List> _permissionItems() => [ + DropdownMenuItem(value: "read", child: Text(t("forms.share.read"))), + DropdownMenuItem(value: "write", child: Text(t("forms.share.write"))), + ]; + Future _add() async { final email = _email.text.trim(); if (email.isEmpty) return; @@ -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( 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>( 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 _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 _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")), ), ), ), diff --git a/Phone App/lib/screens/car_form_sheet.dart b/Phone App/lib/screens/car_form_sheet.dart index 4e418ad..4271872 100644 --- a/Phone App/lib/screens/car_form_sheet.dart +++ b/Phone App/lib/screens/car_form_sheet.dart @@ -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 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 { Future _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 { 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 { 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( 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")), ), ), ], diff --git a/Phone App/lib/screens/car_view_sheet.dart b/Phone App/lib/screens/car_view_sheet.dart index fe2122c..c65792c 100644 --- a/Phone App/lib/screens/car_view_sheet.dart +++ b/Phone App/lib/screens/car_view_sheet.dart @@ -129,9 +129,14 @@ class _CarViewSheetState extends State { /// The tab rows on offer, in the car's arrangement. List 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 diff --git a/Phone App/lib/screens/record_form_sheets.dart b/Phone App/lib/screens/record_form_sheets.dart index 650a630..6027aae 100644 --- a/Phone App/lib/screens/record_form_sheets.dart +++ b/Phone App/lib/screens/record_form_sheets.dart @@ -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 { /// 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 _save() async { @@ -281,37 +281,48 @@ class _TechnicalCheckSheetState extends State { 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 { 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 { 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 { 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 { 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 { 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 { recordId: widget.entry?.id, pending: _pending, onChanged: () => setState(() {}), - legend: "Receipt", + legend: t("forms.fuel.attachmentLegend"), ), ], ); @@ -562,11 +574,11 @@ class _ChargingSheetState extends State { 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 { 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 { 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 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 keys) => + [for (final key in keys) (key, t("enums.$namespace.$key"))]; class _MaintenanceSheetState extends State { late DateTime _date; @@ -777,7 +784,7 @@ class _MaintenanceSheetState extends State { Future _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 { 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 { 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 { 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 { // --- 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 { Future _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 { 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 { @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 { // --- 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 { Future _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 { @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")), ], ); } diff --git a/Phone App/lib/widgets/attachment_field.dart b/Phone App/lib/widgets/attachment_field.dart index 7a4233a..c25e716 100644 --- a/Phone App/lib/widgets/attachment_field.dart +++ b/Phone App/lib/widgets/attachment_field.dart @@ -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 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 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 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 { 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 { 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 { IconButton( onPressed: _clearPick, icon: const Icon(Icons.close, size: 18), - tooltip: "Clear", + tooltip: t("attachment.clear"), ), ], ), @@ -187,15 +194,16 @@ class _AttachmentFieldState extends State { 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 { 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)), ], ), ), diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index c1f7697..015c543 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -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}); diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index cd7d2ff..3da7261 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -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.