Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee28b522c7
commit
b6bb6b1df0
54 files changed
+4191
-979
No files matched your search
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Gem",
|
||||
"saving": "Gemmer…",
|
||||
"saved": "Gemt ✓",
|
||||
"cancel": "Annuller",
|
||||
"remove": "Fjern",
|
||||
"retry": "Prøv igen",
|
||||
"required": "Påkrævet",
|
||||
"confirm": "Bekræft",
|
||||
"empty": "—"
|
||||
},
|
||||
|
||||
"nav": {
|
||||
"garage": "Garage",
|
||||
"settings": "Indstillinger",
|
||||
"users": "Brugere"
|
||||
},
|
||||
|
||||
"login": {
|
||||
"tagline": "Styr på din bil.",
|
||||
"email": "E-mail",
|
||||
"password": "Adgangskode",
|
||||
"showPassword": "Vis adgangskode",
|
||||
"hidePassword": "Skjul adgangskode",
|
||||
"submit": "Log ind",
|
||||
"submitting": "Logger ind…",
|
||||
"or": "eller",
|
||||
"signInWithFace": "Log ind med ansigtsgenkendelse",
|
||||
"signInWithFingerprint": "Log ind med fingeraftryk",
|
||||
"signInWithBiometrics": "Log ind med biometri",
|
||||
"enrollTitle": "Aktivér biometrisk login?",
|
||||
"enrollBody": "Næste gang kan du logge ind med {method} i stedet for at skrive din adgangskode.",
|
||||
"enrollNotNow": "Ikke nu",
|
||||
"enrollEnable": "Aktivér",
|
||||
"methodFace": "ansigtsgenkendelse",
|
||||
"methodFingerprint": "dit fingeraftryk",
|
||||
"methodBiometrics": "biometri",
|
||||
"bioReasonSignInAs": "Log ind som {email}",
|
||||
"bioReasonSignIn": "Log ind på DriverVault",
|
||||
"savedInvalid": "Det gemte login er ikke længere gyldigt. Log ind med din adgangskode.",
|
||||
"serverSettings": "Serverindstillinger",
|
||||
"apiServerUrl": "API-serverens adresse",
|
||||
"leaveBlank": "Lad feltet stå tomt for at bruge standarden.",
|
||||
"reset": "Nulstil",
|
||||
"savedNote": "Gemt ✓",
|
||||
"resetNote": "Nulstillet ✓"
|
||||
},
|
||||
|
||||
"lock": {
|
||||
"title": "DriverVault er låst",
|
||||
"reason": "Lås DriverVault op",
|
||||
"unlock": "Lås op",
|
||||
"unlocking": "Låser op…",
|
||||
"usePassword": "Brug adgangskode i stedet"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"eyebrow": "DINE BILER",
|
||||
"title": "Garage",
|
||||
"addCar": "Tilføj bil",
|
||||
"lightMode": "Lys tilstand",
|
||||
"darkMode": "Mørk tilstand",
|
||||
"logOut": "Log ud",
|
||||
"empty": "Ingen biler endnu.",
|
||||
"shared": "Delt",
|
||||
"sharedReadOnly": "Delt · skrivebeskyttet",
|
||||
"serviceLife": "SERVICEINTERVAL BRUGT",
|
||||
"lastService": "Seneste service",
|
||||
"currentOdometer": "Nuværende kilometerstand",
|
||||
"nextDue": "Næste service",
|
||||
"nextDueKm": "Næste service (km)",
|
||||
"serviceRecords": {
|
||||
"one": "{n} servicepost",
|
||||
"other": "{n} serviceposter"
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "Indstillinger",
|
||||
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"name": "Navn",
|
||||
"nameSaved": "Navn gemt.",
|
||||
"email": "E-mail",
|
||||
"verified": "Bekræftet",
|
||||
"notVerified": "Ikke bekræftet",
|
||||
"sending": "Sender…",
|
||||
"resendVerification": "Send bekræftelsesmail igen",
|
||||
"verificationRequested": "Bekræftelsesmail anmodet.",
|
||||
"changePassword": "Skift adgangskode",
|
||||
"currentPassword": "Nuværende adgangskode",
|
||||
"newPassword": "Ny adgangskode (mindst 8)",
|
||||
"confirmNewPassword": "Bekræft ny adgangskode",
|
||||
"tooShort": "Den nye adgangskode skal være på mindst 8 tegn.",
|
||||
"mismatch": "Den nye adgangskode og bekræftelsen stemmer ikke overens.",
|
||||
"passwordUpdated": "Adgangskode opdateret.",
|
||||
"updating": "Opdaterer…",
|
||||
"updatePassword": "Opdater adgangskode"
|
||||
},
|
||||
|
||||
"appearance": {
|
||||
"title": "Udseende",
|
||||
"theme": "Tema",
|
||||
"themeLight": "Lyst",
|
||||
"themeDark": "Mørkt",
|
||||
"themeSystem": "System",
|
||||
"language": "Sprog",
|
||||
"languageHint": "Appens tekst samt navne på måneder og dage.",
|
||||
"languageFallbackHint": "Dette sprog er endnu ikke oversat — appens tekst forbliver på engelsk.",
|
||||
"region": "Region",
|
||||
"regionHint": "Tal- og valutaformat.",
|
||||
"currency": "Valuta",
|
||||
"currencyHint": "Eksempel: {example}. Beløb er kun til visning — intet omregnes.",
|
||||
"dateFormat": "Datoformat",
|
||||
"dateHint": "Eksempel: {example}",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"fontSmall": "Lille",
|
||||
"fontMedium": "Mellem",
|
||||
"fontLarge": "Stor"
|
||||
},
|
||||
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"working": "Arbejder…",
|
||||
"uploadPhoto": "Upload billede",
|
||||
"remove": "Fjern",
|
||||
"bio": "Om mig",
|
||||
"bioHint": "En kort note, som andre i din husstand kan se.",
|
||||
"bioSaved": "Beskrivelse gemt.",
|
||||
"saveBio": "Gem beskrivelse"
|
||||
},
|
||||
|
||||
"security": {
|
||||
"title": "Sikkerhed",
|
||||
"checkingDevice": "Tjekker enhed…",
|
||||
"biometricSignIn": "Biometrisk login",
|
||||
"biometricAvailable": "Log ind med {method} i stedet for din adgangskode.",
|
||||
"biometricUnavailable": "Der er ikke registreret biometri på denne enhed.",
|
||||
"methodFaceOrFingerprint": "ansigt eller fingeraftryk",
|
||||
"methodFace": "ansigtsgenkendelse",
|
||||
"methodFingerprint": "fingeraftryk",
|
||||
"methodBiometrics": "biometri",
|
||||
"turnedOff": "Biometrisk login slået fra",
|
||||
"enabled": "Biometrisk login aktiveret",
|
||||
"couldNotEnable": "Kunne ikke aktivere: {error}",
|
||||
"confirmPassword": "Bekræft din adgangskode",
|
||||
"password": "Adgangskode"
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
"title": "Privatliv og sikkerhed",
|
||||
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.",
|
||||
"signOut": "Log ud"
|
||||
},
|
||||
|
||||
"danger": {
|
||||
"title": "Farezone",
|
||||
"body": "Sletning af din konto fjerner dit login og din profil. Det sletter ikke husstandens delte biler eller servicehistorik. Der er 3 dages betænkningstid, før sletningen er endelig, og du kan annullere når som helst inden da.",
|
||||
"deleteAccount": "Slet min konto",
|
||||
"typeToConfirm": "Skriv {email} for at bekræfte",
|
||||
"typeEmailPrompt": "Skriv din e-mail for at bekræfte.",
|
||||
"requesting": "Anmoder…",
|
||||
"requestDeletion": "Anmod om sletning",
|
||||
"requestedOn": "Sletning af konto anmodet den {date}. {tail}",
|
||||
"cooldownPassed": "Betænkningstiden er udløbet. Du kan nu gennemføre sletningen.",
|
||||
"canStillCancel": "Du kan stadig annullere — det bliver permanent efter 3 dages betænkningstid.",
|
||||
"cancelRequest": "Annuller anmodning om sletning",
|
||||
"permanentlyDelete": "Slet permanent",
|
||||
"finalizeTitle": "Slet konto?",
|
||||
"finalizeBody": "Dette sletter din konto permanent. Det kan ikke fortrydes.",
|
||||
"delete": "Slet"
|
||||
}
|
||||
},
|
||||
|
||||
"status": {
|
||||
"noData": "Ingen data",
|
||||
"serviceOverdueDays": "Service overskredet med {days} d",
|
||||
"dueInDays": "Forfalder om {days} d",
|
||||
"okDays": "OK · {days} d",
|
||||
"noKm": "Ingen km",
|
||||
"serviceOverdueKm": "Service overskredet med {km} km",
|
||||
"inKm": "Om {km} km",
|
||||
"kmLeft": "{km} km tilbage",
|
||||
"expiredAgo": "Udløb for {days} d siden",
|
||||
"expiresToday": "Udløber i dag",
|
||||
"renewInDays": "Forny om {days} d",
|
||||
"validDays": "Gyldig · {days} d",
|
||||
"noExpiry": "Udløber ikke",
|
||||
"done": "Færdig",
|
||||
"noTrigger": "Ingen udløser",
|
||||
"overdue": "Overskredet",
|
||||
"overdueBy": "Overskredet {parts}",
|
||||
"dueIn": "Forfalder om {parts}",
|
||||
"upcoming": "Kommende",
|
||||
"today": "i dag",
|
||||
"days": "{days} d",
|
||||
"km": "{km} km",
|
||||
"warrantyExpiredAgo": "Garanti udløb for {days} d siden",
|
||||
"warrantyEndsIn": "Garanti slutter om {days} d",
|
||||
"underWarranty": "Under garanti · {days} d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved ✓",
|
||||
"cancel": "Cancel",
|
||||
"remove": "Remove",
|
||||
"retry": "Retry",
|
||||
"required": "Required",
|
||||
"confirm": "Confirm",
|
||||
"empty": "—"
|
||||
},
|
||||
|
||||
"nav": {
|
||||
"garage": "Garage",
|
||||
"settings": "Settings",
|
||||
"users": "Users"
|
||||
},
|
||||
|
||||
"login": {
|
||||
"tagline": "Your car, on track.",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"or": "or",
|
||||
"signInWithFace": "Sign in with face recognition",
|
||||
"signInWithFingerprint": "Sign in with fingerprint",
|
||||
"signInWithBiometrics": "Sign in with biometrics",
|
||||
"enrollTitle": "Enable biometric sign-in?",
|
||||
"enrollBody": "Next time you can sign in with {method} instead of typing your password.",
|
||||
"enrollNotNow": "Not now",
|
||||
"enrollEnable": "Enable",
|
||||
"methodFace": "face recognition",
|
||||
"methodFingerprint": "your fingerprint",
|
||||
"methodBiometrics": "biometrics",
|
||||
"bioReasonSignInAs": "Sign in as {email}",
|
||||
"bioReasonSignIn": "Sign in to DriverVault",
|
||||
"savedInvalid": "Saved sign-in is no longer valid. Please sign in with your password.",
|
||||
"serverSettings": "Server settings",
|
||||
"apiServerUrl": "API server URL",
|
||||
"leaveBlank": "Leave blank to use the default.",
|
||||
"reset": "Reset",
|
||||
"savedNote": "Saved ✓",
|
||||
"resetNote": "Reset ✓"
|
||||
},
|
||||
|
||||
"lock": {
|
||||
"title": "DriverVault is locked",
|
||||
"reason": "Unlock DriverVault",
|
||||
"unlock": "Unlock",
|
||||
"unlocking": "Unlocking…",
|
||||
"usePassword": "Use password instead"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"eyebrow": "YOUR CARS",
|
||||
"title": "Garage",
|
||||
"addCar": "Add car",
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode",
|
||||
"logOut": "Log out",
|
||||
"empty": "No cars yet.",
|
||||
"shared": "Shared",
|
||||
"sharedReadOnly": "Shared · read-only",
|
||||
"serviceLife": "SERVICE LIFE",
|
||||
"lastService": "Last service",
|
||||
"currentOdometer": "Current odometer",
|
||||
"nextDue": "Next due",
|
||||
"nextDueKm": "Next due km",
|
||||
"serviceRecords": {
|
||||
"one": "{n} service record",
|
||||
"other": "{n} service records"
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"name": "Name",
|
||||
"nameSaved": "Name saved.",
|
||||
"email": "Email",
|
||||
"verified": "Verified",
|
||||
"notVerified": "Not verified",
|
||||
"sending": "Sending…",
|
||||
"resendVerification": "Resend verification email",
|
||||
"verificationRequested": "Verification email requested.",
|
||||
"changePassword": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password (min 8)",
|
||||
"confirmNewPassword": "Confirm new password",
|
||||
"tooShort": "New password must be at least 8 characters.",
|
||||
"mismatch": "New password and confirmation don't match.",
|
||||
"passwordUpdated": "Password updated.",
|
||||
"updating": "Updating…",
|
||||
"updatePassword": "Update password"
|
||||
},
|
||||
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"language": "Language",
|
||||
"languageHint": "App text, and the names of months and days.",
|
||||
"languageFallbackHint": "This language isn't translated yet — the app text stays in English.",
|
||||
"region": "Region",
|
||||
"regionHint": "Number and currency layout.",
|
||||
"currency": "Currency",
|
||||
"currencyHint": "Example: {example}. Amounts are display-only — nothing is converted.",
|
||||
"dateFormat": "Date format",
|
||||
"dateHint": "Example: {example}",
|
||||
"fontSize": "Font size",
|
||||
"fontSmall": "Small",
|
||||
"fontMedium": "Medium",
|
||||
"fontLarge": "Large"
|
||||
},
|
||||
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"working": "Working…",
|
||||
"uploadPhoto": "Upload photo",
|
||||
"remove": "Remove",
|
||||
"bio": "Bio",
|
||||
"bioHint": "A short note visible to other people in your household.",
|
||||
"bioSaved": "Bio saved.",
|
||||
"saveBio": "Save bio"
|
||||
},
|
||||
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"checkingDevice": "Checking device…",
|
||||
"biometricSignIn": "Biometric sign-in",
|
||||
"biometricAvailable": "Sign in with {method} instead of your password.",
|
||||
"biometricUnavailable": "No biometrics are enrolled on this device.",
|
||||
"methodFaceOrFingerprint": "face or fingerprint",
|
||||
"methodFace": "face recognition",
|
||||
"methodFingerprint": "fingerprint",
|
||||
"methodBiometrics": "biometrics",
|
||||
"turnedOff": "Biometric sign-in turned off",
|
||||
"enabled": "Biometric sign-in enabled",
|
||||
"couldNotEnable": "Could not enable: {error}",
|
||||
"confirmPassword": "Confirm your password",
|
||||
"password": "Password"
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
"title": "Privacy & security",
|
||||
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out ends this device's session only. To lock out every device, change your password above.",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
|
||||
"danger": {
|
||||
"title": "Danger zone",
|
||||
"body": "Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
|
||||
"deleteAccount": "Delete my account",
|
||||
"typeToConfirm": "Type {email} to confirm",
|
||||
"typeEmailPrompt": "Type your email to confirm.",
|
||||
"requesting": "Requesting…",
|
||||
"requestDeletion": "Request deletion",
|
||||
"requestedOn": "Account deletion requested on {date}. {tail}",
|
||||
"cooldownPassed": "The cooldown has passed. You can now finalize the deletion.",
|
||||
"canStillCancel": "You can still cancel — it becomes permanent after the 3-day cooldown.",
|
||||
"cancelRequest": "Cancel deletion request",
|
||||
"permanentlyDelete": "Permanently delete",
|
||||
"finalizeTitle": "Delete account?",
|
||||
"finalizeBody": "This permanently deletes your account. This cannot be undone.",
|
||||
"delete": "Delete"
|
||||
}
|
||||
},
|
||||
|
||||
"status": {
|
||||
"noData": "No data",
|
||||
"serviceOverdueDays": "Service Overdue {days}d",
|
||||
"dueInDays": "Due in {days}d",
|
||||
"okDays": "OK · {days}d",
|
||||
"noKm": "No km",
|
||||
"serviceOverdueKm": "Service Overdue {km} km",
|
||||
"inKm": "In {km} km",
|
||||
"kmLeft": "{km} km left",
|
||||
"expiredAgo": "Expired {days}d ago",
|
||||
"expiresToday": "Expires today",
|
||||
"renewInDays": "Renew in {days}d",
|
||||
"validDays": "Valid · {days}d",
|
||||
"noExpiry": "No expiry",
|
||||
"done": "Done",
|
||||
"noTrigger": "No trigger",
|
||||
"overdue": "Overdue",
|
||||
"overdueBy": "Overdue {parts}",
|
||||
"dueIn": "Due in {parts}",
|
||||
"upcoming": "Upcoming",
|
||||
"today": "today",
|
||||
"days": "{days}d",
|
||||
"km": "{km} km",
|
||||
"warrantyExpiredAgo": "Warranty expired {days}d ago",
|
||||
"warrantyEndsIn": "Warranty ends in {days}d",
|
||||
"underWarranty": "Under warranty · {days}d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"saved": "Zapisano ✓",
|
||||
"cancel": "Anuluj",
|
||||
"remove": "Usuń",
|
||||
"retry": "Ponów",
|
||||
"required": "Wymagane",
|
||||
"confirm": "Potwierdź",
|
||||
"empty": "—"
|
||||
},
|
||||
|
||||
"nav": {
|
||||
"garage": "Garaż",
|
||||
"settings": "Ustawienia",
|
||||
"users": "Użytkownicy"
|
||||
},
|
||||
|
||||
"login": {
|
||||
"tagline": "Twój samochód pod kontrolą.",
|
||||
"email": "E-mail",
|
||||
"password": "Hasło",
|
||||
"showPassword": "Pokaż hasło",
|
||||
"hidePassword": "Ukryj hasło",
|
||||
"submit": "Zaloguj się",
|
||||
"submitting": "Logowanie…",
|
||||
"or": "lub",
|
||||
"signInWithFace": "Zaloguj się rozpoznawaniem twarzy",
|
||||
"signInWithFingerprint": "Zaloguj się odciskiem palca",
|
||||
"signInWithBiometrics": "Zaloguj się biometrią",
|
||||
"enrollTitle": "Włączyć logowanie biometryczne?",
|
||||
"enrollBody": "Następnym razem możesz zalogować się za pomocą {method} zamiast wpisywać hasło.",
|
||||
"enrollNotNow": "Nie teraz",
|
||||
"enrollEnable": "Włącz",
|
||||
"methodFace": "rozpoznawania twarzy",
|
||||
"methodFingerprint": "odcisku palca",
|
||||
"methodBiometrics": "biometrii",
|
||||
"bioReasonSignInAs": "Zaloguj się jako {email}",
|
||||
"bioReasonSignIn": "Zaloguj się do DriverVault",
|
||||
"savedInvalid": "Zapisane dane logowania są już nieważne. Zaloguj się hasłem.",
|
||||
"serverSettings": "Ustawienia serwera",
|
||||
"apiServerUrl": "Adres serwera API",
|
||||
"leaveBlank": "Pozostaw puste, aby użyć domyślnego.",
|
||||
"reset": "Resetuj",
|
||||
"savedNote": "Zapisano ✓",
|
||||
"resetNote": "Zresetowano ✓"
|
||||
},
|
||||
|
||||
"lock": {
|
||||
"title": "DriverVault jest zablokowany",
|
||||
"reason": "Odblokuj DriverVault",
|
||||
"unlock": "Odblokuj",
|
||||
"unlocking": "Odblokowywanie…",
|
||||
"usePassword": "Użyj hasła"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"eyebrow": "TWOJE SAMOCHODY",
|
||||
"title": "Garaż",
|
||||
"addCar": "Dodaj samochód",
|
||||
"lightMode": "Tryb jasny",
|
||||
"darkMode": "Tryb ciemny",
|
||||
"logOut": "Wyloguj się",
|
||||
"empty": "Nie masz jeszcze samochodów.",
|
||||
"shared": "Udostępniony",
|
||||
"sharedReadOnly": "Udostępniony · tylko do odczytu",
|
||||
"serviceLife": "ZUŻYCIE OKRESU SERWISOWEGO",
|
||||
"lastService": "Ostatni serwis",
|
||||
"currentOdometer": "Aktualny przebieg",
|
||||
"nextDue": "Następny termin",
|
||||
"nextDueKm": "Następny przebieg",
|
||||
"serviceRecords": {
|
||||
"one": "{n} wpis serwisowy",
|
||||
"few": "{n} wpisy serwisowe",
|
||||
"many": "{n} wpisów serwisowych",
|
||||
"other": "{n} wpisu serwisowego"
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"name": "Imię i nazwisko",
|
||||
"nameSaved": "Zapisano imię i nazwisko.",
|
||||
"email": "E-mail",
|
||||
"verified": "Zweryfikowany",
|
||||
"notVerified": "Niezweryfikowany",
|
||||
"sending": "Wysyłanie…",
|
||||
"resendVerification": "Wyślij ponownie e-mail weryfikacyjny",
|
||||
"verificationRequested": "Zamówiono e-mail weryfikacyjny.",
|
||||
"changePassword": "Zmień hasło",
|
||||
"currentPassword": "Obecne hasło",
|
||||
"newPassword": "Nowe hasło (min. 8)",
|
||||
"confirmNewPassword": "Potwierdź nowe hasło",
|
||||
"tooShort": "Nowe hasło musi mieć co najmniej 8 znaków.",
|
||||
"mismatch": "Nowe hasło i potwierdzenie nie są takie same.",
|
||||
"passwordUpdated": "Hasło zaktualizowane.",
|
||||
"updating": "Aktualizowanie…",
|
||||
"updatePassword": "Zaktualizuj hasło"
|
||||
},
|
||||
|
||||
"appearance": {
|
||||
"title": "Wygląd",
|
||||
"theme": "Motyw",
|
||||
"themeLight": "Jasny",
|
||||
"themeDark": "Ciemny",
|
||||
"themeSystem": "Systemowy",
|
||||
"language": "Język",
|
||||
"languageHint": "Tekst aplikacji oraz nazwy miesięcy i dni.",
|
||||
"languageFallbackHint": "Ten język nie jest jeszcze przetłumaczony — tekst aplikacji pozostanie po angielsku.",
|
||||
"region": "Region",
|
||||
"regionHint": "Format liczb i waluty.",
|
||||
"currency": "Waluta",
|
||||
"currencyHint": "Przykład: {example}. Kwoty są tylko wyświetlane — nic nie jest przeliczane.",
|
||||
"dateFormat": "Format daty",
|
||||
"dateHint": "Przykład: {example}",
|
||||
"fontSize": "Rozmiar czcionki",
|
||||
"fontSmall": "Mała",
|
||||
"fontMedium": "Średnia",
|
||||
"fontLarge": "Duża"
|
||||
},
|
||||
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"working": "Przetwarzanie…",
|
||||
"uploadPhoto": "Prześlij zdjęcie",
|
||||
"remove": "Usuń",
|
||||
"bio": "O mnie",
|
||||
"bioHint": "Krótka notatka widoczna dla innych osób w Twoim gospodarstwie domowym.",
|
||||
"bioSaved": "Zapisano opis.",
|
||||
"saveBio": "Zapisz opis"
|
||||
},
|
||||
|
||||
"security": {
|
||||
"title": "Bezpieczeństwo",
|
||||
"checkingDevice": "Sprawdzanie urządzenia…",
|
||||
"biometricSignIn": "Logowanie biometryczne",
|
||||
"biometricAvailable": "Zaloguj się za pomocą {method} zamiast hasła.",
|
||||
"biometricUnavailable": "Na tym urządzeniu nie zarejestrowano biometrii.",
|
||||
"methodFaceOrFingerprint": "twarzy lub odcisku palca",
|
||||
"methodFace": "rozpoznawania twarzy",
|
||||
"methodFingerprint": "odcisku palca",
|
||||
"methodBiometrics": "biometrii",
|
||||
"turnedOff": "Logowanie biometryczne wyłączone",
|
||||
"enabled": "Logowanie biometryczne włączone",
|
||||
"couldNotEnable": "Nie udało się włączyć: {error}",
|
||||
"confirmPassword": "Potwierdź hasło",
|
||||
"password": "Hasło"
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
"title": "Prywatność i bezpieczeństwo",
|
||||
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.",
|
||||
"signOut": "Wyloguj się"
|
||||
},
|
||||
|
||||
"danger": {
|
||||
"title": "Strefa niebezpieczna",
|
||||
"body": "Usunięcie konta usuwa Twój login i profil. Nie usuwa samochodów ani historii serwisowej współdzielonych w gospodarstwie domowym. Obowiązuje 3-dniowy okres karencji, zanim usunięcie stanie się ostateczne — do tego czasu możesz je anulować.",
|
||||
"deleteAccount": "Usuń moje konto",
|
||||
"typeToConfirm": "Wpisz {email}, aby potwierdzić",
|
||||
"typeEmailPrompt": "Wpisz swój e-mail, aby potwierdzić.",
|
||||
"requesting": "Wysyłanie żądania…",
|
||||
"requestDeletion": "Zażądaj usunięcia",
|
||||
"requestedOn": "Żądanie usunięcia konta złożono {date}. {tail}",
|
||||
"cooldownPassed": "Okres karencji minął. Możesz teraz dokończyć usuwanie.",
|
||||
"canStillCancel": "Nadal możesz je anulować — stanie się ostateczne po 3-dniowym okresie karencji.",
|
||||
"cancelRequest": "Anuluj żądanie usunięcia",
|
||||
"permanentlyDelete": "Usuń trwale",
|
||||
"finalizeTitle": "Usunąć konto?",
|
||||
"finalizeBody": "To trwale usunie Twoje konto. Tej operacji nie można cofnąć.",
|
||||
"delete": "Usuń"
|
||||
}
|
||||
},
|
||||
|
||||
"status": {
|
||||
"noData": "Brak danych",
|
||||
"serviceOverdueDays": "Serwis zaległy {days} dni",
|
||||
"dueInDays": "Termin za {days} dni",
|
||||
"okDays": "OK · {days} dni",
|
||||
"noKm": "Brak przebiegu",
|
||||
"serviceOverdueKm": "Serwis zaległy {km} km",
|
||||
"inKm": "Za {km} km",
|
||||
"kmLeft": "Pozostało {km} km",
|
||||
"expiredAgo": "Wygasło {days} dni temu",
|
||||
"expiresToday": "Wygasa dzisiaj",
|
||||
"renewInDays": "Odnowienie za {days} dni",
|
||||
"validDays": "Ważne · {days} dni",
|
||||
"noExpiry": "Bezterminowe",
|
||||
"done": "Gotowe",
|
||||
"noTrigger": "Brak wyzwalacza",
|
||||
"overdue": "Zaległe",
|
||||
"overdueBy": "Zaległe {parts}",
|
||||
"dueIn": "Termin za {parts}",
|
||||
"upcoming": "Nadchodzące",
|
||||
"today": "dzisiaj",
|
||||
"days": "{days} dni",
|
||||
"km": "{km} km",
|
||||
"warrantyExpiredAgo": "Gwarancja wygasła {days} dni temu",
|
||||
"warrantyEndsIn": "Gwarancja kończy się za {days} dni",
|
||||
"underWarranty": "Na gwarancji · {days} dni"
|
||||
}
|
||||
}
|
||||
+24
-23
@@ -1,6 +1,7 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
import "i18n.dart";
|
||||
import "main.dart";
|
||||
import "models.dart";
|
||||
import "theme.dart";
|
||||
@@ -97,22 +98,22 @@ int _rank(StatusKey k) => switch (k) {
|
||||
};
|
||||
|
||||
Status _dateSignal(DateTime? nextDate) {
|
||||
if (nextDate == null) return const Status(StatusKey.unknown, "No data");
|
||||
if (nextDate == null) return Status(StatusKey.unknown, t("status.noData"));
|
||||
final today = DateTime.now();
|
||||
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
|
||||
.difference(DateTime(today.year, today.month, today.day))
|
||||
.inDays;
|
||||
if (days < 0) return Status(StatusKey.overdue, "Service Overdue ${days.abs()}d");
|
||||
if (days <= 30) return Status(StatusKey.soon, "Due in ${days}d");
|
||||
return Status(StatusKey.ok, "OK · ${days}d");
|
||||
if (days < 0) return Status(StatusKey.overdue, t("status.serviceOverdueDays", params: {"days": days.abs()}));
|
||||
if (days <= 30) return Status(StatusKey.soon, t("status.dueInDays", params: {"days": days}));
|
||||
return Status(StatusKey.ok, t("status.okDays", params: {"days": days}));
|
||||
}
|
||||
|
||||
Status _kmSignal(int currentKm, int? nextKm) {
|
||||
if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km");
|
||||
if (currentKm == 0 || nextKm == null) return Status(StatusKey.unknown, t("status.noKm"));
|
||||
final remaining = nextKm - currentKm;
|
||||
if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_num(remaining.abs())} km");
|
||||
if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_num(remaining)} km");
|
||||
return Status(StatusKey.ok, "${_num(remaining)} km left");
|
||||
if (remaining < 0) return Status(StatusKey.overdue, t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}));
|
||||
if (remaining <= _kmSoon) return Status(StatusKey.soon, t("status.inKm", params: {"km": _num(remaining)}));
|
||||
return Status(StatusKey.ok, t("status.kmLeft", params: {"km": _num(remaining)}));
|
||||
}
|
||||
|
||||
/// Maps the server's expiry/reminder state names onto the badge palette. The
|
||||
@@ -129,10 +130,10 @@ StatusKey _expiryKey(String state) => switch (state) {
|
||||
Status expiryStatus(ExpiryAssessment e) {
|
||||
final days = e.days;
|
||||
final label = switch (e.state) {
|
||||
"expired" => "Expired ${(days ?? 0).abs()}d ago",
|
||||
"expiring_soon" => days == 0 ? "Expires today" : "Renew in ${days}d",
|
||||
"valid" => "Valid · ${days}d",
|
||||
_ => "No expiry",
|
||||
"expired" => t("status.expiredAgo", params: {"days": (days ?? 0).abs()}),
|
||||
"expiring_soon" => days == 0 ? t("status.expiresToday") : t("status.renewInDays", params: {"days": days}),
|
||||
"valid" => t("status.validDays", params: {"days": days}),
|
||||
_ => t("status.noExpiry"),
|
||||
};
|
||||
return Status(_expiryKey(e.state), label);
|
||||
}
|
||||
@@ -150,19 +151,19 @@ Status reminderStatus(Reminder r) {
|
||||
|
||||
final days = r.daysLeft;
|
||||
final km = r.kmLeft;
|
||||
if (r.status == "done") return const Status(StatusKey.unknown, "Done");
|
||||
if (r.status == "no_trigger") return const Status(StatusKey.unknown, "No trigger");
|
||||
if (r.status == "done") return Status(StatusKey.unknown, t("status.done"));
|
||||
if (r.status == "no_trigger") return Status(StatusKey.unknown, t("status.noTrigger"));
|
||||
|
||||
final parts = <String>[];
|
||||
if (r.status == "overdue") {
|
||||
if (days != null && days < 0) parts.add("${days.abs()}d");
|
||||
if (km != null && km < 0) parts.add("${_num(km.abs())} km");
|
||||
return Status(key, parts.isEmpty ? "Overdue" : "Overdue ${parts.join(" · ")}");
|
||||
if (days != null && days < 0) parts.add(t("status.days", params: {"days": days.abs()}));
|
||||
if (km != null && km < 0) parts.add(t("status.km", params: {"km": _num(km.abs())}));
|
||||
return Status(key, parts.isEmpty ? t("status.overdue") : t("status.overdueBy", params: {"parts": parts.join(" · ")}));
|
||||
}
|
||||
|
||||
if (days != null && days >= 0) parts.add(days == 0 ? "today" : "${days}d");
|
||||
if (km != null && km >= 0) parts.add("${_num(km)} km");
|
||||
return Status(key, parts.isEmpty ? "Upcoming" : "Due in ${parts.join(" · ")}");
|
||||
if (days != null && days >= 0) parts.add(days == 0 ? t("status.today") : t("status.days", params: {"days": days}));
|
||||
if (km != null && km >= 0) parts.add(t("status.km", params: {"km": _num(km)}));
|
||||
return Status(key, parts.isEmpty ? t("status.upcoming") : t("status.dueIn", params: {"parts": parts.join(" · ")}));
|
||||
}
|
||||
|
||||
/// Warranty badge for a maintenance entry. Unlike the others this one has no
|
||||
@@ -171,9 +172,9 @@ Status reminderStatus(Reminder r) {
|
||||
Status? warrantyStatus(MaintenanceEntry m) {
|
||||
final days = m.warrantyDaysLeft;
|
||||
if (m.warrantyActive == null || days == null) return null;
|
||||
if (!m.warrantyActive!) return Status(StatusKey.unknown, "Warranty expired ${days.abs()}d ago");
|
||||
if (days <= 30) return Status(StatusKey.soon, "Warranty ends in ${days}d");
|
||||
return Status(StatusKey.ok, "Under warranty · ${days}d");
|
||||
if (!m.warrantyActive!) return Status(StatusKey.unknown, t("status.warrantyExpiredAgo", params: {"days": days.abs()}));
|
||||
if (days <= 30) return Status(StatusKey.soon, t("status.warrantyEndsIn", params: {"days": days}));
|
||||
return Status(StatusKey.ok, t("status.underWarranty", params: {"days": days}));
|
||||
}
|
||||
|
||||
/// Combines the date- and km-based signals, returning the worse of the two —
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:flutter/services.dart" show rootBundle;
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
import "main.dart" show appSettings;
|
||||
|
||||
/// UI translation for the phone app. Every user-visible string lives in a
|
||||
/// per-language JSON file under assets/i18n/; nothing hardcodes English.
|
||||
///
|
||||
/// The language is the language half of the signed-in user's stored BCP-47
|
||||
/// locale (appSettings.locale), the same value that already drives dates and
|
||||
/// numbers in format.dart — so the Settings language picker steers both. Because
|
||||
/// MaterialApp is wrapped in a ListenableBuilder on appSettings, changing the
|
||||
/// locale rebuilds the whole tree and every t() re-resolves.
|
||||
///
|
||||
/// Deliberately hand-rolled rather than gen_l10n/ARB: the app already reads a
|
||||
/// runtime locale from the profile (not the device locale Flutter's own
|
||||
/// localization is built around), and "a JSON file per language the app reads
|
||||
/// from" is exactly the shape asked for.
|
||||
|
||||
const String _base = "en";
|
||||
|
||||
/// Languages that ship a real translation file. The Settings picker offers every
|
||||
/// European language for date/number formatting; only these change the UI text,
|
||||
/// and the rest fall back to English.
|
||||
const List<String> translatedLanguages = ["en", "pl", "da"];
|
||||
|
||||
final Map<String, Map<String, dynamic>> _messages = {};
|
||||
|
||||
/// Loads every bundled language file into memory. Call once in main() before
|
||||
/// runApp, alongside initializeDateFormatting — the files are small assets and
|
||||
/// t() is synchronous thereafter.
|
||||
Future<void> loadTranslations() async {
|
||||
for (final code in translatedLanguages) {
|
||||
final raw = await rootBundle.loadString("assets/i18n/$code.json");
|
||||
_messages[code] = json.decode(raw) as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
String get _lang {
|
||||
final lang = appSettings.locale.split("-").first;
|
||||
return _messages.containsKey(lang) ? lang : _base;
|
||||
}
|
||||
|
||||
/// True when the active language has its own file (vs. falling back to English).
|
||||
bool get languageTranslated =>
|
||||
_messages.containsKey(appSettings.locale.split("-").first);
|
||||
|
||||
dynamic _lookup(Map<String, dynamic>? dict, String key) {
|
||||
dynamic node = dict;
|
||||
for (final part in key.split(".")) {
|
||||
if (node is! Map) return null;
|
||||
node = node[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
String _interpolate(String template, Map<String, Object?>? params) {
|
||||
if (params == null) return template;
|
||||
return template.replaceAllMapped(RegExp(r"\{(\w+)\}"), (m) {
|
||||
final v = params[m.group(1)];
|
||||
return v == null ? m.group(0)! : v.toString();
|
||||
});
|
||||
}
|
||||
|
||||
/// Translate [key] for the active language.
|
||||
///
|
||||
/// Pass [n] to select a plural form (a JSON object keyed by CLDR category —
|
||||
/// one/few/many/other); Intl.plural picks the right one for the language, which
|
||||
/// is what makes Polish read correctly rather than an English-style n==1 split.
|
||||
/// Any [params] interpolate `{name}` placeholders. A key missing from the active
|
||||
/// language falls back to English; missing from English too, the key is returned
|
||||
/// so the gap is visible rather than blank.
|
||||
String t(String key, {Map<String, Object?>? params, int? n}) {
|
||||
final lang = _lang;
|
||||
dynamic entry = _lookup(_messages[lang], key);
|
||||
entry ??= _lookup(_messages[_base], key);
|
||||
if (entry == null) return key;
|
||||
|
||||
if (entry is Map) {
|
||||
if (n == null) return key;
|
||||
final forms = entry;
|
||||
entry = Intl.plural(
|
||||
n,
|
||||
one: forms["one"] as String?,
|
||||
few: forms["few"] as String?,
|
||||
many: forms["many"] as String?,
|
||||
other: (forms["other"] ?? forms["one"]) as String? ?? key,
|
||||
locale: lang,
|
||||
);
|
||||
}
|
||||
if (entry is! String) return key;
|
||||
|
||||
final merged = <String, Object?>{if (n != null) "n": n, ...?params};
|
||||
return _interpolate(entry, merged.isEmpty ? null : merged);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import "api.dart";
|
||||
import "app_settings.dart";
|
||||
import "auth.dart";
|
||||
import "biometric.dart";
|
||||
import "i18n.dart";
|
||||
import "theme.dart";
|
||||
import "screens/lock_screen.dart";
|
||||
import "screens/login_screen.dart";
|
||||
@@ -21,6 +22,8 @@ Future<void> main() async {
|
||||
// locale's date symbols have to be loaded before the first formatDate call —
|
||||
// DateFormat throws on an uninitialized locale rather than falling back.
|
||||
await initializeDateFormatting();
|
||||
// Load the UI translation files before the first frame so t() is ready.
|
||||
await loadTranslations();
|
||||
await apiClient.loadServerUrl();
|
||||
await appSettings.loadFromStorage();
|
||||
await authService.loadFromStorage();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
@@ -73,7 +74,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addCar,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add car"),
|
||||
label: Text(t("dashboard.addCar")),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
@@ -88,25 +89,25 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("YOUR CARS",
|
||||
Text(t("dashboard.eyebrow"),
|
||||
style: DriverVault.mono(context,
|
||||
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||||
.copyWith(letterSpacing: 2.2)),
|
||||
const SizedBox(height: 2),
|
||||
const Text("Garage",
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||||
Text(t("dashboard.title"),
|
||||
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_HeaderButton(
|
||||
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
tooltip: dark ? "Light mode" : "Dark mode",
|
||||
tooltip: dark ? t("dashboard.lightMode") : t("dashboard.darkMode"),
|
||||
onTap: _toggleTheme,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderButton(
|
||||
icon: Icons.logout,
|
||||
tooltip: "Log out",
|
||||
tooltip: t("dashboard.logOut"),
|
||||
onTap: () => authService.logout(),
|
||||
),
|
||||
],
|
||||
@@ -126,9 +127,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
}
|
||||
final rows = snap.data ?? [];
|
||||
if (rows.isEmpty) {
|
||||
return ListView(children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text("No cars yet.")),
|
||||
return ListView(children: [
|
||||
const SizedBox(height: 80),
|
||||
Center(child: Text(t("dashboard.empty"))),
|
||||
]);
|
||||
}
|
||||
return ListView.builder(
|
||||
@@ -230,12 +231,12 @@ class _CarCard extends StatelessWidget {
|
||||
],
|
||||
..._serviceLife(context, status),
|
||||
const SizedBox(height: 12),
|
||||
_kv(context, "Last service", formatDate(row.latest?.date)),
|
||||
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
|
||||
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
|
||||
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
|
||||
_kv(context, t("dashboard.lastService"), formatDate(row.latest?.date)),
|
||||
_kv(context, t("dashboard.currentOdometer"), formatKm(row.car.currentKm)),
|
||||
_kv(context, t("dashboard.nextDue"), formatDate(row.latest?.nextServiceDate)),
|
||||
_kv(context, t("dashboard.nextDueKm"), formatKm(row.latest?.nextServiceKm)),
|
||||
const SizedBox(height: 6),
|
||||
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
|
||||
Text(t("dashboard.serviceRecords", n: row.count),
|
||||
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
|
||||
],
|
||||
),
|
||||
@@ -271,7 +272,7 @@ class _CarCard extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("SERVICE LIFE",
|
||||
Text(t("dashboard.serviceLife"),
|
||||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
|
||||
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
|
||||
Text("$pct%",
|
||||
@@ -305,7 +306,7 @@ class _SharedChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
readOnly ? "Shared · read-only" : "Shared",
|
||||
readOnly ? t("dashboard.sharedReadOnly") : t("dashboard.shared"),
|
||||
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
@@ -344,7 +345,7 @@ class _ErrorView extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: onRetry, child: const Text("Retry")),
|
||||
FilledButton(onPressed: onRetry, child: Text(t("common.retry"))),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../biometric.dart";
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
@@ -32,7 +33,7 @@ class _LockScreenState extends State<LockScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(reason: "Unlock DriverVault");
|
||||
final creds = await biometricAuth.unlock(reason: t("lock.reason"));
|
||||
if (creds == null) {
|
||||
// Cancelled — leave locked; the buttons let them retry or use password.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
@@ -70,8 +71,8 @@ class _LockScreenState extends State<LockScreen> {
|
||||
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("DriverVault is locked",
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
|
||||
Text(t("lock.title"),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
|
||||
if (name != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
@@ -92,14 +93,14 @@ class _LockScreenState extends State<LockScreen> {
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Text(_busy ? "Unlocking…" : "Unlock"),
|
||||
child: Text(_busy ? t("lock.unlocking") : t("lock.unlock")),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => authService.logout(),
|
||||
child: const Text("Use password instead"),
|
||||
child: Text(t("lock.usePassword")),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import "package:flutter/material.dart";
|
||||
import "../api.dart";
|
||||
import "../biometric.dart";
|
||||
import "../config.dart";
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
@@ -73,7 +74,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.text = current;
|
||||
_serverSaved = "Saved ✓";
|
||||
_serverSaved = t("login.savedNote");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server.clear();
|
||||
_serverSaved = "Reset ✓";
|
||||
_serverSaved = t("login.resetNote");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,19 +116,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
|
||||
if (!_bioAvailable || _bioEnabled || !mounted) return;
|
||||
final method = _hasFace && !_hasFingerprint
|
||||
? "face recognition"
|
||||
? t("login.methodFace")
|
||||
: _hasFingerprint && !_hasFace
|
||||
? "your fingerprint"
|
||||
: "biometrics";
|
||||
? t("login.methodFingerprint")
|
||||
: t("login.methodBiometrics");
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Enable biometric sign-in?"),
|
||||
content: Text(
|
||||
"Next time you can sign in with $method instead of typing your password."),
|
||||
title: Text(t("login.enrollTitle")),
|
||||
content: Text(t("login.enrollBody", params: {"method": method})),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("login.enrollNotNow"))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("login.enrollEnable"))),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -143,7 +143,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
try {
|
||||
final creds = await biometricAuth.unlock(
|
||||
reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
|
||||
reason: _bioEmail != null
|
||||
? t("login.bioReasonSignInAs", params: {"email": _bioEmail})
|
||||
: t("login.bioReasonSignIn"),
|
||||
);
|
||||
if (creds == null) {
|
||||
// User cancelled the prompt, or nothing is stored.
|
||||
@@ -164,7 +166,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bioEnabled = false;
|
||||
_error = "Saved sign-in is no longer valid. Please sign in with your password.";
|
||||
_error = t("login.savedInvalid");
|
||||
});
|
||||
}
|
||||
} else if (mounted) {
|
||||
@@ -198,22 +200,22 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition"));
|
||||
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
|
||||
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
|
||||
if (_hasFace) buttons.add(bioButton(Icons.face, t("login.signInWithFace")));
|
||||
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, t("login.signInWithFingerprint")));
|
||||
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, t("login.signInWithBiometrics")));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider()),
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(t("login.or"), style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -235,7 +237,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
children: [
|
||||
const DriverVaultLogo(markSize: 40, fontSize: 30),
|
||||
const SizedBox(height: 10),
|
||||
Text("Your car, on track.",
|
||||
Text(t("login.tagline"),
|
||||
style: TextStyle(color: DriverVault.muted(context))),
|
||||
const SizedBox(height: 24),
|
||||
Form(
|
||||
@@ -256,23 +258,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
TextFormField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
|
||||
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _password,
|
||||
obscureText: !_showPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
labelText: t("login.password"),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||
tooltip: _showPassword ? "Hide password" : "Show password",
|
||||
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
|
||||
onPressed: () => setState(() => _showPassword = !_showPassword),
|
||||
),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
|
||||
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -282,7 +284,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(_loading ? "Signing in…" : "Sign in"),
|
||||
child: Text(_loading ? t("login.submitting") : t("login.submit")),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -338,8 +340,8 @@ class _ServerSettingsState extends State<_ServerSettings> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("Server settings",
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||
Text(t("login.serverSettings"),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
@@ -350,24 +352,24 @@ class _ServerSettingsState extends State<_ServerSettings> {
|
||||
controller: widget.controller,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "API server URL",
|
||||
decoration: InputDecoration(
|
||||
labelText: t("login.apiServerUrl"),
|
||||
hintText: kDefaultApiBase,
|
||||
border: OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text("Leave blank to use the default.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(t("login.leaveBlank"),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
|
||||
OutlinedButton(onPressed: widget.onSave, child: Text(t("common.save"))),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
|
||||
TextButton(onPressed: widget.onReset, child: Text(t("login.reset"))),
|
||||
if (widget.savedNote != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "dashboard_screen.dart";
|
||||
import "settings_screen.dart";
|
||||
@@ -28,21 +29,21 @@ class _RootShellState extends State<RootShell> {
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.grid_view_outlined),
|
||||
selectedIcon: Icon(Icons.grid_view_rounded),
|
||||
label: "Garage",
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.grid_view_outlined),
|
||||
selectedIcon: const Icon(Icons.grid_view_rounded),
|
||||
label: t("nav.garage"),
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: "Settings",
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
selectedIcon: const Icon(Icons.settings),
|
||||
label: t("nav.settings"),
|
||||
),
|
||||
if (isAdmin)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.group_outlined),
|
||||
selectedIcon: Icon(Icons.group),
|
||||
label: "Users",
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
selectedIcon: const Icon(Icons.group),
|
||||
label: t("nav.users"),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
|
||||
import "../biometric.dart";
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
@@ -72,7 +73,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Settings")),
|
||||
appBar: AppBar(title: Text(t("settings.title"))),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _loadError != null
|
||||
@@ -186,7 +187,7 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
name: updated.name,
|
||||
role: updated.role,
|
||||
);
|
||||
widget.snack("Name saved.");
|
||||
widget.snack(t("settings.account.nameSaved"));
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
@@ -198,7 +199,7 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
setState(() => _verifying = true);
|
||||
try {
|
||||
await apiClient.requestVerification();
|
||||
widget.snack("Verification email requested.");
|
||||
widget.snack(t("settings.account.verificationRequested"));
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
@@ -209,11 +210,11 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
Future<void> _savePassword() async {
|
||||
setState(() => _pwError = null);
|
||||
if (_newPw.text.length < 8) {
|
||||
setState(() => _pwError = "New password must be at least 8 characters.");
|
||||
setState(() => _pwError = t("settings.account.tooShort"));
|
||||
return;
|
||||
}
|
||||
if (_newPw.text != _confirmPw.text) {
|
||||
setState(() => _pwError = "New password and confirmation don't match.");
|
||||
setState(() => _pwError = t("settings.account.mismatch"));
|
||||
return;
|
||||
}
|
||||
setState(() => _savingPw = true);
|
||||
@@ -222,7 +223,7 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
_oldPw.clear();
|
||||
_newPw.clear();
|
||||
_confirmPw.clear();
|
||||
widget.snack("Password updated.");
|
||||
widget.snack(t("settings.account.passwordUpdated"));
|
||||
} catch (e) {
|
||||
setState(() => _pwError = e.toString());
|
||||
} finally {
|
||||
@@ -234,9 +235,9 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.profile;
|
||||
return _Card(
|
||||
title: "Account",
|
||||
title: t("settings.account.title"),
|
||||
children: [
|
||||
const Text("Name", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.account.name"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
@@ -248,11 +249,11 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _savingName ? null : _saveName,
|
||||
child: Text(_savingName ? "Saving…" : "Save"),
|
||||
child: Text(_savingName ? t("common.saving") : t("common.save")),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Email", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.account.email"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(child: Text(p.email)),
|
||||
@@ -264,7 +265,7 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
: (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(p.verified ? "Verified" : "Not verified",
|
||||
child: Text(p.verified ? t("settings.account.verified") : t("settings.account.notVerified"),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -276,31 +277,31 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _verifying ? null : _sendVerification,
|
||||
child: Text(_verifying ? "Sending…" : "Resend verification email"),
|
||||
child: Text(_verifying ? t("settings.account.sending") : t("settings.account.resendVerification")),
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.account.changePassword"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _oldPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Current password", border: OutlineInputBorder(), isDense: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("settings.account.currentPassword"), border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _newPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("settings.account.newPassword"), border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _confirmPw,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Confirm new password", border: OutlineInputBorder(), isDense: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("settings.account.confirmNewPassword"), border: const OutlineInputBorder(), isDense: true),
|
||||
),
|
||||
if (_pwError != null)
|
||||
Padding(
|
||||
@@ -312,7 +313,7 @@ class _AccountSectionState extends State<_AccountSection> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton(
|
||||
onPressed: _savingPw ? null : _savePassword,
|
||||
child: Text(_savingPw ? "Updating…" : "Update password"),
|
||||
child: Text(_savingPw ? t("settings.account.updating") : t("settings.account.updatePassword")),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -444,21 +445,21 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Appearance",
|
||||
title: t("settings.appearance.title"),
|
||||
children: [
|
||||
const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.theme"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: "light", label: Text("Light")),
|
||||
ButtonSegment(value: "dark", label: Text("Dark")),
|
||||
ButtonSegment(value: "system", label: Text("System")),
|
||||
segments: [
|
||||
ButtonSegment(value: "light", label: Text(t("settings.appearance.themeLight"))),
|
||||
ButtonSegment(value: "dark", label: Text(t("settings.appearance.themeDark"))),
|
||||
ButtonSegment(value: "system", label: Text(t("settings.appearance.themeSystem"))),
|
||||
],
|
||||
selected: {appSettings.theme},
|
||||
onSelectionChanged: (s) => _save({"theme": s.first}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Language", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.language"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _language,
|
||||
@@ -468,13 +469,18 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _saveLocale(lang: v),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text("Names of months and days.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
languageTranslated
|
||||
? t("settings.appearance.languageHint")
|
||||
: t("settings.appearance.languageFallbackHint"),
|
||||
style: TextStyle(
|
||||
color: languageTranslated ? Colors.grey : DriverVault.warning, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Region", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.region"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _region,
|
||||
@@ -484,13 +490,13 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _saveLocale(region: v),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text("Number and currency layout.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(t("settings.appearance.regionHint"),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Currency", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.currency"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _knownOr(_currencies, appSettings.currency, "USD"),
|
||||
@@ -504,12 +510,12 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
"Example: ${formatMoney(1234.5)}. Amounts are display-only — nothing is converted.",
|
||||
t("settings.appearance.currencyHint", params: {"example": formatMoney(1234.5)}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.dateFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: appSettings.dateFormat,
|
||||
@@ -524,17 +530,17 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text("Example: ${formatDate(DateTime.now())}",
|
||||
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: "small", label: Text("Small")),
|
||||
ButtonSegment(value: "medium", label: Text("Medium")),
|
||||
ButtonSegment(value: "large", label: Text("Large")),
|
||||
segments: [
|
||||
ButtonSegment(value: "small", label: Text(t("settings.appearance.fontSmall"))),
|
||||
ButtonSegment(value: "medium", label: Text(t("settings.appearance.fontMedium"))),
|
||||
ButtonSegment(value: "large", label: Text(t("settings.appearance.fontLarge"))),
|
||||
],
|
||||
selected: {appSettings.fontSize},
|
||||
onSelectionChanged: (s) => _save({"fontSize": s.first}),
|
||||
@@ -599,7 +605,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
|
||||
setState(() => _savingBio = true);
|
||||
try {
|
||||
await apiClient.updateMe({"bio": widget.bioController.text});
|
||||
widget.snack("Bio saved.");
|
||||
widget.snack(t("settings.profile.bioSaved"));
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
} finally {
|
||||
@@ -612,7 +618,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
|
||||
final p = widget.profile;
|
||||
final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase();
|
||||
return _Card(
|
||||
title: "Profile",
|
||||
title: t("settings.profile.title"),
|
||||
children: [
|
||||
Row(children: [
|
||||
CircleAvatar(
|
||||
@@ -629,24 +635,24 @@ class _ProfileSectionState extends State<_ProfileSection> {
|
||||
Wrap(spacing: 8, children: [
|
||||
OutlinedButton(
|
||||
onPressed: _avatarBusy ? null : _pickAvatar,
|
||||
child: Text(_avatarBusy ? "Working…" : "Upload photo"),
|
||||
child: Text(_avatarBusy ? t("settings.profile.working") : t("settings.profile.uploadPhoto")),
|
||||
),
|
||||
if (p.hasAvatar)
|
||||
OutlinedButton(
|
||||
onPressed: _avatarBusy ? null : _removeAvatar,
|
||||
child: const Text("Remove"),
|
||||
child: Text(t("settings.profile.remove")),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(t("settings.profile.bio"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: widget.bioController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: "A short note visible to other people in your household.",
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: t("settings.profile.bioHint"),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -654,7 +660,7 @@ class _ProfileSectionState extends State<_ProfileSection> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton(
|
||||
onPressed: _savingBio ? null : _saveBio,
|
||||
child: Text(_savingBio ? "Saving…" : "Save bio"),
|
||||
child: Text(_savingBio ? t("common.saving") : t("settings.profile.saveBio")),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -702,10 +708,10 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
}
|
||||
|
||||
String get _methodLabel {
|
||||
if (_hasFace && _hasFingerprint) return "face or fingerprint";
|
||||
if (_hasFace) return "face recognition";
|
||||
if (_hasFingerprint) return "fingerprint";
|
||||
return "biometrics";
|
||||
if (_hasFace && _hasFingerprint) return t("settings.security.methodFaceOrFingerprint");
|
||||
if (_hasFace) return t("settings.security.methodFace");
|
||||
if (_hasFingerprint) return t("settings.security.methodFingerprint");
|
||||
return t("settings.security.methodBiometrics");
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
@@ -714,7 +720,7 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
setState(() => _busy = true);
|
||||
await biometricAuth.disable();
|
||||
if (mounted) setState(() { _enabled = false; _busy = false; });
|
||||
widget.snack("Biometric sign-in turned off");
|
||||
widget.snack(t("settings.security.turnedOff"));
|
||||
return;
|
||||
}
|
||||
// Enabling requires re-confirming the password so we store known-good creds.
|
||||
@@ -726,10 +732,10 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
await authService.login(widget.email, pw);
|
||||
await biometricAuth.enable(widget.email, pw);
|
||||
if (mounted) setState(() { _enabled = true; _busy = false; });
|
||||
widget.snack("Biometric sign-in enabled");
|
||||
widget.snack(t("settings.security.enabled"));
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
widget.snack("Could not enable: ${e.toString()}");
|
||||
widget.snack(t("settings.security.couldNotEnable", params: {"error": e.toString()}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,20 +744,20 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Confirm your password"),
|
||||
title: Text(t("settings.security.confirmPassword")),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("settings.security.password"),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t("common.cancel"))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: Text(t("common.confirm"))),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -760,20 +766,20 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Security",
|
||||
title: t("settings.security.title"),
|
||||
children: [
|
||||
if (!_loaded)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text("Checking device…", style: TextStyle(color: Colors.grey)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(t("settings.security.checkingDevice"), style: const TextStyle(color: Colors.grey)),
|
||||
)
|
||||
else ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Biometric sign-in"),
|
||||
title: Text(t("settings.security.biometricSignIn")),
|
||||
subtitle: Text(_available
|
||||
? "Sign in with $_methodLabel instead of your password."
|
||||
: "No biometrics are enrolled on this device."),
|
||||
? t("settings.security.biometricAvailable", params: {"method": _methodLabel})
|
||||
: t("settings.security.biometricUnavailable")),
|
||||
value: _enabled,
|
||||
onChanged: (!_available || _busy) ? null : _toggle,
|
||||
),
|
||||
@@ -793,20 +799,17 @@ class _PrivacySection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Card(
|
||||
title: "Privacy & security",
|
||||
title: t("settings.privacy.title"),
|
||||
children: [
|
||||
const Text(
|
||||
"Two-factor authentication isn't available yet. Sessions are held as "
|
||||
"server-issued tokens that expire on their own, so signing out ends "
|
||||
"this device's session only. To lock out every device, change your "
|
||||
"password above.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
Text(
|
||||
t("settings.privacy.body"),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => authService.logout(),
|
||||
child: const Text("Sign out", style: TextStyle(color: DriverVault.danger)),
|
||||
child: Text(t("settings.privacy.signOut"), style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -848,7 +851,7 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
|
||||
Future<void> _request() async {
|
||||
if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) {
|
||||
widget.snack("Type your email to confirm.");
|
||||
widget.snack(t("settings.danger.typeEmailPrompt"));
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
@@ -878,14 +881,14 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Delete account?"),
|
||||
content: const Text("This permanently deletes your account. This cannot be undone."),
|
||||
title: Text(t("settings.danger.finalizeTitle")),
|
||||
content: Text(t("settings.danger.finalizeBody")),
|
||||
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("settings.danger.delete")),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -903,23 +906,23 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
Widget build(BuildContext context) {
|
||||
final p = widget.profile;
|
||||
return _Card(
|
||||
title: "Danger zone",
|
||||
title: t("settings.danger.title"),
|
||||
titleColor: DriverVault.danger,
|
||||
children: [
|
||||
if (!_pending) ...[
|
||||
const Text(
|
||||
"Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.",
|
||||
style: TextStyle(fontSize: 13),
|
||||
Text(
|
||||
t("settings.danger.body"),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_showConfirm)
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() => _showConfirm = true),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger),
|
||||
child: const Text("Delete my account"),
|
||||
child: Text(t("settings.danger.deleteAccount")),
|
||||
)
|
||||
else ...[
|
||||
Text("Type ${p.email} to confirm",
|
||||
Text(t("settings.danger.typeToConfirm", params: {"email": p.email}),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
@@ -934,31 +937,35 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
_showConfirm = false;
|
||||
_confirmEmail.clear();
|
||||
}),
|
||||
child: const Text("Cancel"),
|
||||
child: Text(t("common.cancel")),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: _busy ? null : _request,
|
||||
child: Text(_busy ? "Requesting…" : "Request deletion"),
|
||||
child: Text(_busy ? t("settings.danger.requesting") : t("settings.danger.requestDeletion")),
|
||||
),
|
||||
]),
|
||||
],
|
||||
] else ...[
|
||||
Text(
|
||||
"Account deletion requested on ${formatDate(p.deletionRequestedAt)}. "
|
||||
"${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}",
|
||||
t("settings.danger.requestedOn", params: {
|
||||
"date": formatDate(p.deletionRequestedAt),
|
||||
"tail": _cooldownElapsed
|
||||
? t("settings.danger.cooldownPassed")
|
||||
: t("settings.danger.canStillCancel"),
|
||||
}),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")),
|
||||
OutlinedButton(onPressed: _cancel, child: Text(t("settings.danger.cancelRequest"))),
|
||||
const SizedBox(width: 8),
|
||||
if (_cooldownElapsed)
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||
onPressed: _finalize,
|
||||
child: const Text("Permanently delete"),
|
||||
child: Text(t("settings.danger.permanentlyDelete")),
|
||||
),
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -32,6 +32,9 @@ dev_dependencies:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
# Per-language UI translation files, read at runtime by lib/i18n.dart.
|
||||
assets:
|
||||
- assets/i18n/
|
||||
|
||||
# DriverVault launcher icon — regenerate with:
|
||||
# flutter pub run flutter_launcher_icons
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:intl/date_symbol_data_local.dart";
|
||||
import "package:carcontrol_phone/format.dart";
|
||||
import "package:carcontrol_phone/i18n.dart";
|
||||
import "package:carcontrol_phone/main.dart";
|
||||
import "package:carcontrol_phone/models.dart";
|
||||
|
||||
void main() {
|
||||
// rootBundle (used by loadTranslations) needs the binding initialised.
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await initializeDateFormatting();
|
||||
await loadTranslations();
|
||||
// Polish exercises both the region-aware number grouping and the localized
|
||||
// status wording (one/few/many plurals), so the badge labels below are the
|
||||
// Polish strings — the same text the web app renders under pl.
|
||||
appSettings.locale = "pl-PL";
|
||||
appSettings.currency = "PLN";
|
||||
appSettings.dateFormat = "DMY";
|
||||
@@ -67,11 +75,11 @@ void main() {
|
||||
"expiry": {"state": state, "daysUntilExpiry": days},
|
||||
"hasFile": false,
|
||||
});
|
||||
expect(expiryStatus(doc("expired", -5).expiry).label, "Expired 5d ago");
|
||||
expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Expires today");
|
||||
expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Renew in 12d");
|
||||
expect(expiryStatus(doc("valid", 200).expiry).label, "Valid · 200d");
|
||||
expect(expiryStatus(doc("no_expiry", null).expiry).label, "No expiry");
|
||||
expect(expiryStatus(doc("expired", -5).expiry).label, "Wygasło 5 dni temu");
|
||||
expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Wygasa dzisiaj");
|
||||
expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Odnowienie za 12 dni");
|
||||
expect(expiryStatus(doc("valid", 200).expiry).label, "Ważne · 200 dni");
|
||||
expect(expiryStatus(doc("no_expiry", null).expiry).label, "Bezterminowe");
|
||||
expect(expiryStatus(doc("expired", -5).expiry).key, StatusKey.overdue);
|
||||
});
|
||||
|
||||
@@ -79,16 +87,19 @@ void main() {
|
||||
Reminder rem(Map<String, dynamic> extra) =>
|
||||
Reminder.fromJson({"id": "r", "car": "c", "title": "t", "type": "service", ...extra});
|
||||
|
||||
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Done");
|
||||
expect(reminderStatus(rem({"status": "no_trigger"})).label, "No trigger");
|
||||
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Gotowe");
|
||||
expect(reminderStatus(rem({"status": "no_trigger"})).label, "Brak wyzwalacza");
|
||||
expect(
|
||||
reminderStatus(rem({"status": "overdue", "daysLeft": -3, "kmLeft": -200})).label,
|
||||
"Overdue 3d · 200 km");
|
||||
expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Due in today");
|
||||
// The km count is grouped per the chosen locale (pl-PL uses a space), which
|
||||
// is the whole point of routing every number through the one helper.
|
||||
expect(reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label,
|
||||
"Due in 40d · 5 000 km");
|
||||
"Zaległe 3 dni · 200 km");
|
||||
expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Termin za dzisiaj");
|
||||
// The km count is grouped per the chosen locale (pl-PL groups thousands with
|
||||
// a space), which is the whole point of routing every number through the one
|
||||
// helper. Asserted as start + end so the exact space glyph (ICU uses a
|
||||
// non-breaking space) does not make the test brittle.
|
||||
final due = reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label;
|
||||
expect(due, startsWith("Termin za 40 dni · 5"));
|
||||
expect(due, endsWith("000 km"));
|
||||
});
|
||||
|
||||
test("TechnicalCheck: a failed check derives no next date", () {
|
||||
@@ -121,7 +132,7 @@ void main() {
|
||||
"hasFile": false,
|
||||
});
|
||||
expect(m.totalCost, 150.0);
|
||||
expect(warrantyStatus(m)!.label, "Warranty ends in 10d");
|
||||
expect(warrantyStatus(m)!.label, "Gwarancja kończy się za 10 dni");
|
||||
expect(warrantyStatus(m)!.key, StatusKey.soon);
|
||||
|
||||
final noWarranty = MaintenanceEntry.fromJson(
|
||||
|
||||
Reference in new issue
Block a user