diff --git a/Phone App/README.md b/Phone App/README.md index a097ef6..410ec9c 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -2,8 +2,7 @@ A Flutter client for the DriverVault maintenance tracker. Talks **only** to the API Server (same contract and auth as the web app — a PocketBase token relayed by -the server, not a JWT the server mints). At full feature parity with the web app -(data export/import is the only deliberate omission). +the server, not a JWT the server mints). At full feature parity with the web app. Project name `drivervault_phone`, package id `com.drivervault.phoneapp`. **Android is the supported target** — the older Flutter-web build path is @@ -24,8 +23,27 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an - **Car detail** — all spec fields (incl. VIN and transmission / differential / brake / coolant specs), a **share** sheet (owner only), quick odometer update, edit car, and delete car (type-to-confirm; cascades). Actions are gated by the - caller's access level (read-only vs write vs owner). Tabs, in the web app's - order: + caller's access level (read-only vs write vs owner). + + **Which tabs a car shows, and in what order, belongs to the car** — the same + arrangement the web app reads, so everyone it is shared with sees the same + page. Edit it under the **tune** icon: switch tabs and Information rows on or + off, and drag either list by its handle to reorder it. (The web rearranges by + dragging the tab bar itself; on a touch screen that gesture is the tab bar's, + so both arrangements are made in the picker instead.) Information cannot be + switched off — a page with no tabs left would be a dead end — but it can be + moved. The tabs, in their default order: + - **Connected service** — everything the manufacturer's own app knows about + this car, read live under *your* account: headline readings (odometer, fuel + or battery, range, charging state, position), the vehicle record, and one + card per capability the plugin exposes, rendered from the server's flattened + key/value pairs so a provider adding a field surfaces it without an app + change. Offers to take the provider's odometer when it is ahead of the + stored one. A car with no link yet gets a **connect** card instead, and the + tab is hidden entirely for a user with no manufacturer account connected. + Because the read runs under your own credentials, a car shared from someone + else shows data here only if that vehicle is on your account too — it says + so rather than failing. - **Service history** — date/odometer plus which parts were changed, with the next-due date/km derived by the server. - **Technical check history** — the mandatory roadworthiness inspections @@ -39,6 +57,11 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an cost per km, price per litre). Consumption is measured between full tanks, so partial fills roll into the next full one and a flagged missed fill leaves its window uncomputed rather than reporting a fictional figure. + - **Charging cost** — charges with a summary panel (average / best / worst + kWh/100km, cost per km, price per kWh). The electric twin of Fuel, and + derived the same way: consumption is measured between full charges, so a + top-up rolls into the next full one. Distinct from the **Charging** section + in the bottom bar, which is the charger network and OCPP control. - **Documents** — insurance, registration, road tax and the rest, with a renewal badge driven by the server's expiry assessment. - **Parts catalog** — the per-car parts list. @@ -46,7 +69,7 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an **Mark done** action. Reminders the server derived from a document or service record are shown read-only. - **Attachments** — one optional file per service record, technical check, - workshop visit, refill, document and part (PDF or image, up to 10MB). Picked + workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked with `file_picker`, fetched back through the API Server — never a public URL — and opened with the phone's own viewer via `open_filex`. - **Charging** — mirrors the web `Charging.vue`, split into two tabs. **Public** @@ -59,8 +82,11 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an (theme + dark mode, **language**, **region**, date format, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota, Anker Solix), **Security** (biometric toggle), **Organization** (create your - own — which makes you its admin — or rename/delete the one you administer), and - the account-deletion state machine. Auth relays PocketBase's own stateless + own — which makes you its admin — or rename/delete the one you administer), + **data export/import**, and the account-deletion state machine. Export writes + the account JSON into the app's own documents directory and offers to open it; + import picks a previously exported file and always creates new records, so it + asks first, with the number of cars the file actually holds. Auth relays PocketBase's own stateless tokens, so there is no per-device session list to show or revoke. - **Users (admin)** — user management tab (list / create / role / reset password / delete), shown only for the admin role. @@ -161,5 +187,7 @@ lib/ ├── root_shell.dart # bottom-nav shell: Garage, Charging, Settings, Users ├── login_screen.dart lock_screen.dart dashboard_screen.dart ├── car_detail_screen.dart car_form_sheet.dart record_form_sheets.dart + ├── car_view_sheet.dart # which tabs/rows a car shows + the key catalogue + ├── provider_tab.dart # the connected-service tab (MyToyota) └── charging_screen.dart settings_screen.dart admin_users_screen.dart ``` diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 871aef8..5b1d88f 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -8,7 +8,17 @@ "retry": "Prøv igen", "required": "Påkrævet", "confirm": "Bekræft", - "empty": "—" + "empty": "—", + "saveChanges": "Gem ændringer", + "loading": "Indlæser…", + "edit": "Rediger", + "open": "Åbn", + "delete": "Slet", + "done": "Færdig", + "undo": "Fortryd", + "download": "Download", + "yes": "Ja", + "no": "Nej" }, "nav": { "garage": "Garage", @@ -216,6 +226,24 @@ "finalizeTitle": "Slet konto?", "finalizeBody": "Dette sletter din konto permanent. Det kan ikke fortrydes.", "delete": "Slet" + }, + "advanced": { + "title": "Avanceret", + "exportTitle": "Eksportér dine data", + "exportBody": "Download din profil samt alle biler, serviceposter og reservedele som JSON.", + "preparing": "Forbereder…", + "exportAction": "Eksportér data", + "importTitle": "Importér dine data", + "importBody": "Tilføj biler fra en tidligere eksporteret JSON-fil. Dette opretter nye poster — intet flettes eller overskrives.", + "importing": "Importerer…", + "importAction": "Importér data", + "notJson": "Filen er ikke gyldig JSON.", + "notExport": "Filen ligner ikke en DriverVault-eksport (der mangler en \"cars\"-liste).", + "confirmImport": "Importér {count} bil(er) fra denne fil? Dette tilføjer nye poster — eksisterende biler flettes eller overskrives ikke.", + "imported": "Importerede {cars} bil(er), {services} servicepost(er), {parts} reservedel(e).", + "exported": "Gemte {file}", + "open": "Åbn", + "openFailed": "Filen kunne ikke åbnes." } }, "status": { @@ -244,5 +272,317 @@ "warrantyExpiredAgo": "Garanti udløb for {days} d siden", "warrantyEndsIn": "Garanti slutter om {days} d", "underWarranty": "Under garanti · {days} d" + }, + "car": { + "allCars": "← Alle biler", + "share": "Del", + "shared": "Delt", + "sharedReadOnly": "Delt · skrivebeskyttet", + "tabs": { + "connected": "Tilsluttet tjeneste", + "info": "Oplysninger", + "services": "Servicehistorik", + "technical": "Synshistorik", + "maintenance": "Værksted", + "fuel": "Brændstofudgifter", + "charging": "Opladningsudgifter", + "documents": "Dokumenter", + "parts": "Reservedelskatalog", + "reminders": "Påmindelser", + "dragHint": "Træk en fane for at ændre rækkefølgen af bilens faner." + }, + "viewPicker": { + "open": "Hvad denne bils side viser", + "title": "Hvad denne bil viser", + "subtitle": "Vælg hvilke afsnit og oplysninger denne bils side viser. Det gælder alle, bilen er delt med.", + "tabsHeading": "Faner", + "fieldsHeading": "Oplysninger", + "alwaysOn": "{tab} er altid tilgængelig.", + "fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge.", + "reorderHint": "Træk en række i håndtaget for at ændre rækkefølgen.", + "tabsReorder": "Faneblade", + "fieldsReorder": "Informationsfelter" + }, + "provider": { + "subtitle": "Live-data fra din {label}-konto.", + "refresh": "Opdater", + "refreshing": "Opdaterer…", + "updated": "Opdateret {time}", + "vehicle": "Køretøj", + "readings": "Aktuelle målinger", + "dragHint": "Træk en aflæsning for at ændre rækkefølgen.", + "noData": "{label} returnerede ingen data for denne bil.", + "raw": "Rå svar", + "allFields": "Alle oplyste felter", + "truncated": "Kun de første {n} felter er vist — resten findes i det rå svar nedenfor.", + "sectionEmpty": "Intet oplyst.", + "sectionFailed": "Kunne ikke hentes", + "own": "Kun din egen konto bruges, så loginoplysninger deles aldrig sammen med en bil.", + "odometerSuggest": "{label} oplyser {km}, altså mere end bilens gemte kilometerstand.", + "updateOdometer": "Opdater kilometerstand", + "updatingOdometer": "Opdaterer…", + "unlink": "Afbryd", + "unlinkConfirm": "Afbryd denne bil fra {label}? Intet gemt slettes.", + "connectTitle": "Forbind denne bil til en tjeneste", + "connectHint": "Vælg det køretøj på din konto, der svarer til denne bil. Dens data vises så her.", + "connectSubmit": "Forbind køretøj", + "connecting": "Forbinder…", + "noProviders": "Ingen producentkonto er tilsluttet. Tilføj en under Indstillinger › Integrationer.", + "settingsLink": "Åbn Indstillinger", + "sections": { + "telemetry": "Kilometerstand og rækkevidde", + "electric": "Batteri og opladning", + "status": "Døre, ruder og lys", + "health": "Køretøjets tilstand", + "location": "Sidst kendte position", + "serviceHistory": "Servicehistorik hos forhandler", + "notifications": "Notifikationer" + }, + "metrics": { + "odometer": "Kilometerstand", + "fuelLevel": "Brændstofniveau", + "fuelRange": "Rækkevidde", + "batteryLevel": "Batteri", + "evRange": "Elektrisk rækkevidde (uden aircon)", + "evRangeWithAc": "Elektrisk rækkevidde (med aircon)", + "chargingStatus": "Opladning", + "location": "Position" + } + }, + "info": { + "allHidden": "Alle felter er slået fra for denne bil.", + "dragHint": "Træk et felt for at ændre rækkefølgen af bilens oplysninger.", + "oilSpec": "Motorolie-specifikation", + "transmissionOil": "Gearolie", + "differentialOil": "Differentialeolie", + "brakeFluid": "Bremsevæske", + "coolant": "Kølervæske", + "odometer": "Kilometerstand", + "serviceInterval": "Serviceinterval", + "nextDue": "Næste service", + "registrationPlate": "Nummerplade", + "registrationCountry": "Registreringsland", + "vin": "Stelnummer", + "fuelType": "Brændstoftype", + "buildDate": "Produktionsdato", + "firstRegistration": "Første registrering", + "technicalCheckInterval": "Synsinterval" + }, + "services": { + "title": "Servicehistorik", + "add": "Tilføj service", + "empty": "Ingen serviceposter endnu.", + "colDate": "Dato", + "colKm": "Km", + "colNextDate": "Næste dato", + "colNextKm": "Næste km", + "colOil": "Olie og oliefilter", + "colEngineFilter": "Luftfilter", + "colCabinFilter": "Kabinefilter", + "colNotes": "Noter", + "colFile": "Fil", + "confirmDelete": "Slet denne servicepost?" + }, + "technical": { + "title": "Synshistorik", + "subtitle": "Lovpligtige syn. Gentages alene efter tid, uanset hvad kilometerstanden viser.", + "add": "Tilføj syn", + "empty": "Ingen syn endnu.", + "colDate": "Dato", + "colResult": "Resultat", + "colNextCheck": "Næste syn", + "colStatus": "Status", + "colStation": "Synssted", + "colCost": "Pris", + "colNotes": "Noter", + "colFile": "Fil", + "passed": "Godkendt", + "failed": "Ikke godkendt", + "confirmDelete": "Slet dette syn?" + }, + "maintenance": { + "title": "Værksted", + "subtitle": "Værkstedsbesøg og reparationer. Almindelig service hører under Servicehistorik.", + "add": "Registrér besøg", + "empty": "Ingen værkstedsbesøg registreret endnu.", + "colDate": "Dato", + "colKm": "Km", + "colType": "Type", + "colWork": "Udført arbejde", + "colWorkshop": "Værksted", + "colStatus": "Status", + "colCost": "Pris", + "colFile": "Fil", + "underWarranty": "Under garanti · {days} dage tilbage", + "confirmDelete": "Slet dette værkstedsbesøg?" + }, + "fuel": { + "title": "Brændstofudgifter", + "subtitle": "Forbruget måles mellem fulde tanke.", + "add": "Registrér tankning", + "empty": "Ingen tankninger registreret endnu.", + "average": "Gennemsnit", + "best": "Bedste", + "worst": "Værste", + "costPerKm": "Pris pr. km", + "refills": "Tankninger", + "totalLiters": "Liter i alt", + "totalSpent": "Brugt i alt", + "trackedDistance": "Målt distance", + "avgPrice": "Gns. pris {price}/L", + "needTwoTanks": "Registrér mindst to fulde tanke for at se forbrugstal.", + "colDate": "Dato", + "colKm": "Km", + "colLiters": "Liter", + "colCost": "Pris", + "colPerLiter": "Pr. liter", + "colDistance": "Distance", + "colConsumption": "Forbrug", + "colStation": "Tankstation", + "colFile": "Fil", + "partial": "delvis", + "gap": "hul", + "confirmDelete": "Slet denne tankning?" + }, + "charging": { + "title": "Opladningsudgifter", + "subtitle": "Forbruget måles mellem fulde opladninger.", + "add": "Registrér opladning", + "empty": "Ingen opladninger registreret endnu.", + "average": "Gennemsnit", + "best": "Bedste", + "worst": "Værste", + "costPerKm": "Pris pr. km", + "sessions": "Opladninger", + "totalKwh": "Energi i alt", + "totalSpent": "Brugt i alt", + "trackedDistance": "Målt distance", + "avgPrice": "Gns. pris {price}/kWh", + "needTwoCharges": "Registrér mindst to fulde opladninger for at se forbrugstal.", + "colDate": "Dato", + "colKm": "Km", + "colKwh": "kWh", + "colCost": "Pris", + "colPerKwh": "Pr. kWh", + "colDistance": "Distance", + "colConsumption": "Forbrug", + "colLocation": "Ladepunkt", + "colFile": "Fil", + "partial": "delvis", + "gap": "hul", + "confirmDelete": "Slet denne opladning?" + }, + "documents": { + "title": "Dokumenter", + "subtitle": "Forsikring, miljøattester og andre papirer med fornyelsesdatoer.", + "add": "Tilføj dokument", + "empty": "Ingen dokumenter endnu.", + "colType": "Type", + "colTitle": "Titel", + "colProvider": "Udbyder", + "colIssued": "Udstedt", + "colRenewal": "Fornyelse", + "colStatus": "Status", + "colFile": "Fil", + "confirmDelete": "Slet dette dokument?" + }, + "reminders": { + "title": "Påmindelser", + "subtitle": "Påmindelser om fornyelse og service tilføjes automatisk ud fra dine dokumenter og din servicehistorik.", + "add": "Tilføj påmindelse", + "empty": "Intet at blive mindet om endnu.", + "automatic": "Automatisk", + "repeats": "Gentages", + "at": "ved {km}", + "doneRollForward": "Færdig · flyt frem", + "markDone": "Markér som færdig", + "reopen": "Genåbn", + "confirmDelete": "Slet denne påmindelse?" + }, + "parts": { + "title": "Reservedelskatalog", + "add": "Tilføj reservedel", + "empty": "Ingen reservedele endnu.", + "colPart": "Reservedel", + "colPartNumber": "Varenummer", + "colNotes": "Noter", + "colFile": "Fil", + "confirmDelete": "Slet denne reservedel?" + }, + "delete": { + "title": "Slet denne bil?", + "body": "Dette sletter {name} permanent sammen med alt, der er registreret på den — {services}, {maintenance}, {fuel}, {charging}, {documents} og {parts}. Det kan ikke fortrydes.", + "services": { + "one": "{n} servicepost", + "other": "{n} serviceposter" + }, + "maintenance": { + "one": "{n} værkstedsbesøg", + "other": "{n} værkstedsbesøg" + }, + "fuel": { + "one": "{n} tankning", + "other": "{n} tankninger" + }, + "charging": { + "one": "{n} opladning", + "other": "{n} opladninger" + }, + "documents": { + "one": "{n} dokument", + "other": "{n} dokumenter" + }, + "parts": { + "one": "{n} reservedel", + "other": "{n} reservedele" + }, + "typeToConfirm": "Skriv {name} for at bekræfte", + "deleting": "Sletter…", + "confirm": "Slet permanent" + } + }, + "forms": { + "charging": { + "addTitle": "Registrér opladning", + "editTitle": "Redigér opladning", + "date": "Dato", + "odometer": "Kilometerstand (km)", + "kwh": "Energi (kWh)", + "cost": "Pris", + "pricePerKwh": "Det er {price} pr. kWh.", + "battery": "Batteri", + "fullCharge": "Ladet helt op", + "missedSession": "En opladning før denne blev ikke registreret", + "batteryHint": "Forbruget måles mellem fulde opladninger. Delvise opladninger tæller stadig med i prisen og indgår i den næste fulde.", + "location": "Ladepunkt", + "locationPlaceholder": "Hjemme", + "notes": "Noter", + "attachmentLegend": "Kvittering", + "submit": "Gem opladning", + "odometerRequired": "Kilometerstand er påkrævet.", + "kwhRequired": "Energi (kWh) er påkrævet." + }, + "import": { + "title": "Importér en bil", + "subtitle": "Opret en bil ud fra et køretøj på din producentkonto. Alt, hvad der kan læses, udfyldes for dig.", + "service": "Tjeneste", + "loadingVehicles": "Indlæser dine køretøjer…", + "noVehicles": "Ingen køretøjer på denne konto.", + "notConnected": "Ikke tilsluttet", + "noProviders": "Ingen producentkonto er tilsluttet. Tilføj en under Indstillinger › Integrationer.", + "alreadyInGarage": "Allerede i din garage", + "selectVehicle": "Køretøj", + "dataTitle": "Hvad skal importeres", + "dataHint": "Fjern fluebenet ved det, du helst selv vil udfylde.", + "includeIdentity": "Mærke, model, årgang, nummerplade og VIN", + "includeFuelType": "Brændstoftype", + "includeDates": "Produktions- og første registreringsdato", + "includeOdometer": "Aktuel kilometerstand", + "name": "Bilens navn", + "submit": "Importér bil", + "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." + } } -} \ No newline at end of file +} diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index d435a5a..f19f6d2 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -8,16 +8,24 @@ "retry": "Retry", "required": "Required", "confirm": "Confirm", - "empty": "—" + "empty": "—", + "saveChanges": "Save changes", + "loading": "Loading…", + "edit": "Edit", + "open": "Open", + "delete": "Delete", + "done": "Done", + "undo": "Undo", + "download": "Download", + "yes": "Yes", + "no": "No" }, - "nav": { "garage": "Garage", "charging": "Charging", "settings": "Settings", "users": "Users" }, - "login": { "tagline": "Your car, on track.", "email": "Email", @@ -47,7 +55,6 @@ "savedNote": "Saved ✓", "resetNote": "Reset ✓" }, - "lock": { "title": "DriverVault is locked", "reason": "Unlock DriverVault", @@ -55,7 +62,6 @@ "unlocking": "Unlocking…", "usePassword": "Use password instead" }, - "dashboard": { "eyebrow": "YOUR CARS", "title": "Garage", @@ -76,7 +82,6 @@ "other": "{n} service records" } }, - "charging": { "eyebrow": "CHARGING & MAP", "title": "Nearby chargers", @@ -128,15 +133,12 @@ "homeCharger": "Home charger" } }, - "settings": { "title": "Settings", - "tabs": { "personal": "Personal settings", "integrations": "Integrations" }, - "integrations": { "title": "Integrations", "subtitle": "Connect external services to your account.", @@ -193,7 +195,6 @@ "controlConnected": "Connected to control backend", "controlDisconnected": "Not connected" }, - "account": { "title": "Account", "name": "Name", @@ -214,7 +215,6 @@ "updating": "Updating…", "updatePassword": "Update password" }, - "appearance": { "title": "Appearance", "theme": "Theme", @@ -235,7 +235,6 @@ "fontMedium": "Medium", "fontLarge": "Large" }, - "profile": { "title": "Profile", "working": "Working…", @@ -246,7 +245,6 @@ "bioSaved": "Bio saved.", "saveBio": "Save bio" }, - "security": { "title": "Security", "checkingDevice": "Checking device…", @@ -263,7 +261,6 @@ "confirmPassword": "Confirm your password", "password": "Password" }, - "org": { "title": "Organization", "titleAll": "Organizations", @@ -287,7 +284,6 @@ "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.", @@ -304,9 +300,26 @@ "finalizeTitle": "Delete account?", "finalizeBody": "This permanently deletes your account. This cannot be undone.", "delete": "Delete" + }, + "advanced": { + "title": "Advanced", + "exportTitle": "Export your data", + "exportBody": "Download your profile and all cars, service records, and parts as JSON.", + "preparing": "Preparing…", + "exportAction": "Export data", + "importTitle": "Import your data", + "importBody": "Add cars from a previously exported JSON file. This creates new records — it doesn't merge with or overwrite anything existing.", + "importing": "Importing…", + "importAction": "Import data", + "notJson": "That file isn't valid JSON.", + "notExport": "That file doesn't look like a DriverVault export (missing a \"cars\" list).", + "confirmImport": "Import {count} car(s) from this file? This adds new records — it does not merge with or overwrite existing cars.", + "imported": "Imported {cars} car(s), {services} service record(s), {parts} part(s).", + "exported": "Saved {file}", + "open": "Open", + "openFailed": "Could not open the file." } }, - "status": { "noData": "No data", "serviceOverdueDays": "Service Overdue {days}d", @@ -333,5 +346,317 @@ "warrantyExpiredAgo": "Warranty expired {days}d ago", "warrantyEndsIn": "Warranty ends in {days}d", "underWarranty": "Under warranty · {days}d" + }, + "car": { + "allCars": "← All cars", + "share": "Share", + "shared": "Shared", + "sharedReadOnly": "Shared · read-only", + "tabs": { + "connected": "Connected service", + "info": "Information", + "services": "Service history", + "technical": "Technical check history", + "maintenance": "Maintenance", + "fuel": "Fuel cost", + "charging": "Charging cost", + "documents": "Documents", + "parts": "Parts catalog", + "reminders": "Reminders", + "dragHint": "Drag a tab to rearrange this car's tabs." + }, + "viewPicker": { + "open": "What this car's page shows", + "title": "What this car shows", + "subtitle": "Pick the sections and details this car's page shows. It applies to everyone the car is shared with.", + "tabsHeading": "Tabs", + "fieldsHeading": "Information fields", + "alwaysOn": "{tab} is always available.", + "fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in.", + "reorderHint": "Drag a row by its handle to change the order.", + "tabsReorder": "Tabs", + "fieldsReorder": "Information fields" + }, + "provider": { + "subtitle": "Live data from your {label} account.", + "refresh": "Refresh", + "refreshing": "Refreshing…", + "updated": "Updated {time}", + "vehicle": "Vehicle", + "readings": "Current readings", + "dragHint": "Drag a reading to rearrange them.", + "noData": "{label} returned no data for this car.", + "raw": "Raw response", + "allFields": "All reported fields", + "truncated": "Only the first {n} fields are listed — the raw response below has the rest.", + "sectionEmpty": "Nothing reported.", + "sectionFailed": "Couldn't be fetched", + "own": "Only your own account is used, so credentials are never shared with a car.", + "odometerSuggest": "{label} reports {km}, ahead of this car's stored reading.", + "updateOdometer": "Update odometer", + "updatingOdometer": "Updating…", + "unlink": "Disconnect", + "unlinkConfirm": "Disconnect this car from {label}? Nothing already saved is deleted.", + "connectTitle": "Connect this car to a service", + "connectHint": "Pick the vehicle on your account that matches this car. Its data then appears here.", + "connectSubmit": "Connect vehicle", + "connecting": "Connecting…", + "noProviders": "No manufacturer account is connected. Add one in Settings › Integrations.", + "settingsLink": "Open Settings", + "sections": { + "telemetry": "Odometer & range", + "electric": "Battery & charging", + "status": "Doors, windows & lights", + "health": "Vehicle health", + "location": "Last known location", + "serviceHistory": "Dealer service history", + "notifications": "Notifications" + }, + "metrics": { + "odometer": "Odometer", + "fuelLevel": "Fuel level", + "fuelRange": "Range", + "batteryLevel": "Battery", + "evRange": "Electric range (A/C off)", + "evRangeWithAc": "Electric range (A/C on)", + "chargingStatus": "Charging", + "location": "Position" + } + }, + "info": { + "allHidden": "Every field is switched off for this car.", + "dragHint": "Drag a field to rearrange this car's information.", + "oilSpec": "Engine oil spec", + "transmissionOil": "Transmission oil", + "differentialOil": "Differential oil", + "brakeFluid": "Brake fluid", + "coolant": "Coolant", + "odometer": "Odometer", + "serviceInterval": "Service interval", + "nextDue": "Next due", + "registrationPlate": "Registration plate", + "registrationCountry": "Registration country", + "vin": "VIN", + "fuelType": "Fuel type", + "buildDate": "Build date", + "firstRegistration": "First registration", + "technicalCheckInterval": "Technical check interval" + }, + "services": { + "title": "Service history", + "add": "Add service", + "empty": "No service records yet.", + "colDate": "Date", + "colKm": "Km", + "colNextDate": "Next date", + "colNextKm": "Next km", + "colOil": "Oil & Oil filter", + "colEngineFilter": "Engine air filter", + "colCabinFilter": "Cabin air filter", + "colNotes": "Notes", + "colFile": "File", + "confirmDelete": "Delete this service record?" + }, + "technical": { + "title": "Technical check history", + "subtitle": "Mandatory roadworthiness inspections. Recurs on time alone, whatever the odometer reads.", + "add": "Add check", + "empty": "No technical checks yet.", + "colDate": "Date", + "colResult": "Result", + "colNextCheck": "Next check", + "colStatus": "Status", + "colStation": "Station", + "colCost": "Cost", + "colNotes": "Notes", + "colFile": "File", + "passed": "Passed", + "failed": "Failed", + "confirmDelete": "Delete this technical check?" + }, + "maintenance": { + "title": "Maintenance", + "subtitle": "Workshop visits and repairs. Routine servicing lives under Service history.", + "add": "Log visit", + "empty": "No workshop visits logged yet.", + "colDate": "Date", + "colKm": "Km", + "colType": "Type", + "colWork": "Work done", + "colWorkshop": "Workshop", + "colStatus": "Status", + "colCost": "Cost", + "colFile": "File", + "underWarranty": "Under warranty · {days}d left", + "confirmDelete": "Delete this workshop visit?" + }, + "fuel": { + "title": "Fuel cost", + "subtitle": "Consumption is measured between full tanks.", + "add": "Log refill", + "empty": "No refills logged yet.", + "average": "Average", + "best": "Best", + "worst": "Worst", + "costPerKm": "Cost per km", + "refills": "Refills", + "totalLiters": "Total litres", + "totalSpent": "Total spent", + "trackedDistance": "Tracked distance", + "avgPrice": "Avg. price {price}/L", + "needTwoTanks": "Log at least two full tanks to see consumption figures.", + "colDate": "Date", + "colKm": "Km", + "colLiters": "Litres", + "colCost": "Cost", + "colPerLiter": "Per litre", + "colDistance": "Distance", + "colConsumption": "Consumption", + "colStation": "Station", + "colFile": "File", + "partial": "partial", + "gap": "gap", + "confirmDelete": "Delete this refill?" + }, + "charging": { + "title": "Charging cost", + "subtitle": "Consumption is measured between full charges.", + "add": "Log charge", + "empty": "No charges logged yet.", + "average": "Average", + "best": "Best", + "worst": "Worst", + "costPerKm": "Cost per km", + "sessions": "Charges", + "totalKwh": "Total energy", + "totalSpent": "Total spent", + "trackedDistance": "Tracked distance", + "avgPrice": "Avg. price {price}/kWh", + "needTwoCharges": "Log at least two full charges to see consumption figures.", + "colDate": "Date", + "colKm": "Km", + "colKwh": "kWh", + "colCost": "Cost", + "colPerKwh": "Per kWh", + "colDistance": "Distance", + "colConsumption": "Consumption", + "colLocation": "Charge point", + "colFile": "File", + "partial": "partial", + "gap": "gap", + "confirmDelete": "Delete this charge?" + }, + "documents": { + "title": "Documents", + "subtitle": "Insurance, pollution certificates and other paperwork with renewal dates.", + "add": "Add document", + "empty": "No documents yet.", + "colType": "Type", + "colTitle": "Title", + "colProvider": "Provider", + "colIssued": "Issued", + "colRenewal": "Renewal", + "colStatus": "Status", + "colFile": "File", + "confirmDelete": "Delete this document?" + }, + "reminders": { + "title": "Reminders", + "subtitle": "Renewal and service reminders are added automatically from your documents and service history.", + "add": "Add reminder", + "empty": "Nothing to be reminded about yet.", + "automatic": "Automatic", + "repeats": "Repeats", + "at": "at {km}", + "doneRollForward": "Done · roll forward", + "markDone": "Mark done", + "reopen": "Reopen", + "confirmDelete": "Delete this reminder?" + }, + "parts": { + "title": "Parts catalog", + "add": "Add part", + "empty": "No parts yet.", + "colPart": "Part", + "colPartNumber": "Part number", + "colNotes": "Notes", + "colFile": "File", + "confirmDelete": "Delete this part?" + }, + "delete": { + "title": "Delete this car?", + "body": "This permanently deletes {name} and everything logged against it — {services}, {maintenance}, {fuel}, {charging}, {documents} and {parts}. This cannot be undone.", + "services": { + "one": "{n} service record", + "other": "{n} service records" + }, + "maintenance": { + "one": "{n} workshop visit", + "other": "{n} workshop visits" + }, + "fuel": { + "one": "{n} refill", + "other": "{n} refills" + }, + "charging": { + "one": "{n} charge", + "other": "{n} charges" + }, + "documents": { + "one": "{n} document", + "other": "{n} documents" + }, + "parts": { + "one": "{n} part", + "other": "{n} parts" + }, + "typeToConfirm": "Type {name} to confirm", + "deleting": "Deleting…", + "confirm": "Delete permanently" + } + }, + "forms": { + "charging": { + "addTitle": "Log charge", + "editTitle": "Edit charge", + "date": "Date", + "odometer": "Odometer (km)", + "kwh": "Energy (kWh)", + "cost": "Cost", + "pricePerKwh": "That is {price} per kWh.", + "battery": "Battery", + "fullCharge": "Charged to full", + "missedSession": "A charge before this one was not logged", + "batteryHint": "Consumption is measured between full charges. Partial charges still count towards the cost, and roll into the next full one.", + "location": "Charge point", + "locationPlaceholder": "Home", + "notes": "Notes", + "attachmentLegend": "Receipt", + "submit": "Save charge", + "odometerRequired": "Odometer is required.", + "kwhRequired": "Energy (kWh) is required." + }, + "import": { + "title": "Import a car", + "subtitle": "Create a car from a vehicle on your manufacturer account. Everything it can read is filled in for you.", + "service": "Service", + "loadingVehicles": "Loading your vehicles…", + "noVehicles": "No vehicles on this account.", + "notConnected": "Not connected", + "noProviders": "No manufacturer account is connected. Add one in Settings › Integrations.", + "alreadyInGarage": "Already in your garage", + "selectVehicle": "Vehicle", + "dataTitle": "What to import", + "dataHint": "Uncheck anything you would rather fill in yourself.", + "includeIdentity": "Make, model, year, registration and VIN", + "includeFuelType": "Fuel type", + "includeDates": "Build and first-registration dates", + "includeOdometer": "Current odometer", + "name": "Car name", + "submit": "Import car", + "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." + } } } diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 6843ea3..f56ef5e 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -8,7 +8,17 @@ "retry": "Ponów", "required": "Wymagane", "confirm": "Potwierdź", - "empty": "—" + "empty": "—", + "saveChanges": "Zapisz zmiany", + "loading": "Ładowanie…", + "edit": "Edytuj", + "open": "Otwórz", + "delete": "Usuń", + "done": "Gotowe", + "undo": "Cofnij", + "download": "Pobierz", + "yes": "Tak", + "no": "Nie" }, "nav": { "garage": "Garaż", @@ -220,6 +230,24 @@ "finalizeTitle": "Usunąć konto?", "finalizeBody": "To trwale usunie Twoje konto. Tej operacji nie można cofnąć.", "delete": "Usuń" + }, + "advanced": { + "title": "Zaawansowane", + "exportTitle": "Eksportuj swoje dane", + "exportBody": "Pobierz swój profil oraz wszystkie samochody, wpisy serwisowe i części w formacie JSON.", + "preparing": "Przygotowywanie…", + "exportAction": "Eksportuj dane", + "importTitle": "Importuj swoje dane", + "importBody": "Dodaj samochody z wcześniej wyeksportowanego pliku JSON. Tworzy to nowe wpisy — nic nie jest scalane ani nadpisywane.", + "importing": "Importowanie…", + "importAction": "Importuj dane", + "notJson": "Ten plik nie jest poprawnym plikiem JSON.", + "notExport": "Ten plik nie wygląda na eksport z DriverVault (brak listy \"cars\").", + "confirmImport": "Zaimportować samochody z tego pliku ({count})? Zostaną dodane nowe wpisy — istniejące samochody nie zostaną scalone ani nadpisane.", + "imported": "Zaimportowano: samochody ({cars}), wpisy serwisowe ({services}), części ({parts}).", + "exported": "Zapisano {file}", + "open": "Otwórz", + "openFailed": "Nie udało się otworzyć pliku." } }, "status": { @@ -248,5 +276,327 @@ "warrantyExpiredAgo": "Gwarancja wygasła {days} dni temu", "warrantyEndsIn": "Gwarancja kończy się za {days} dni", "underWarranty": "Na gwarancji · {days} dni" + }, + "car": { + "allCars": "← Wszystkie samochody", + "share": "Udostępnij", + "shared": "Udostępniony", + "sharedReadOnly": "Udostępniony · tylko do odczytu", + "tabs": { + "connected": "Połączona usługa", + "info": "Informacje", + "services": "Historia serwisowa", + "technical": "Historia przeglądów", + "maintenance": "Naprawy", + "fuel": "Koszty paliwa", + "charging": "Koszty ładowania", + "documents": "Dokumenty", + "parts": "Katalog części", + "reminders": "Przypomnienia", + "dragHint": "Przeciągnij kartę, aby zmienić kolejność kart tego samochodu." + }, + "viewPicker": { + "open": "Co pokazuje strona tego samochodu", + "title": "Co pokazuje ten samochód", + "subtitle": "Wybierz sekcje i szczegóły widoczne na stronie tego samochodu. Dotyczy wszystkich, którym go udostępniono.", + "tabsHeading": "Zakładki", + "fieldsHeading": "Pola informacji", + "alwaysOn": "Zakładka {tab} jest zawsze dostępna.", + "fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność.", + "reorderHint": "Przeciągnij wiersz za uchwyt, aby zmienić kolejność.", + "tabsReorder": "Zakładki", + "fieldsReorder": "Pola informacyjne" + }, + "provider": { + "subtitle": "Dane na żywo z Twojego konta {label}.", + "refresh": "Odśwież", + "refreshing": "Odświeżanie…", + "updated": "Zaktualizowano {time}", + "vehicle": "Pojazd", + "readings": "Aktualne odczyty", + "dragHint": "Przeciągnij odczyt, aby zmienić ich kolejność.", + "noData": "{label} nie zwróciło żadnych danych dla tego samochodu.", + "raw": "Surowa odpowiedź", + "allFields": "Wszystkie zgłoszone pola", + "truncated": "Wypisano tylko pierwsze {n} pól — pozostałe znajdziesz w surowej odpowiedzi poniżej.", + "sectionEmpty": "Brak danych.", + "sectionFailed": "Nie udało się pobrać", + "own": "Używane jest wyłącznie Twoje własne konto, więc dane logowania nigdy nie są udostępniane wraz z samochodem.", + "odometerSuggest": "{label} podaje {km}, czyli więcej niż zapisany przebieg tego samochodu.", + "updateOdometer": "Zaktualizuj przebieg", + "updatingOdometer": "Aktualizowanie…", + "unlink": "Odłącz", + "unlinkConfirm": "Odłączyć ten samochód od {label}? Żadne zapisane dane nie zostaną usunięte.", + "connectTitle": "Połącz ten samochód z usługą", + "connectHint": "Wybierz pojazd ze swojego konta, który odpowiada temu samochodowi. Jego dane pojawią się tutaj.", + "connectSubmit": "Połącz pojazd", + "connecting": "Łączenie…", + "noProviders": "Nie połączono żadnego konta producenta. Dodaj je w Ustawieniach › Integracje.", + "settingsLink": "Otwórz Ustawienia", + "sections": { + "telemetry": "Przebieg i zasięg", + "electric": "Akumulator i ładowanie", + "status": "Drzwi, szyby i światła", + "health": "Stan pojazdu", + "location": "Ostatnia znana lokalizacja", + "serviceHistory": "Historia serwisowa u dealera", + "notifications": "Powiadomienia" + }, + "metrics": { + "odometer": "Przebieg", + "fuelLevel": "Poziom paliwa", + "fuelRange": "Zasięg", + "batteryLevel": "Akumulator", + "evRange": "Zasięg elektryczny (bez klimatyzacji)", + "evRangeWithAc": "Zasięg elektryczny (z klimatyzacją)", + "chargingStatus": "Ładowanie", + "location": "Pozycja" + } + }, + "info": { + "allHidden": "Wszystkie pola są wyłączone dla tego samochodu.", + "dragHint": "Przeciągnij pole, aby zmienić układ informacji o tym samochodzie.", + "oilSpec": "Specyfikacja oleju silnikowego", + "transmissionOil": "Olej przekładniowy", + "differentialOil": "Olej mostu napędowego", + "brakeFluid": "Płyn hamulcowy", + "coolant": "Płyn chłodniczy", + "odometer": "Przebieg", + "serviceInterval": "Interwał serwisowy", + "nextDue": "Następny termin", + "registrationPlate": "Numer rejestracyjny", + "registrationCountry": "Kraj rejestracji", + "vin": "VIN", + "fuelType": "Rodzaj paliwa", + "buildDate": "Data produkcji", + "firstRegistration": "Pierwsza rejestracja", + "technicalCheckInterval": "Interwał przeglądów technicznych" + }, + "services": { + "title": "Historia serwisowa", + "add": "Dodaj serwis", + "empty": "Brak wpisów serwisowych.", + "colDate": "Data", + "colKm": "Km", + "colNextDate": "Następna data", + "colNextKm": "Następny przebieg", + "colOil": "Olej i filtr oleju", + "colEngineFilter": "Filtr powietrza silnika", + "colCabinFilter": "Filtr kabinowy", + "colNotes": "Notatki", + "colFile": "Plik", + "confirmDelete": "Usunąć ten wpis serwisowy?" + }, + "technical": { + "title": "Historia przeglądów technicznych", + "subtitle": "Obowiązkowe badania techniczne. Powtarzają się wyłącznie w oparciu o czas, niezależnie od przebiegu.", + "add": "Dodaj przegląd", + "empty": "Brak przeglądów technicznych.", + "colDate": "Data", + "colResult": "Wynik", + "colNextCheck": "Następny przegląd", + "colStatus": "Status", + "colStation": "Stacja", + "colCost": "Koszt", + "colNotes": "Notatki", + "colFile": "Plik", + "passed": "Pozytywny", + "failed": "Negatywny", + "confirmDelete": "Usunąć ten przegląd techniczny?" + }, + "maintenance": { + "title": "Naprawy", + "subtitle": "Wizyty w warsztacie i naprawy. Rutynowa obsługa znajduje się w Historii serwisowej.", + "add": "Zapisz wizytę", + "empty": "Brak zapisanych wizyt w warsztacie.", + "colDate": "Data", + "colKm": "Km", + "colType": "Rodzaj", + "colWork": "Wykonane prace", + "colWorkshop": "Warsztat", + "colStatus": "Status", + "colCost": "Koszt", + "colFile": "Plik", + "underWarranty": "Na gwarancji · pozostało {days} dni", + "confirmDelete": "Usunąć tę wizytę w warsztacie?" + }, + "fuel": { + "title": "Koszty paliwa", + "subtitle": "Zużycie liczone jest między pełnymi bakami.", + "add": "Zapisz tankowanie", + "empty": "Brak zapisanych tankowań.", + "average": "Średnie", + "best": "Najlepsze", + "worst": "Najgorsze", + "costPerKm": "Koszt na km", + "refills": "Tankowania", + "totalLiters": "Łącznie litrów", + "totalSpent": "Łącznie wydano", + "trackedDistance": "Zmierzony dystans", + "avgPrice": "Śr. cena {price}/l", + "needTwoTanks": "Zapisz co najmniej dwa pełne baki, aby zobaczyć zużycie.", + "colDate": "Data", + "colKm": "Km", + "colLiters": "Litry", + "colCost": "Koszt", + "colPerLiter": "Za litr", + "colDistance": "Dystans", + "colConsumption": "Zużycie", + "colStation": "Stacja", + "colFile": "Plik", + "partial": "częściowe", + "gap": "luka", + "confirmDelete": "Usunąć to tankowanie?" + }, + "charging": { + "title": "Koszty ładowania", + "subtitle": "Zużycie liczone jest między pełnymi ładowaniami.", + "add": "Zapisz ładowanie", + "empty": "Brak zapisanych ładowań.", + "average": "Średnio", + "best": "Najlepsze", + "worst": "Najgorsze", + "costPerKm": "Koszt na km", + "sessions": "Ładowania", + "totalKwh": "Łączna energia", + "totalSpent": "Łącznie wydano", + "trackedDistance": "Zmierzony dystans", + "avgPrice": "Śr. cena {price}/kWh", + "needTwoCharges": "Zapisz co najmniej dwa pełne ładowania, aby zobaczyć zużycie.", + "colDate": "Data", + "colKm": "Km", + "colKwh": "kWh", + "colCost": "Koszt", + "colPerKwh": "Za kWh", + "colDistance": "Dystans", + "colConsumption": "Zużycie", + "colLocation": "Ładowarka", + "colFile": "Plik", + "partial": "częściowe", + "gap": "przerwa", + "confirmDelete": "Usunąć to ładowanie?" + }, + "documents": { + "title": "Dokumenty", + "subtitle": "Ubezpieczenie, zaświadczenia i inne dokumenty z terminami odnowienia.", + "add": "Dodaj dokument", + "empty": "Brak dokumentów.", + "colType": "Rodzaj", + "colTitle": "Nazwa", + "colProvider": "Wystawca", + "colIssued": "Wystawiono", + "colRenewal": "Odnowienie", + "colStatus": "Status", + "colFile": "Plik", + "confirmDelete": "Usunąć ten dokument?" + }, + "reminders": { + "title": "Przypomnienia", + "subtitle": "Przypomnienia o odnowieniach i serwisach dodawane są automatycznie na podstawie dokumentów i historii serwisowej.", + "add": "Dodaj przypomnienie", + "empty": "Nie ma jeszcze o czym przypominać.", + "automatic": "Automatyczne", + "repeats": "Powtarza się", + "at": "przy {km}", + "doneRollForward": "Gotowe · przenieś dalej", + "markDone": "Oznacz jako gotowe", + "reopen": "Otwórz ponownie", + "confirmDelete": "Usunąć to przypomnienie?" + }, + "parts": { + "title": "Katalog części", + "add": "Dodaj część", + "empty": "Brak części.", + "colPart": "Część", + "colPartNumber": "Numer części", + "colNotes": "Notatki", + "colFile": "Plik", + "confirmDelete": "Usunąć tę część?" + }, + "delete": { + "title": "Usunąć ten samochód?", + "body": "To trwale usunie {name} i wszystko, co zostało w nim zapisane — {services}, {maintenance}, {fuel}, {charging}, {documents} i {parts}. Tej operacji nie można cofnąć.", + "services": { + "one": "{n} wpis serwisowy", + "few": "{n} wpisy serwisowe", + "many": "{n} wpisów serwisowych", + "other": "{n} wpisu serwisowego" + }, + "maintenance": { + "one": "{n} wizyta w warsztacie", + "few": "{n} wizyty w warsztacie", + "many": "{n} wizyt w warsztacie", + "other": "{n} wizyty w warsztacie" + }, + "fuel": { + "one": "{n} tankowanie", + "few": "{n} tankowania", + "many": "{n} tankowań", + "other": "{n} tankowania" + }, + "charging": { + "one": "{n} ładowanie", + "other": "{n} ładowań" + }, + "documents": { + "one": "{n} dokument", + "few": "{n} dokumenty", + "many": "{n} dokumentów", + "other": "{n} dokumentu" + }, + "parts": { + "one": "{n} część", + "few": "{n} części", + "many": "{n} części", + "other": "{n} części" + }, + "typeToConfirm": "Wpisz {name}, aby potwierdzić", + "deleting": "Usuwanie…", + "confirm": "Usuń trwale" + } + }, + "forms": { + "charging": { + "addTitle": "Zapisz ładowanie", + "editTitle": "Edytuj ładowanie", + "date": "Data", + "odometer": "Przebieg (km)", + "kwh": "Energia (kWh)", + "cost": "Koszt", + "pricePerKwh": "To {price} za kWh.", + "battery": "Akumulator", + "fullCharge": "Naładowany do pełna", + "missedSession": "Wcześniejsze ładowanie nie zostało zapisane", + "batteryHint": "Zużycie liczone jest między pełnymi ładowaniami. Ładowania częściowe wliczają się do kosztów i doliczają do kolejnego pełnego.", + "location": "Ładowarka", + "locationPlaceholder": "Dom", + "notes": "Notatki", + "attachmentLegend": "Paragon", + "submit": "Zapisz ładowanie", + "odometerRequired": "Przebieg jest wymagany.", + "kwhRequired": "Energia (kWh) jest wymagana." + }, + "import": { + "title": "Importuj samochód", + "subtitle": "Utwórz samochód na podstawie pojazdu z Twojego konta u producenta. Wszystko, co da się odczytać, zostanie wypełnione automatycznie.", + "service": "Usługa", + "loadingVehicles": "Wczytywanie Twoich pojazdów…", + "noVehicles": "Brak pojazdów na tym koncie.", + "notConnected": "Nie połączono", + "noProviders": "Nie połączono żadnego konta producenta. Dodaj je w Ustawieniach › Integracje.", + "alreadyInGarage": "Już w Twoim garażu", + "selectVehicle": "Pojazd", + "dataTitle": "Co zaimportować", + "dataHint": "Odznacz to, co wolisz wpisać samodzielnie.", + "includeIdentity": "Marka, model, rok, rejestracja i VIN", + "includeFuelType": "Rodzaj paliwa", + "includeDates": "Data produkcji i pierwszej rejestracji", + "includeOdometer": "Aktualny przebieg", + "name": "Nazwa samochodu", + "submit": "Importuj samochód", + "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." + } } -} \ No newline at end of file +} diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index dccd13c..91c146c 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -133,6 +133,29 @@ class ApiClient { Future deleteCar(String id) => _send("DELETE", "/cars/$id"); + /// What this car's page shows and in which order — its own endpoint rather + /// than part of the car PATCH, so saving the car form can never silently + /// reveal a hidden tab or undo an arrangement. Every field is optional; only + /// the ones passed are written. + Future updateCarView( + String id, { + List? hiddenTabs, + List? hiddenFields, + List? tabOrder, + List? fieldOrder, + List? metricOrder, + }) async { + final body = { + if (hiddenTabs != null) "hiddenTabs": hiddenTabs, + if (hiddenFields != null) "hiddenFields": hiddenFields, + if (tabOrder != null) "tabOrder": tabOrder, + if (fieldOrder != null) "fieldOrder": fieldOrder, + if (metricOrder != null) "metricOrder": metricOrder, + }; + final data = await _send("PUT", "/cars/$id/view", body: body); + return Car.fromJson(Map.from(data)); + } + // --- sharing (owner-only) --- Future> listCarShares(String carId) async { final data = await _send("GET", "/cars/$carId/shares") as List; @@ -278,6 +301,31 @@ class ApiClient { Future deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id"); + // --- charging sessions --- + // The electric counterpart of the fuel entries, on identical terms: the + // per-car list plus the derived summary, and CRUD on the flat collection. + Future> listCarChargingSessions(String carId) async { + final data = await _send("GET", "/cars/$carId/charging-sessions") as List; + return data.map((e) => ChargingSession.fromJson(Map.from(e))).toList(); + } + + Future getCarChargingStats(String carId) async { + final data = await _send("GET", "/cars/$carId/charging-stats"); + return ChargingStats.fromJson(Map.from(data)); + } + + Future createChargingSession(Map body) async { + final data = await _send("POST", "/charging-sessions", body: body); + return ChargingSession.fromJson(Map.from(data)); + } + + Future updateChargingSession(String id, Map body) async { + final data = await _send("PATCH", "/charging-sessions/$id", body: body); + return ChargingSession.fromJson(Map.from(data)); + } + + Future deleteChargingSession(String id) => _send("DELETE", "/charging-sessions/$id"); + // --- maintenance --- Future> listCarMaintenance(String carId) async { final data = await _send("GET", "/cars/$carId/maintenance") as List; @@ -432,6 +480,90 @@ class ApiClient { Future cancelAccountDeletion() => _send("POST", "/me/delete/cancel"); Future finalizeAccountDeletion() => _send("DELETE", "/me"); + // --- data export / import --- + + /// The whole account as a JSON file: the profile plus every car the user owns + /// with its service records and parts. Cars merely shared with them are not + /// included. Returns the bytes and the filename the server named it, which is + /// dated — the phone has to write the file itself, so it needs both. + Future<(List, String)> exportData() async { + final res = await http.get(_uri("/me/export"), + headers: {if (token != null) "Authorization": "Bearer $token"}); + if (res.statusCode == 401) { + onUnauthorized?.call(); + throw ApiException(401, "Session expired — please log in again."); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw ApiException(res.statusCode, _errorMessage(_tryDecode(res.body), res.reasonPhrase)); + } + return (res.bodyBytes, _filenameFrom(res.headers["content-disposition"])); + } + + /// Adds the cars in a previously exported file. Always creates new records — + /// nothing is merged with or overwritten, so importing the same file twice + /// leaves two copies rather than one updated one. + Future importData(Map payload) async { + final data = await _send("POST", "/me/import", body: payload); + return ImportResult.fromJson(Map.from(data)); + } + + static dynamic _tryDecode(String body) { + try { + return jsonDecode(body); + } catch (_) { + return null; + } + } + + /// The filename out of `attachment; filename="…"`, falling back to a plain + /// name so the export is still saveable if the header is missing or unquoted. + static String _filenameFrom(String? disposition) { + if (disposition == null) return "drivervault-export.json"; + final m = RegExp(r'filename="?([^";]+)"?').firstMatch(disposition); + final name = m?.group(1)?.trim() ?? ""; + return name.isEmpty ? "drivervault-export.json" : name; + } + + // --- vehicle providers (the connected-service tab) --- + // Every call runs server-side under *this* user's manufacturer account, so a + // car shared from someone else only shows provider data when that vehicle is + // on this user's account too. A closed gate is a 200 with `unavailable` set + // rather than an error: "we asked, and here is why there is nothing". + + Future> listVehicleProviders() async { + final data = await _send("GET", "/vehicle-providers"); + final items = (data["providers"] ?? []) as List; + return items.map((e) => VehicleProvider.fromJson(Map.from(e))).toList(); + } + + Future> listProviderVehicles(String provider) async { + final data = await _send("GET", "/vehicle-providers/${Uri.encodeComponent(provider)}/vehicles"); + final items = (data["vehicles"] ?? []) as List; + return items.map((e) => ProviderVehicle.fromJson(Map.from(e))).toList(); + } + + Future getCarProvider(String carId) async { + final data = await _send("GET", "/cars/$carId/provider"); + return ProviderSnapshot.fromJson(Map.from(data)); + } + + /// Links this car to a vehicle on the caller's provider account; an empty + /// [provider] unlinks it. Returns the updated car. + Future linkCarProvider(String carId, {String provider = "", String vehicleId = ""}) async { + final data = await _send("POST", "/cars/$carId/provider", + body: {"provider": provider, "vehicleId": vehicleId}); + return Car.fromJson(Map.from(data)); + } + + /// Re-applies the provider's data to the car. [include] selects what to take + /// (identity, fuelType, dates, odometer); omitted means everything. + Future syncCarProvider(String carId, {Map? include}) async { + final data = await _send("POST", "/cars/$carId/provider/sync", + body: {if (include != null) "include": include}); + final car = (data is Map && data["car"] != null) ? data["car"] : data; + return Car.fromJson(Map.from(car)); + } + // --- integrations (per-user plugin settings, superadmin → org → user cascade) --- // getToyota/getAnkerSolix return the resolved view (effective/own/locked per // field, secrets and inherited values masked); saveToyota/saveAnkerSolix write diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart index ba0634e..72ecc34 100644 --- a/Phone App/lib/format.dart +++ b/Phone App/lib/format.dart @@ -37,6 +37,15 @@ String formatDate(DateTime? d) { return DateFormat(pattern, _locale).format(d); } +/// A timestamp: the user's chosen date format with the wall clock beside it. +/// Only the provider snapshot needs one — everything else in the app is +/// date-only — but it follows the same settings as [formatDate] so the two never +/// disagree on screen. +String formatDateTime(DateTime? d) { + if (d == null) return "—"; + return "${formatDate(d)} ${DateFormat.Hm(_locale).format(d)}"; +} + // 0 km is a reading — a car collected new — not a blank. See format.js. String formatKm(int? km) => km == null ? "—" : "${_num(km)} km"; @@ -66,6 +75,16 @@ String formatConsumption(double? value) => String formatKmPerLiter(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} km/L"; +/// The charging figures. Same rule as the fuel ones: null is "could not be +/// derived", which reads as "—" rather than a misleading zero. +String formatKwh(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} kWh"; + +String formatKwhConsumption(double? value) => + value == null ? "—" : "${value.toStringAsFixed(1)} kWh/100km"; + +String formatKmPerKwh(double? value) => + value == null ? "—" : "${value.toStringAsFixed(2)} km/kWh"; + const int _kmSoon = 1000; enum StatusKey { unknown, ok, soon, overdue } diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 5e7f7a4..2024a22 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -14,6 +14,12 @@ double? _asDoubleOrNull(dynamic v) => v is num ? v.toDouble() : null; DateTime? _asDate(dynamic v) => v == null ? null : DateTime.tryParse(v.toString())?.toLocal(); +/// A JSON array of strings — the key sets a car carries for what its page shows +/// and in which order. Absent or null reads as empty, which every consumer takes +/// to mean "nothing hidden" / "the default order". +List _asStrList(dynamic v) => + v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const []; + /// The single optional file a record carries — a receipt, a scan, a photo of a /// part's box. Mirrors the API's embedded Attachment: the bytes are never in the /// JSON, only whether there are any and what they were stored as. Fetch them @@ -71,6 +77,25 @@ class Car { /// (or any code that constructs a Car without it) stay fully editable. final String access; + /// The manufacturer service this car came from ("toyota") and that service's + /// own id for the vehicle (the VIN, for Toyota). Both blank for a car entered + /// by hand. Written only by the link/import endpoints, never by a car edit — + /// which is why _carPayload leaves them out. + final String provider; + final String providerVehicleId; + + /// What this car's page does not show, and the order it lays out what it + /// does. Properties of the car, so everyone it is shared with sees the same + /// page. The hidden sets rather than the visible ones, so a tab or field added + /// in a later release is on by default; the orders may be partial, and any key + /// they leave out follows the ones they name. Written only through + /// PUT /cars/{id}/view. + final List hiddenTabs; + final List hiddenFields; + final List tabOrder; + final List fieldOrder; + final List metricOrder; + Car({ required this.id, required this.name, @@ -93,6 +118,13 @@ class Car { this.technicalCheckIntervalDays = 0, required this.currentKm, this.access = "owner", + this.provider = "", + this.providerVehicleId = "", + this.hiddenTabs = const [], + this.hiddenFields = const [], + this.tabOrder = const [], + this.fieldOrder = const [], + this.metricOrder = const [], }); factory Car.fromJson(Map j) => Car( @@ -117,6 +149,13 @@ class Car { technicalCheckIntervalDays: _asInt(j["technicalCheckIntervalDays"]), currentKm: _asInt(j["currentKm"]), access: j["access"] == null ? "owner" : _asStr(j["access"]), + provider: _asStr(j["provider"]), + providerVehicleId: _asStr(j["providerVehicleId"]), + hiddenTabs: _asStrList(j["hiddenTabs"]), + hiddenFields: _asStrList(j["hiddenFields"]), + tabOrder: _asStrList(j["tabOrder"]), + fieldOrder: _asStrList(j["fieldOrder"]), + metricOrder: _asStrList(j["metricOrder"]), ); bool get isOwner => access == "owner"; @@ -385,6 +424,132 @@ class FuelStats { ); } +/// One charge of an EV — the electric counterpart of [FuelEntry], and derived +/// the same way: consumption is measured between full charges, so a top-up +/// counts towards the next full one. The server computes every figure below the +/// raw kWh/cost and omits any it could not derive. +class ChargingSession with HasAttachment { + final String id; + final String car; + final DateTime? date; + final int km; + final double kwh; + final double cost; + + /// Taken to the car's usual full point — the reference the efficiency windows + /// are measured between. + final bool fullCharge; + + /// The car was charged before this without being logged, so the odometer span + /// is not accounted for by the kWh on record. Any window containing it is + /// left uncomputed rather than reported as implausibly efficient. + final bool missedSession; + + final String location; + final String notes; + + final double? pricePerKwh; + final int? distanceKm; + final double? kwhUsed; + final double? consumptionKwh100; + final double? kmPerKwh; + final double? costPerKm; + @override + final String fileName; + @override + final bool hasFile; + + ChargingSession({ + required this.id, + required this.car, + required this.date, + required this.km, + required this.kwh, + required this.cost, + this.fullCharge = true, + this.missedSession = false, + this.location = "", + this.notes = "", + this.pricePerKwh, + this.distanceKm, + this.kwhUsed, + this.consumptionKwh100, + this.kmPerKwh, + this.costPerKm, + this.fileName = "", + this.hasFile = false, + }); + + factory ChargingSession.fromJson(Map j) => ChargingSession( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + date: _asDate(j["date"]), + km: _asInt(j["km"]), + kwh: _asDouble(j["kwh"]), + cost: _asDouble(j["cost"]), + fullCharge: _asBool(j["fullCharge"]), + missedSession: _asBool(j["missedSession"]), + location: _asStr(j["location"]), + notes: _asStr(j["notes"]), + pricePerKwh: _asDoubleOrNull(j["pricePerKwh"]), + distanceKm: _asIntOrNull(j["distanceKm"]), + kwhUsed: _asDoubleOrNull(j["kwhUsed"]), + consumptionKwh100: _asDoubleOrNull(j["consumptionKwh100"]), + kmPerKwh: _asDoubleOrNull(j["kmPerKwh"]), + costPerKm: _asDoubleOrNull(j["costPerKm"]), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), + ); +} + +/// A summary of a car's whole charging history. [trackedDistanceKm] is the +/// distance covered by computable full-charge windows — the same caveat as +/// [FuelStats.trackedDistanceKm]. +class ChargingStats { + final int entries; + final double totalKwh; + final double totalCost; + final int trackedDistanceKm; + final double? avgConsumptionKwh100; + final double? bestConsumptionKwh100; + final double? worstConsumptionKwh100; + final double? avgKmPerKwh; + final double? avgPricePerKwh; + final double? costPerKm; + final DateTime? firstDate; + final DateTime? lastDate; + + const ChargingStats({ + this.entries = 0, + this.totalKwh = 0, + this.totalCost = 0, + this.trackedDistanceKm = 0, + this.avgConsumptionKwh100, + this.bestConsumptionKwh100, + this.worstConsumptionKwh100, + this.avgKmPerKwh, + this.avgPricePerKwh, + this.costPerKm, + this.firstDate, + this.lastDate, + }); + + factory ChargingStats.fromJson(Map j) => ChargingStats( + entries: _asInt(j["entries"]), + totalKwh: _asDouble(j["totalKwh"]), + totalCost: _asDouble(j["totalCost"]), + trackedDistanceKm: _asInt(j["trackedDistanceKm"]), + avgConsumptionKwh100: _asDoubleOrNull(j["avgConsumptionKwh100"]), + bestConsumptionKwh100: _asDoubleOrNull(j["bestConsumptionKwh100"]), + worstConsumptionKwh100: _asDoubleOrNull(j["worstConsumptionKwh100"]), + avgKmPerKwh: _asDoubleOrNull(j["avgKmPerKwh"]), + avgPricePerKwh: _asDoubleOrNull(j["avgPricePerKwh"]), + costPerKm: _asDoubleOrNull(j["costPerKm"]), + firstDate: _asDate(j["firstDate"]), + lastDate: _asDate(j["lastDate"]), + ); +} + /// One workshop visit or repair — work done outside the routine service /// schedule (which lives in [ServiceRecord]). A broken alternator replaced at a /// garage belongs here; the annual oil change does not. @@ -891,3 +1056,208 @@ class AnkerControl { double get meterKwh => meterWh / 1000.0; } + +// --- vehicle providers (the connected-service tab) --------------------------- +// +// A provider is a manufacturer service a car can be linked to (MyToyota today). +// Nothing below models an upstream schema: the API Server flattens whatever the +// plugin returned into key/value pairs, so a provider adding a field surfaces it +// here without a change to this file. See the API's vehicleproviders.go. + +/// One registered provider and whether this user can currently use it. +/// [detail] says, in one sentence, what to do about a closed gate. +class VehicleProvider { + final String id; + final String label; // "MyToyota" + final String service; // "Toyota Connected Europe" + final bool connected; + final String detail; + + const VehicleProvider({ + required this.id, + required this.label, + this.service = "", + this.connected = false, + this.detail = "", + }); + + factory VehicleProvider.fromJson(Map j) => VehicleProvider( + id: _asStr(j["id"]), + label: _asStr(j["label"]), + service: _asStr(j["service"]), + connected: _asBool(j["connected"]), + detail: _asStr(j["detail"]), + ); +} + +/// One leaf of a provider payload, flattened to a dotted path. +class ProviderField { + final String key; + final String value; + const ProviderField({required this.key, required this.value}); + + factory ProviderField.fromJson(Map j) => + ProviderField(key: _asStr(j["key"]), value: _asStr(j["value"])); + + static List listFrom(dynamic v) => v is List + ? v.map((e) => ProviderField.fromJson(Map.from(e))).toList() + : const []; +} + +/// One vehicle on the user's provider account. +class ProviderVehicle { + final String id; // the provider's own id (the VIN, for Toyota) + final String vin; + final String name; + final String make; + final String model; + final int year; + final String imageUrl; + final List fields; + + /// Set when this user already has a car linked to this vehicle, so the UI can + /// say so instead of offering to link it twice. + final String linkedCarId; + + const ProviderVehicle({ + required this.id, + this.vin = "", + this.name = "", + this.make = "", + this.model = "", + this.year = 0, + this.imageUrl = "", + this.fields = const [], + this.linkedCarId = "", + }); + + factory ProviderVehicle.fromJson(Map j) => ProviderVehicle( + id: _asStr(j["id"]), + vin: _asStr(j["vin"]), + name: _asStr(j["name"]), + make: _asStr(j["make"]), + model: _asStr(j["model"]), + year: _asInt(j["year"]), + imageUrl: _asStr(j["imageUrl"]), + fields: ProviderField.listFrom(j["fields"]), + linkedCarId: _asStr(j["linkedCarId"]), + ); + + String get subtitle => + [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" "); +} + +/// A headline reading lifted out of the sections — the few values worth showing +/// large. [key] is a stable id the app localizes (car.provider.metrics.*). +class ProviderMetric { + final String key; + final String value; + final String unit; + const ProviderMetric({required this.key, required this.value, this.unit = ""}); + + factory ProviderMetric.fromJson(Map j) => ProviderMetric( + key: _asStr(j["key"]), + value: _asStr(j["value"]), + unit: _asStr(j["unit"]), + ); +} + +/// One capability's outcome. A section that fails carries its error and the rest +/// still render: half a snapshot beats an error page. +class ProviderSection { + final String id; + final String status; // ok | error | empty + final String error; + final List fields; + final bool truncated; + + const ProviderSection({ + required this.id, + this.status = "ok", + this.error = "", + this.fields = const [], + this.truncated = false, + }); + + factory ProviderSection.fromJson(Map j) => ProviderSection( + id: _asStr(j["id"]), + status: _asStr(j["status"]).isEmpty ? "ok" : _asStr(j["status"]), + error: _asStr(j["error"]), + fields: ProviderField.listFrom(j["fields"]), + truncated: _asBool(j["truncated"]), + ); +} + +/// Everything a provider can currently tell us about one car. +/// +/// [unavailable] replaces the payload when the provider cannot be reached for +/// this car at all — not connected, or the vehicle is not on *this* user's +/// account. It arrives as a 200, because "we asked and here is why there is +/// nothing" is an answer rather than a failure. +class ProviderSnapshot { + final String provider; + final String label; + final String service; + final bool unavailable; + final String detail; + final DateTime? fetchedAt; + final ProviderVehicle? vehicle; + final List metrics; + final List sections; + + /// The odometer the provider reports when it is ahead of the car's stored + /// reading — what the tab's "update odometer" offers. 0 when they agree. + final int suggestedCurrentKm; + + const ProviderSnapshot({ + this.provider = "", + this.label = "", + this.service = "", + this.unavailable = false, + this.detail = "", + this.fetchedAt, + this.vehicle, + this.metrics = const [], + this.sections = const [], + this.suggestedCurrentKm = 0, + }); + + factory ProviderSnapshot.fromJson(Map j) { + final v = j["vehicle"]; + return ProviderSnapshot( + provider: _asStr(j["provider"]), + label: _asStr(j["label"]), + service: _asStr(j["service"]), + unavailable: _asBool(j["unavailable"]), + detail: _asStr(j["detail"]), + fetchedAt: _asDate(j["fetchedAt"]), + vehicle: v is Map ? ProviderVehicle.fromJson(Map.from(v)) : null, + metrics: j["metrics"] is List + ? (j["metrics"] as List) + .map((e) => ProviderMetric.fromJson(Map.from(e))) + .toList() + : const [], + sections: j["sections"] is List + ? (j["sections"] as List) + .map((e) => ProviderSection.fromJson(Map.from(e))) + .toList() + : const [], + suggestedCurrentKm: _asInt(j["suggestedCurrentKm"]), + ); + } +} + +/// What POST /me/import created. Every count is of brand-new records — the +/// import never merges with or overwrites anything already there. +class ImportResult { + final int cars; + final int services; + final int parts; + const ImportResult({this.cars = 0, this.services = 0, this.parts = 0}); + + factory ImportResult.fromJson(Map j) => ImportResult( + cars: _asInt(j["carsImported"]), + services: _asInt(j["servicesImported"]), + parts: _asInt(j["partsImported"]), + ); +} diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index 77afeb2..2213872 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -1,12 +1,15 @@ import "package:flutter/material.dart"; import "../api.dart"; +import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; import "../theme.dart"; import "../widgets/attachment_field.dart"; import "car_form_sheet.dart"; +import "car_view_sheet.dart"; +import "provider_tab.dart"; import "record_form_sheets.dart"; /// Serializes a car to the full update payload. The API's updateCar rewrites @@ -48,10 +51,17 @@ class _CarDetailData { final List maintenance; final List fuel; final FuelStats fuelStats; + final List charging; + final ChargingStats chargingStats; final List documents; final List parts; final List reminders; + /// The manufacturer services this user has connected. Only read to decide + /// whether the connected-service tab is on offer for a car that has no link + /// yet — the tab loads its own data once it is open. + final List providers; + _CarDetailData({ required this.car, required this.services, @@ -59,9 +69,12 @@ class _CarDetailData { required this.maintenance, required this.fuel, required this.fuelStats, + required this.charging, + required this.chargingStats, required this.documents, required this.parts, required this.reminders, + required this.providers, }); } @@ -85,9 +98,15 @@ class _CarDetailScreenState extends State { apiClient.listCarMaintenance(widget.carId), apiClient.listCarFuelEntries(widget.carId), apiClient.getCarFuelStats(widget.carId), + apiClient.listCarChargingSessions(widget.carId), + apiClient.getCarChargingStats(widget.carId), apiClient.listCarDocuments(widget.carId), apiClient.listCarParts(widget.carId), apiClient.listCarReminders(widget.carId), + // Only decides whether one tab is offered, so it must not be able to take + // the whole page down with it — a provider plugin that is misconfigured + // (or a server too old to know the endpoint) leaves the tab off instead. + apiClient.listVehicleProviders().catchError((_) => []), ]); return _CarDetailData( car: results[0] as Car, @@ -96,9 +115,12 @@ class _CarDetailScreenState extends State { maintenance: results[3] as List, fuel: results[4] as List, fuelStats: results[5] as FuelStats, - documents: results[6] as List, - parts: results[7] as List, - reminders: results[8] as List, + charging: results[6] as List, + chargingStats: results[7] as ChargingStats, + documents: results[8] as List, + parts: results[9] as List, + reminders: results[10] as List, + providers: results[11] as List, ); } @@ -304,8 +326,15 @@ class _CarDetailScreenState extends State { final latest = data.services.isNotEmpty ? data.services.first : null; final status = serviceStatus(latest, car); + final tabKeys = _tabKeys(car, data.providers); + return DefaultTabController( - length: 8, + // The tab set is a property of the car: the view picker switches + // tabs off, and linking a provider adds one. Keying the controller + // on it rebuilds the controller instead of leaving it addressing a + // length that no longer exists. + key: ValueKey(tabKeys.join("/")), + length: tabKeys.length, child: Scaffold( appBar: AppBar( title: Text(car.name), @@ -316,6 +345,12 @@ class _CarDetailScreenState extends State { icon: const Icon(Icons.person_add_alt), onPressed: () => _shareCar(car), ), + if (car.canWrite) + IconButton( + tooltip: t("car.viewPicker.open"), + icon: const Icon(Icons.tune), + onPressed: () => _openViewPicker(car, data.providers), + ), if (car.canWrite) IconButton( tooltip: "Edit car", @@ -336,18 +371,11 @@ class _CarDetailScreenState extends State { _deleteCar(car, data.services.length, data.parts.length), ), ], - // Same order as the web app's CarDetail tabs. - bottom: const TabBar( + bottom: TabBar( isScrollable: true, tabs: [ - Tab(text: "Information"), - Tab(text: "Service history"), - Tab(text: "Technical check history"), - Tab(text: "Maintenance"), - Tab(text: "Fuel"), - Tab(text: "Documents"), - Tab(text: "Parts catalog"), - Tab(text: "Reminders"), + for (final key in tabKeys) + Tab(text: _tabLabel(key, car, data.providers)), ], ), ), @@ -367,144 +395,7 @@ class _CarDetailScreenState extends State { ), Expanded( child: TabBarView( - children: [ - // Information tab - _InfoTab(car: car, latest: latest), - // Service history tab - _TabList( - empty: data.services.isEmpty ? "No service records yet." : null, - onAdd: car.canWrite ? () => _addService(car) : null, - addLabel: "Add service", - children: data.services - .map((s) => _ServiceTile( - record: s, - onEdit: car.canWrite ? () => _editService(car, s) : null, - onDelete: car.canWrite ? () => _deleteService(s) : null, - )) - .toList(), - ), - // Technical check history tab - _TabList( - empty: data.technicalChecks.isEmpty ? "No technical checks yet." : null, - onAdd: car.canWrite - ? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car)) - : null, - addLabel: "Add check", - children: data.technicalChecks - .map((c) => _TechnicalCheckTile( - check: c, - onEdit: car.canWrite - ? () => _sheet( - TechnicalCheckSheet(carId: car.id, car: car, check: c)) - : null, - onDelete: car.canWrite - ? () => _deleteRecord("technical check", - () => apiClient.deleteTechnicalCheck(c.id)) - : null, - )) - .toList(), - ), - // Maintenance tab - _TabList( - empty: data.maintenance.isEmpty ? "No workshop visits yet." : null, - onAdd: - car.canWrite ? () => _sheet(MaintenanceSheet(carId: car.id)) : null, - addLabel: "Log visit", - children: data.maintenance - .map((m) => _MaintenanceTile( - entry: m, - onEdit: car.canWrite - ? () => _sheet(MaintenanceSheet(carId: car.id, entry: m)) - : null, - onDelete: car.canWrite - ? () => _deleteRecord("workshop visit", - () => apiClient.deleteMaintenance(m.id)) - : null, - )) - .toList(), - ), - // Fuel tab — the stats panel sits above the refill list. - _TabList( - empty: null, - onAdd: car.canWrite ? () => _sheet(FuelSheet(carId: car.id)) : null, - addLabel: "Log refill", - children: [ - _FuelStatsPanel(stats: data.fuelStats), - const SizedBox(height: 8), - if (data.fuel.isEmpty) - const _Empty("No refills yet.") - 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)) - : null, - )), - ], - ), - // Documents tab - _TabList( - empty: data.documents.isEmpty ? "No documents yet." : null, - onAdd: car.canWrite ? () => _sheet(DocumentSheet(carId: car.id)) : null, - addLabel: "Add document", - 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)) - : null, - )) - .toList(), - ), - // Parts catalog tab - _TabList( - empty: data.parts.isEmpty ? "No parts yet." : null, - onAdd: car.canWrite ? () => _addPart(car) : null, - addLabel: "Add part", - children: data.parts - .map((p) => _PartTile( - part: p, - onEdit: car.canWrite ? () => _editPart(car, p) : null, - onDelete: car.canWrite ? () => _deletePart(p) : null, - )) - .toList(), - ), - // Reminders tab - _TabList( - empty: data.reminders.isEmpty ? "No reminders yet." : null, - onAdd: car.canWrite - ? () => _sheet(ReminderSheet(carId: car.id, car: car)) - : null, - addLabel: "Add reminder", - children: data.reminders - .map((r) => _ReminderTile( - reminder: r, - // Auto reminders are derived from a document or - // service record, so they are read-only here — - // edit the record they came from instead. - onEdit: car.canWrite && !r.auto - ? () => _sheet( - ReminderSheet(carId: car.id, car: car, reminder: r)) - : null, - onDelete: car.canWrite && !r.auto - ? () => _deleteRecord( - "reminder", () => apiClient.deleteReminder(r.id)) - : null, - onComplete: car.canWrite && !r.auto && !r.done - ? () => _completeReminder(r) - : null, - )) - .toList(), - ), - ], + children: [for (final key in tabKeys) _tabBody(key, data)], ), ), ], @@ -515,6 +406,221 @@ class _CarDetailScreenState extends State { ), ); } + + // --- which tabs this car shows ------------------------------------------- + + /// Whether the connected-service tab is on offer. It shows for a linked car, + /// and also for an unlinked one as long as this user has some manufacturer + /// account connected — that is where the tab offers to link it. A user with + /// nothing connected never sees the tab at all. + bool _showProviderTab(Car car, List providers) => + car.provider.isNotEmpty || providers.any((p) => p.connected); + + /// The linked provider's display name ("MyToyota"), falling back to the raw + /// plugin name and then to the generic tab label. + String _providerLabel(Car car, List providers) { + for (final p in providers) { + if (p.id == car.provider) return p.label; + } + return car.provider.isEmpty ? t("car.tabs.connected") : car.provider; + } + + /// The tabs to show, in this car's arrangement: its stored order applied to + /// the catalogue, minus the ones switched off, minus the connected service + /// when it does not apply. + List _tabKeys(Car car, List providers) => + arrangeKeys(kCarTabKeys, car.tabOrder) + .where((key) => !car.hiddenTabs.contains(key)) + .where((key) => key != "provider" || _showProviderTab(car, providers)) + .toList(); + + String _tabLabel(String key, Car car, List providers) => + key == "provider" ? _providerLabel(car, providers) : t("car.tabs.$key"); + + Future _openViewPicker(Car car, List providers) async { + final updated = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CarViewSheet( + car: car, + showProvider: _showProviderTab(car, providers), + providerLabel: car.provider.isEmpty ? "" : _providerLabel(car, providers), + ), + ); + if (updated != null) _reload(); + } + + /// One tab's content, by key. The keys are the web app's, so the two apps + /// cannot drift on what a stored arrangement means. + Widget _tabBody(String key, _CarDetailData data) { + final car = data.car; + final latest = data.services.isNotEmpty ? data.services.first : null; + + switch (key) { + case "provider": + return ProviderTab(car: car, onCarUpdated: (_) => _reload()); + + case "info": + return _InfoTab(car: car, latest: latest); + + case "services": + return _TabList( + empty: data.services.isEmpty ? "No service records yet." : null, + onAdd: car.canWrite ? () => _addService(car) : null, + addLabel: "Add service", + children: data.services + .map((s) => _ServiceTile( + record: s, + onEdit: car.canWrite ? () => _editService(car, s) : null, + onDelete: car.canWrite ? () => _deleteService(s) : null, + )) + .toList(), + ); + + case "technical": + return _TabList( + empty: data.technicalChecks.isEmpty ? "No technical checks yet." : null, + onAdd: car.canWrite ? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car)) : null, + addLabel: "Add check", + children: data.technicalChecks + .map((c) => _TechnicalCheckTile( + check: c, + onEdit: car.canWrite + ? () => _sheet(TechnicalCheckSheet(carId: car.id, car: car, check: c)) + : null, + onDelete: car.canWrite + ? () => _deleteRecord( + "technical check", () => apiClient.deleteTechnicalCheck(c.id)) + : null, + )) + .toList(), + ); + + case "maintenance": + return _TabList( + empty: data.maintenance.isEmpty ? "No workshop visits yet." : null, + onAdd: car.canWrite ? () => _sheet(MaintenanceSheet(carId: car.id)) : null, + addLabel: "Log visit", + children: data.maintenance + .map((m) => _MaintenanceTile( + entry: m, + onEdit: car.canWrite + ? () => _sheet(MaintenanceSheet(carId: car.id, entry: m)) + : null, + onDelete: car.canWrite + ? () => _deleteRecord( + "workshop visit", () => apiClient.deleteMaintenance(m.id)) + : null, + )) + .toList(), + ); + + // The stats panel sits above the refill list. + case "fuel": + return _TabList( + empty: null, + onAdd: car.canWrite ? () => _sheet(FuelSheet(carId: car.id)) : null, + addLabel: "Log refill", + children: [ + _FuelStatsPanel(stats: data.fuelStats), + const SizedBox(height: 8), + if (data.fuel.isEmpty) + const _Empty("No refills yet.") + 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)) + : null, + )), + ], + ); + + // The electric twin of the fuel tab, laid out the same way. + case "charging": + return _TabList( + empty: null, + onAdd: car.canWrite ? () => _sheet(ChargingSheet(carId: car.id)) : null, + addLabel: t("car.charging.add"), + children: [ + _ChargingStatsPanel(stats: data.chargingStats), + const SizedBox(height: 8), + if (data.charging.isEmpty) + _Empty(t("car.charging.empty")) + else + ...data.charging.reversed.map((c) => _ChargingTile( + entry: c, + onEdit: car.canWrite + ? () => _sheet(ChargingSheet(carId: car.id, entry: c)) + : null, + onDelete: car.canWrite + ? () => _deleteRecord( + "charge", () => apiClient.deleteChargingSession(c.id)) + : null, + )), + ], + ); + + case "documents": + return _TabList( + empty: data.documents.isEmpty ? "No documents yet." : null, + onAdd: car.canWrite ? () => _sheet(DocumentSheet(carId: car.id)) : null, + addLabel: "Add document", + 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)) + : null, + )) + .toList(), + ); + + case "parts": + return _TabList( + empty: data.parts.isEmpty ? "No parts yet." : null, + onAdd: car.canWrite ? () => _addPart(car) : null, + addLabel: "Add part", + children: data.parts + .map((p) => _PartTile( + part: p, + onEdit: car.canWrite ? () => _editPart(car, p) : null, + onDelete: car.canWrite ? () => _deletePart(p) : null, + )) + .toList(), + ); + + case "reminders": + return _TabList( + empty: data.reminders.isEmpty ? "No reminders yet." : null, + onAdd: car.canWrite ? () => _sheet(ReminderSheet(carId: car.id, car: car)) : null, + addLabel: "Add reminder", + children: data.reminders + .map((r) => _ReminderTile( + reminder: r, + // Auto reminders are derived from a document or service + // record, so they are read-only here — edit the record they + // came from instead. + onEdit: car.canWrite && !r.auto + ? () => _sheet(ReminderSheet(carId: car.id, car: car, reminder: r)) + : null, + onDelete: car.canWrite && !r.auto + ? () => _deleteRecord("reminder", () => apiClient.deleteReminder(r.id)) + : null, + onComplete: car.canWrite && !r.auto && !r.done + ? () => _completeReminder(r) + : null, + )) + .toList(), + ); + } + // Unreachable for the keys above; a key from a newer release reaching an + // older build lands here rather than taking the page down. + return const SizedBox.shrink(); + } } /// A scrollable tab body: an optional "+ Add" button, an empty-state message, or @@ -591,13 +697,44 @@ class _StatusHeader extends StatelessWidget { /// Information tab: the car's spec details (oil specs, odometer, interval, next /// due, VIN) in a scrollable card. +/// +/// Which rows show and in which order belongs to the car, not to this device — +/// everyone it is shared with sees the same page. Both are edited in the view +/// picker (see [CarViewSheet]). class _InfoTab extends StatelessWidget { final Car car; final ServiceRecord? latest; const _InfoTab({required this.car, required this.latest}); + /// Every row this tab can show, by the key the car's arrangement names it by. + /// Keys mirror hideableCarFields in the API's cars.go and car.info.* in the + /// translation files. + Map _values() => { + "oilSpec": _orDash(car.oilSpec), + "transmissionOil": _orDash(car.transmissionOilSpec), + "differentialOil": _orDash(car.differentialOilSpec), + "brakeFluid": _orDash(car.brakeFluidSpec), + "coolant": _orDash(car.coolantSpec), + "odometer": formatKm(car.currentKm), + "serviceInterval": + "${car.serviceIntervalDays} days · ${formatKm(car.serviceIntervalKm)}", + "nextDue": + "${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}", + "registrationPlate": _orDash(car.registration), + "registrationCountry": _orDash(car.registrationCountry), + "vin": _orDash(car.vin), + "fuelType": _fuelLabel(car.fuelType), + "buildDate": _dateOrDash(car.buildDate), + "firstRegistration": _dateOrDash(car.firstRegistrationDate), + }; + @override Widget build(BuildContext context) { + final values = _values(); + final keys = arrangeKeys(kCarInfoFieldKeys, car.fieldOrder) + .where((key) => !car.hiddenFields.contains(key)) + .toList(); + return ListView( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), children: [ @@ -612,22 +749,22 @@ class _InfoTab extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _kv(context, "Engine oil spec", _orDash(car.oilSpec)), - _kv(context, "Transmission oil spec", _orDash(car.transmissionOilSpec)), - _kv(context, "Differential oil spec", _orDash(car.differentialOilSpec)), - _kv(context, "Brake fluid spec", _orDash(car.brakeFluidSpec)), - _kv(context, "Coolant spec", _orDash(car.coolantSpec)), - _kv(context, "Current odometer", formatKm(car.currentKm)), - _kv(context, "Service interval", "${car.serviceIntervalDays} days · ${formatKm(car.serviceIntervalKm)}"), - _kv(context, "Technical check interval", - "${car.technicalCheckIntervalDays > 0 ? car.technicalCheckIntervalDays : 365} days"), - _kv(context, "Next due", "${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}"), - _kv(context, "Registration plate", _orDash(car.registration)), - _kv(context, "Registration country", _orDash(car.registrationCountry)), - _kv(context, "VIN", _orDash(car.vin)), - _kv(context, "Fuel type", _fuelLabel(car.fuelType)), - _kv(context, "Build date", _dateOrDash(car.buildDate)), - _kv(context, "First registration", _dateOrDash(car.firstRegistrationDate)), + if (keys.isEmpty) + Text(t("car.info.allHidden"), + style: TextStyle(color: DriverVault.muted(context))) + else + for (final key in keys) ...[ + _kv(context, t("car.info.$key"), values[key] ?? "—"), + // The roadworthiness cycle is the car's other interval, and + // reads as a footnote to the service one rather than a row + // of its own — so it follows it, and goes when it goes. + if (key == "serviceInterval") + _kv( + context, + t("car.info.technicalCheckInterval"), + "${car.technicalCheckIntervalDays > 0 ? car.technicalCheckIntervalDays : 365} days", + ), + ], ], ), ), @@ -1082,6 +1219,145 @@ class _FuelStatsPanel extends StatelessWidget { ); } +/// The charging summary, the electric twin of [_FuelStatsPanel]. Same caveat on +/// the tracked distance: it covers the full-charge windows only. +class _ChargingStatsPanel extends StatelessWidget { + final ChargingStats stats; + const _ChargingStatsPanel({required this.stats}); + + @override + Widget build(BuildContext context) { + if (stats.entries == 0) return const SizedBox.shrink(); + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.charging.title"), style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _stat(context, t("car.charging.average"), + formatKwhConsumption(stats.avgConsumptionKwh100)), + _stat(context, t("car.charging.best"), + formatKwhConsumption(stats.bestConsumptionKwh100)), + _stat(context, t("car.charging.worst"), + formatKwhConsumption(stats.worstConsumptionKwh100)), + _stat(context, t("car.charging.average"), formatKmPerKwh(stats.avgKmPerKwh)), + _stat(context, t("car.charging.costPerKm"), formatMoney(stats.costPerKm)), + _stat(context, t("car.charging.colPerKwh"), formatMoney(stats.avgPricePerKwh)), + _stat(context, t("car.charging.totalKwh"), formatKwh(stats.totalKwh)), + _stat(context, t("car.charging.totalSpent"), formatMoney(stats.totalCost)), + _stat(context, t("car.charging.sessions"), "${stats.entries}"), + _stat(context, t("car.charging.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 charge. + _sub(context, t("car.charging.subtitle")), + ], + ), + ); + } + + Widget _stat(BuildContext context, String label, String value) => SizedBox( + width: 150, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: DriverVault.muted(context))), + Text(value, + style: DriverVault.mono(context, + size: 13, + weight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ); +} + +class _ChargingTile extends StatelessWidget { + final ChargingSession entry; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _ChargingTile({required this.entry, this.onEdit, this.onDelete}); + + @override + Widget build(BuildContext context) { + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDate(entry.date), style: const TextStyle(fontWeight: FontWeight.w600)), + Row(children: [ + Text(formatKm(entry.km), style: const TextStyle(fontSize: 13)), + if (onEdit != null || onDelete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete), + ]), + ], + ), + const SizedBox(height: 4), + _sub( + context, + [ + formatKwh(entry.kwh), + if (entry.cost > 0) formatMoney(entry.cost), + if (entry.pricePerKwh != null) + "${entry.pricePerKwh!.toStringAsFixed(3)}/kWh", + ].join(" · ")), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 6, children: [ + _tag(context, entry.fullCharge ? t("forms.charging.fullCharge") : t("car.charging.partial"), + muted: !entry.fullCharge), + if (entry.missedSession) _tag(context, t("car.charging.gap"), warn: true), + if (entry.location.isNotEmpty) _tag(context, entry.location, muted: true), + ]), + // Consumption exists only on a full charge that closes a computable + // window; anything else has nothing to report. + if (entry.consumptionKwh100 != null) ...[ + const SizedBox(height: 6), + Text( + "${formatKwhConsumption(entry.consumptionKwh100)} · ${formatKmPerKwh(entry.kmPerKwh)}" + " over ${formatKm(entry.distanceKm)}", + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + _AttachmentLine(path: "/charging-sessions", id: entry.id, record: entry), + if (entry.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(entry.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ); + } + + Widget _tag(BuildContext context, String label, {bool muted = false, bool warn = false}) { + final dark = DriverVault.isDark(context); + final Color bg, fg; + if (warn) { + bg = dark ? DriverVault.warningSoftDark : DriverVault.warningSoft; + fg = DriverVault.warning; + } else if (muted) { + bg = dark ? DriverVault.darkSunken : DriverVault.ink50; + fg = DriverVault.muted(context); + } else { + bg = dark ? DriverVault.successSoftDark : DriverVault.successSoft; + fg = DriverVault.success; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + child: Text(label, style: TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.w600)), + ); + } +} + class _FuelTile extends StatelessWidget { final FuelEntry entry; final VoidCallback? onEdit; diff --git a/Phone App/lib/screens/car_view_sheet.dart b/Phone App/lib/screens/car_view_sheet.dart new file mode 100644 index 0000000..fe2122c --- /dev/null +++ b/Phone App/lib/screens/car_view_sheet.dart @@ -0,0 +1,332 @@ +import "package:flutter/material.dart"; + +import "../i18n.dart"; +import "../main.dart"; +import "../models.dart"; +import "../theme.dart"; + +/// Every tab a car's page can show, in the order they appear when the car has no +/// arrangement of its own. Information sits second because the connected +/// service, when there is one, is what you came to look at. +/// +/// Mirrors ALL_TAB_KEYS in the web CarDetail and arrangeableCarTabs in the API's +/// cars.go — the server rejects any key outside that set. +const List kCarTabKeys = [ + "provider", + "info", + "services", + "technical", + "maintenance", + "fuel", + "charging", + "documents", + "parts", + "reminders", +]; + +/// The tabs that can be switched off. "info" is deliberately absent: it is the +/// car itself, and a page with no tabs left would be a dead end. It can still be +/// moved, which is why the two lists are separate. +const List kHideableCarTabKeys = [ + "provider", + "services", + "technical", + "maintenance", + "fuel", + "charging", + "documents", + "parts", + "reminders", +]; + +/// The Information rows, in their default order. Keys mirror hideableCarFields +/// in the API's cars.go. +const List kCarInfoFieldKeys = [ + "oilSpec", + "transmissionOil", + "differentialOil", + "brakeFluid", + "coolant", + "odometer", + "serviceInterval", + "nextDue", + "registrationPlate", + "registrationCountry", + "vin", + "fuelType", + "buildDate", + "firstRegistration", +]; + +/// Applies a car's stored arrangement to a catalogue of keys. +/// +/// The stored order may be partial and may name keys this release does not know: +/// anything unrecognised is dropped, and any key it leaves out follows the ones +/// it names. That is what puts a tab or row added in a later release at the end +/// of somebody's page rather than in the middle of it. +List arrangeKeys(List catalogue, List order) { + final arranged = []; + for (final key in order) { + if (catalogue.contains(key) && !arranged.contains(key)) arranged.add(key); + } + arranged.addAll(catalogue.where((k) => !arranged.contains(k))); + return arranged; +} + +/// What this car's page shows: which tabs and Information rows are on, and the +/// order of each. Mirrors the web's view picker, except that the web rearranges +/// by dragging the tab bar and the rows themselves — a gesture the tab bar owns +/// on a touch screen — so both arrangements are made here instead, with a handle. +/// +/// Edited as a draft and saved in one write rather than on every checkbox: +/// switching several off one at a time would make the page rearrange under the +/// finger between taps. Pops the updated [Car] when it saved. +class CarViewSheet extends StatefulWidget { + final Car car; + + /// Whether this car could show a connected-service tab at all. Offering to + /// hide a tab nobody can see would just be confusing. + final bool showProvider; + + /// The provider's display name ("MyToyota"), used for that tab's label. + final String providerLabel; + + const CarViewSheet({ + super.key, + required this.car, + required this.showProvider, + this.providerLabel = "", + }); + + @override + State createState() => _CarViewSheetState(); +} + +class _CarViewSheetState extends State { + late List _tabOrder; + late List _fieldOrder; + late Set _visibleTabs; + late Set _visibleFields; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final car = widget.car; + _tabOrder = arrangeKeys(kCarTabKeys, car.tabOrder); + _fieldOrder = arrangeKeys(kCarInfoFieldKeys, car.fieldOrder); + _visibleTabs = kHideableCarTabKeys.where((k) => !car.hiddenTabs.contains(k)).toSet(); + _visibleFields = kCarInfoFieldKeys.where((k) => !car.hiddenFields.contains(k)).toSet(); + } + + /// Whether [key] is one of the rows on offer below: Information never is + /// (it cannot be switched off), and the connected service only when this car + /// could show one. + bool _isRow(String key) => + key != "info" && (key != "provider" || widget.showProvider); + + /// 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"); + + /// 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 + /// sequence and everything else keeps the position it already had, so + /// rearranging the rows can never move Information — or a connected-service + /// tab this car cannot show — out from under the user. + void _moveTab(int oldIndex, int newIndex) { + final rows = _tabRows; + rows.insert(newIndex, rows.removeAt(oldIndex)); + setState(() { + var next = 0; + _tabOrder = [ + for (final key in _tabOrder) _isRow(key) ? rows[next++] : key, + ]; + }); + } + + void _moveField(int oldIndex, int newIndex) { + setState(() => _fieldOrder.insert(newIndex, _fieldOrder.removeAt(oldIndex))); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + try { + final car = await apiClient.updateCarView( + widget.car.id, + hiddenTabs: kHideableCarTabKeys.where((k) => !_visibleTabs.contains(k)).toList(), + hiddenFields: kCarInfoFieldKeys.where((k) => !_visibleFields.contains(k)).toList(), + tabOrder: _tabOrder, + fieldOrder: _fieldOrder, + ); + if (mounted) Navigator.pop(context, car); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final inset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.only(bottom: inset), + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + maxChildSize: 0.95, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Text(t("car.viewPicker.title"), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + Text(t("car.viewPicker.subtitle"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + const SizedBox(height: 16), + _heading(context, t("car.viewPicker.tabsHeading")), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: _moveTab, + children: [ + for (var i = 0; i < _tabRows.length; i++) + _row( + context, + index: i, + key: _tabRows[i], + label: _tabLabel(_tabRows[i]), + on: _visibleTabs.contains(_tabRows[i]), + onChanged: (v) => setState(() => v + ? _visibleTabs.add(_tabRows[i]) + : _visibleTabs.remove(_tabRows[i])), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + "${t("car.viewPicker.alwaysOn", params: {"tab": t("car.tabs.info")})}" + " ${t("car.viewPicker.reorderHint")}", + style: TextStyle(fontSize: 11, color: DriverVault.muted(context)), + ), + ), + const SizedBox(height: 20), + _heading(context, t("car.viewPicker.fieldsHeading")), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: _moveField, + children: [ + for (var i = 0; i < _fieldOrder.length; i++) + _row( + context, + index: i, + key: _fieldOrder[i], + label: t("car.info.${_fieldOrder[i]}"), + on: _visibleFields.contains(_fieldOrder[i]), + onChanged: (v) => setState(() => v + ? _visibleFields.add(_fieldOrder[i]) + : _visibleFields.remove(_fieldOrder[i])), + ), + ], + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + ], + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context), + child: Text(t("common.cancel")), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _saving ? null : _save, + child: Text(_saving ? t("common.saving") : t("common.save")), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _heading(BuildContext context, String text) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(text.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: DriverVault.muted(context))), + ); + + /// One switchable, draggable row. The handle is explicit rather than + /// long-press-anywhere: the row's own tap target is the checkbox, and a + /// long-press drag would fight it. + Widget _row( + BuildContext context, { + required int index, + required String key, + required String label, + required bool on, + required ValueChanged onChanged, + }) => + Padding( + key: ValueKey(key), + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Checkbox(value: on, onChanged: (v) => onChanged(v ?? false)), + Expanded(child: Text(label, style: const TextStyle(fontSize: 14))), + ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.drag_handle, color: DriverVault.muted(context)), + ), + ), + ], + ), + ); +} diff --git a/Phone App/lib/screens/provider_tab.dart b/Phone App/lib/screens/provider_tab.dart new file mode 100644 index 0000000..442bc36 --- /dev/null +++ b/Phone App/lib/screens/provider_tab.dart @@ -0,0 +1,630 @@ +import "package:flutter/material.dart"; + +import "../format.dart"; +import "../i18n.dart"; +import "../main.dart"; +import "../models.dart"; +import "../theme.dart"; + +/// The connected-service tab on a car: everything the manufacturer's own app +/// knows about it, fetched live. Mirrors the web ProviderPanel. +/// +/// Two things shape what is below. The data is read under *this* user's account +/// — so a car shared from someone else shows nothing here unless the vehicle is +/// on this user's account too, and the tab says so rather than failing. And the +/// sections are whatever the plugin exposes, rendered from the server's +/// flattened key/value pairs: nothing here knows a single upstream field name, +/// so a provider adding a field surfaces it without a change to this file. +/// +/// Unlike the web, the headline readings cannot be rearranged here — a drag on a +/// touch screen is the tab bar's gesture. The stored arrangement is still +/// honoured, so an order set on the web carries over. +class ProviderTab extends StatefulWidget { + final Car car; + + /// Called with the updated car after a link, unlink or odometer sync, so the + /// page holds one copy of it and the tab bar can pick up a new provider link. + final void Function(Car) onCarUpdated; + + const ProviderTab({super.key, required this.car, required this.onCarUpdated}); + + @override + State createState() => _ProviderTabState(); +} + +class _ProviderTabState extends State { + ProviderSnapshot? _snap; + bool _loading = false; + bool _syncing = false; + String? _error; + + // The connect flow, for a car with no link yet (added by hand, or from before + // the feature existed). + List _providers = const []; + String _linkProvider = ""; + List _linkVehicles = const []; + String _linkVehicleId = ""; + bool _linkLoading = false; + bool _linking = false; + + bool get _linked => widget.car.provider.isNotEmpty; + + List get _connectable => + _providers.where((p) => p.connected).toList(); + + String get _label { + final fromSnap = _snap?.label ?? ""; + if (fromSnap.isNotEmpty) return fromSnap; + for (final p in _providers) { + if (p.id == widget.car.provider) return p.label; + } + return widget.car.provider.isEmpty ? t("car.tabs.connected") : widget.car.provider; + } + + @override + void initState() { + super.initState(); + if (_linked) { + _load(); + } else { + _loadProviders(); + } + } + + @override + void didUpdateWidget(ProviderTab old) { + super.didUpdateWidget(old); + // Linking from the connect card replaces the car, which is when the live + // snapshot becomes fetchable at all. + if (old.car.provider != widget.car.provider) { + if (_linked) { + _load(); + } else { + setState(() => _snap = null); + _loadProviders(); + } + } + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final snap = await apiClient.getCarProvider(widget.car.id); + if (mounted) setState(() => _snap = snap); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _loadProviders() async { + try { + final list = await apiClient.listVehicleProviders(); + if (!mounted) return; + setState(() { + _providers = list; + _linkProvider = _connectable.isEmpty ? "" : _connectable.first.id; + }); + await _loadLinkVehicles(); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + Future _loadLinkVehicles() async { + setState(() { + _linkVehicles = const []; + _linkVehicleId = ""; + }); + if (_linkProvider.isEmpty) return; + setState(() => _linkLoading = true); + try { + final list = await apiClient.listProviderVehicles(_linkProvider); + if (!mounted) return; + // Prefer the vehicle whose VIN matches the car — usually the only guess + // needed. + final vin = widget.car.vin.trim().toUpperCase(); + String pick = list.isEmpty ? "" : list.first.id; + if (vin.isNotEmpty) { + for (final v in list) { + if (v.vin.toUpperCase() == vin) { + pick = v.id; + break; + } + } + } + setState(() { + _linkVehicles = list; + _linkVehicleId = pick; + }); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _linkLoading = false); + } + } + + Future _connect() async { + if (_linkVehicleId.isEmpty) return; + setState(() { + _linking = true; + _error = null; + }); + try { + final car = await apiClient.linkCarProvider(widget.car.id, + provider: _linkProvider, vehicleId: _linkVehicleId); + widget.onCarUpdated(car); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _linking = false); + } + } + + Future _disconnect() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(t("car.provider.unlinkConfirm", params: {"label": _label})), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(t("car.provider.unlink")), + ), + ], + ), + ); + if (ok != true) return; + setState(() => _error = null); + try { + final car = await apiClient.linkCarProvider(widget.car.id); + if (mounted) setState(() => _snap = null); + widget.onCarUpdated(car); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + /// Only the odometer is written back: it is the one provider reading the rest + /// of the app computes from (service intervals, km-triggered reminders), and + /// the one the user would otherwise retype after every drive. + Future _applyOdometer() async { + setState(() { + _syncing = true; + _error = null; + }); + try { + final car = await apiClient.syncCarProvider(widget.car.id, include: { + "identity": false, + "fuelType": false, + "dates": false, + "odometer": true, + }); + widget.onCarUpdated(car); + await _load(); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _syncing = false); + } + } + + /// The readings in the car's stored arrangement. A reading the arrangement + /// does not name — one the provider only started reporting later — follows the + /// arranged ones rather than appearing in the middle. + List get _metrics { + final reported = _snap?.metrics ?? const []; + final order = widget.car.metricOrder; + if (order.isEmpty) return reported; + final rank = {for (var i = 0; i < order.length; i++) order[i]: i}; + final list = [...reported]; + // A stable sort, so two unarranged readings keep the order they came in. + list.sort((a, b) => (rank[a.key] ?? order.length).compareTo(rank[b.key] ?? order.length)); + return list; + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + children: [ + if (_error != null) ...[ + _ErrorBanner(_error!), + const SizedBox(height: 12), + ], + if (_linked) ..._linkedBody(context) else ..._connectBody(context), + ], + ); + } + + // --- linked: the live snapshot ------------------------------------------- + + List _linkedBody(BuildContext context) { + final snap = _snap; + return [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_label, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + Text(t("car.provider.subtitle", params: {"label": _label}), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + ], + ), + ), + IconButton( + tooltip: _loading ? t("car.provider.refreshing") : t("car.provider.refresh"), + icon: const Icon(Icons.refresh), + onPressed: _loading ? null : _load, + ), + if (widget.car.canWrite) + IconButton( + tooltip: t("car.provider.unlink"), + icon: const Icon(Icons.link_off, color: DriverVault.danger), + onPressed: _disconnect, + ), + ], + ), + const SizedBox(height: 8), + if (snap == null) + const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())) + else if (snap.unavailable) + _Card( + child: Column( + children: [ + Text(snap.detail, + textAlign: TextAlign.center, + style: const TextStyle(color: DriverVault.warning)), + const SizedBox(height: 6), + Text(t("car.provider.own"), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: DriverVault.muted(context))), + ], + ), + ) + else ...[ + if (_metrics.isNotEmpty) _readings(context), + if (snap.suggestedCurrentKm > 0 && widget.car.canWrite) _odometerOffer(context, snap), + if (snap.vehicle != null) _vehicleCard(context, snap.vehicle!), + ...snap.sections.map((s) => _sectionCard(context, s)), + const SizedBox(height: 8), + Text( + "${t("car.provider.updated", params: {"time": formatDateTime(snap.fetchedAt)})}" + " · ${t("car.provider.own")}", + style: TextStyle(fontSize: 11, color: DriverVault.muted(context)), + ), + ], + ]; + } + + Widget _readings(BuildContext context) => _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.provider.readings"), + style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + for (final m in _metrics) + SizedBox( + width: 150, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.provider.metrics.${m.key}"), + style: TextStyle(fontSize: 11, color: DriverVault.muted(context))), + Text(m.unit.isEmpty ? m.value : "${m.value} ${m.unit}", + style: DriverVault.mono(context, + size: 13, + weight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ), + ], + ), + ], + ), + ); + + Widget _odometerOffer(BuildContext context, ProviderSnapshot snap) => _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.provider.odometerSuggest", params: { + "label": _label, + "km": formatKm(snap.suggestedCurrentKm), + })), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _syncing ? null : _applyOdometer, + child: Text(_syncing + ? t("car.provider.updatingOdometer") + : t("car.provider.updateOdometer")), + ), + ), + ], + ), + ); + + Widget _vehicleCard(BuildContext context, ProviderVehicle v) => _Card( + padding: EdgeInsets.zero, + child: ExpansionTile( + initiallyExpanded: true, + shape: const Border(), + collapsedShape: const Border(), + title: Text(t("car.provider.vehicle"), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(v.name, style: const TextStyle(fontWeight: FontWeight.w700)), + if (v.subtitle.isNotEmpty) + Text(v.subtitle, + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + if (v.vin.isNotEmpty) + Text(v.vin, + style: DriverVault.mono(context, + size: 11, color: DriverVault.muted(context))), + ], + ), + ), + if (v.imageUrl.isNotEmpty) + // The provider's own CDN, so it can fail on a phone with no + // route to it — a broken image must not take the tab with it. + Image.network(v.imageUrl, + height: 64, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink()), + ], + ), + if (v.fields.isNotEmpty) _fieldsExpander(context, t("car.provider.allFields"), v.fields), + ], + ), + ); + + Widget _sectionCard(BuildContext context, ProviderSection sec) => _Card( + padding: EdgeInsets.zero, + child: ExpansionTile( + shape: const Border(), + collapsedShape: const Border(), + title: Row( + children: [ + Expanded( + child: Text(t("car.provider.sections.${sec.id}"), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + ), + // A failed section says so in the collapsed header, but only as a + // badge: the provider's own message can run to hundreds of + // characters, which belongs in the body with the rest of it. + if (sec.status == "error") + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: DriverVault.isDark(context) + ? DriverVault.warningSoftDark + : DriverVault.warningSoft, + borderRadius: BorderRadius.circular(6), + ), + child: Text(t("car.provider.sectionFailed"), + style: const TextStyle(fontSize: 11, color: DriverVault.warning)), + ), + ], + ), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + children: [ + if (sec.status == "error") + Align( + alignment: Alignment.centerLeft, + child: Text(sec.error, + style: const TextStyle(fontSize: 12, color: DriverVault.warning)), + ) + else if (sec.status == "empty") + Align( + alignment: Alignment.centerLeft, + child: Text(t("car.provider.sectionEmpty"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + ) + else ...[ + _fieldList(context, sec.fields), + if (sec.truncated) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + t("car.provider.truncated", params: {"n": sec.fields.length}), + style: const TextStyle(fontSize: 11, color: DriverVault.warning), + ), + ), + ], + ], + ), + ); + + Widget _fieldsExpander(BuildContext context, String label, List fields) => + Theme( + // The nested expander would otherwise draw its own divider lines inside + // the card it is already sitting in. + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: DriverVault.brandOnTint(context))), + childrenPadding: EdgeInsets.zero, + children: [_fieldList(context, fields)], + ), + ); + + Widget _fieldList(BuildContext context, List fields) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final f in fields) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text(f.key, + style: DriverVault.mono(context, + size: 11, color: DriverVault.muted(context))), + ), + Expanded(child: Text(f.value, style: const TextStyle(fontSize: 12))), + ], + ), + ), + ], + ); + + // --- not linked: offer to connect this car to a vehicle ------------------- + + List _connectBody(BuildContext context) { + if (_connectable.isEmpty) { + return [ + _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.provider.connectTitle"), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + Text(t("car.provider.noProviders"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + ], + ), + ), + ]; + } + return [ + _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("car.provider.connectTitle"), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(t("car.provider.connectHint"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))), + const SizedBox(height: 12), + if (_connectable.length > 1) ...[ + DropdownButtonFormField( + initialValue: _linkProvider.isEmpty ? null : _linkProvider, + decoration: InputDecoration( + labelText: t("forms.import.service"), border: const OutlineInputBorder()), + items: [ + for (final p in _connectable) + DropdownMenuItem(value: p.id, child: Text(p.label)), + ], + onChanged: (v) { + if (v == null) return; + setState(() => _linkProvider = v); + _loadLinkVehicles(); + }, + ), + const SizedBox(height: 12), + ], + if (_linkLoading) + Text(t("forms.import.loadingVehicles"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))) + else if (_linkVehicles.isEmpty) + Text(t("forms.import.noVehicles"), + style: TextStyle(fontSize: 12, color: DriverVault.muted(context))) + else + DropdownButtonFormField( + key: ValueKey("$_linkProvider/$_linkVehicleId"), + initialValue: _linkVehicleId.isEmpty ? null : _linkVehicleId, + isExpanded: true, + decoration: InputDecoration( + labelText: t("forms.import.selectVehicle"), + border: const OutlineInputBorder()), + items: [ + for (final v in _linkVehicles) + DropdownMenuItem( + value: v.id, + child: Text(v.vin.isEmpty ? v.name : "${v.name} · ${v.vin}", + overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() => _linkVehicleId = v ?? ""), + ), + if (widget.car.canWrite) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _linkVehicleId.isEmpty || _linking ? null : _connect, + child: Text(_linking + ? t("car.provider.connecting") + : t("car.provider.connectSubmit")), + ), + ), + ], + ], + ), + ), + ]; + } +} + +/// The card chrome the rest of the car screen uses, repeated here so this tab +/// sits flush with the others. +class _Card extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry padding; + const _Card({required this.child, this.padding = const EdgeInsets.all(14)}); + + @override + Widget build(BuildContext context) => Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: Padding(padding: padding, child: child), + ); +} + +class _ErrorBanner extends StatelessWidget { + final String message; + const _ErrorBanner(this.message); + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) + ? DriverVault.dangerSoftDark + : DriverVault.dangerSoft, + borderRadius: BorderRadius.circular(10), + ), + child: Text(message, + style: const TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w500)), + ); +} diff --git a/Phone App/lib/screens/record_form_sheets.dart b/Phone App/lib/screens/record_form_sheets.dart index 3d276a1..650a630 100644 --- a/Phone App/lib/screens/record_form_sheets.dart +++ b/Phone App/lib/screens/record_form_sheets.dart @@ -2,15 +2,16 @@ import "package:flutter/material.dart"; import "../api.dart"; import "../format.dart"; +import "../i18n.dart"; import "../main.dart"; import "../models.dart"; import "../theme.dart"; import "../widgets/attachment_field.dart"; // The bottom-sheet forms for the record types behind the car's tabs: technical -// checks, refills, workshop visits, documents and reminders. Each mirrors the -// web app's matching modal, and pops `true` when it saved so the car detail -// screen can reload. +// checks, refills, charges, workshop visits, documents and reminders. Each +// mirrors the web app's matching modal, and pops `true` when it saved so the car +// detail screen can reload. /// Serializes a picked day as UTC midnight, matching what the web app sends for /// the same field. These are date-only concepts, so the day the user tapped is @@ -502,6 +503,189 @@ class _FuelSheetState extends State { } } +// --- charging --- + +/// Mirrors the web ChargingFormModal — one charge of an EV. The electric twin of +/// [FuelSheet], and derived the same way: consumption is measured between full +/// charges, so a top-up rolls into the next full one. +class ChargingSheet extends StatefulWidget { + final String carId; + final ChargingSession? entry; // null => create + const ChargingSheet({super.key, required this.carId, this.entry}); + + @override + State createState() => _ChargingSheetState(); +} + +class _ChargingSheetState extends State { + late DateTime _date; + late final TextEditingController _km, _kwh, _cost, _location, _notes; + late bool _fullCharge, _missedSession; + final _pending = PendingAttachment(); + bool _saving = false; + String? _error; + + bool get _isEdit => widget.entry != null; + + @override + void initState() { + super.initState(); + final e = widget.entry; + _date = e?.date ?? DateTime.now(); + _km = TextEditingController(text: e == null ? "" : "${e.km}"); + _kwh = TextEditingController(text: (e != null && e.kwh > 0) ? "${e.kwh}" : ""); + _cost = TextEditingController(text: (e != null && e.cost > 0) ? "${e.cost}" : ""); + // A full charge is the common case and the one that makes the entry count + // towards efficiency, so it is the default. + _fullCharge = e?.fullCharge ?? true; + _missedSession = e?.missedSession ?? false; + _location = TextEditingController(text: e?.location ?? ""); + _notes = TextEditingController(text: e?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [_km, _kwh, _cost, _location, _notes]) { + c.dispose(); + } + super.dispose(); + } + + String? get _pricePerKwh { + final k = _numOrNull(_kwh); + final c = _numOrNull(_cost); + if (k == null || c == null || k == 0 || c == 0) return null; + return (c / k).toStringAsFixed(3); + } + + Future _save() async { + final km = _int(_km); + final kwh = _num(_kwh); + if (_km.text.trim().isEmpty || km < 0) { + setState(() => _error = t("forms.charging.odometerRequired")); + return; + } + if (kwh <= 0) { + setState(() => _error = t("forms.charging.kwhRequired")); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "date": _isoDate(_date), + "km": km, + "kwh": kwh, + "cost": _num(_cost), + "fullCharge": _fullCharge, + "missedSession": _missedSession, + "location": _location.text.trim(), + "notes": _notes.text.trim(), + }; + try { + final saved = _isEdit + ? await apiClient.updateChargingSession(widget.entry!.id, payload) + : await apiClient.createChargingSession(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/charging-sessions", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Charge saved, but the receipt did not upload: $e"); + } + } + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final ppk = _pricePerKwh; + return _SheetScaffold( + title: _isEdit ? t("forms.charging.editTitle") : t("forms.charging.addTitle"), + error: _error, + saving: _saving, + saveLabel: t("forms.charging.submit"), + onSave: _save, + children: [ + Row(children: [ + Expanded( + child: _dateField(context, "${t("forms.charging.date")} *", _date, + (v) => setState(() => _date = v ?? _date), + clearable: false), + ), + const SizedBox(width: 8), + Expanded( + child: _field(_km, "${t("forms.charging.odometer")} *", + keyboard: TextInputType.number, hint: "16138"), + ), + ]), + Row(children: [ + Expanded( + child: _field(_kwh, "${t("forms.charging.kwh")} *", + keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "42.5"), + ), + const SizedBox(width: 8), + Expanded( + child: _field(_cost, t("forms.charging.cost"), + keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "85.00"), + ), + ]), + if (ppk != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(t("forms.charging.pricePerKwh", params: {"price": ppk}), + style: Theme.of(context).textTheme.bodySmall), + ), + _group( + context, + t("forms.charging.battery"), + [ + CheckboxListTile( + value: _fullCharge, + onChanged: (v) => setState(() => _fullCharge = v ?? false), + title: Text(t("forms.charging.fullCharge")), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ), + CheckboxListTile( + value: _missedSession, + onChanged: (v) => setState(() => _missedSession = v ?? false), + title: Text(t("forms.charging.missedSession")), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ), + ], + hint: t("forms.charging.batteryHint"), + ), + Row(children: [ + Expanded( + child: _field(_location, t("forms.charging.location"), + hint: t("forms.charging.locationPlaceholder")), + ), + const SizedBox(width: 8), + Expanded(child: _field(_notes, t("forms.charging.notes"))), + ]), + AttachmentField( + path: "/charging-sessions", + record: widget.entry, + recordId: widget.entry?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: t("forms.charging.attachmentLegend"), + ), + ], + ); + } +} + // --- maintenance --- /// Mirrors the web MaintenanceFormModal — a workshop visit or repair, i.e. work diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index b5e5059..7517f58 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -1,7 +1,12 @@ +import "dart:convert"; +import "dart:io"; import "dart:typed_data"; +import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; import "package:image_picker/image_picker.dart"; +import "package:open_filex/open_filex.dart"; +import "package:path_provider/path_provider.dart"; import "../biometric.dart"; import "../i18n.dart"; @@ -10,9 +15,9 @@ import "../models.dart"; import "../format.dart"; import "../theme.dart"; -/// Full settings panel, mirroring the web Settings.vue (minus data export/import): -/// account (name/email/password), appearance (theme/locale/date/font), profile -/// (avatar/bio), privacy (sessions), and account deletion. +/// Full settings panel, mirroring the web Settings.vue: account +/// (name/email/password), appearance (theme/locale/date/font), profile +/// (avatar/bio), privacy (sessions), data export/import, and account deletion. class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @override @@ -136,6 +141,8 @@ class _SettingsScreenState extends State { const SizedBox(height: 12), const _PrivacySection(), const SizedBox(height: 12), + _DataSection(snack: _snack), + const SizedBox(height: 12), _DangerSection(profile: _profile!, onChanged: _load, snack: _snack), const SizedBox(height: 24), ], @@ -1138,6 +1145,185 @@ class _PrivacySection extends StatelessWidget { } } +// --- Data (export / import) ------------------------------------------------- + +/// Export downloads the account as JSON; import adds the cars from a file +/// exported earlier. The web hands the browser a download and a file input; a +/// phone has neither, so the export is written to the app's own directory and +/// offered to whatever the phone opens JSON with, and the import goes through +/// the system file picker. +/// +/// The import always creates new records — nothing is merged or overwritten — +/// so it asks first, with the number of cars the file actually holds. +class _DataSection extends StatefulWidget { + final void Function(String) snack; + const _DataSection({required this.snack}); + @override + State<_DataSection> createState() => _DataSectionState(); +} + +class _DataSectionState extends State<_DataSection> { + bool _exporting = false; + bool _importing = false; + String? _error; + ImportResult? _result; + + /// The last export written, kept so the card can offer to open it: the file + /// lands in the app's documents directory, which the user has no other route + /// to. + File? _exported; + + Future _export() async { + setState(() { + _exporting = true; + _error = null; + _exported = null; + }); + try { + final (bytes, filename) = await apiClient.exportData(); + final dir = await getApplicationDocumentsDirectory(); + final file = File("${dir.path}/$filename"); + await file.writeAsBytes(bytes); + if (!mounted) return; + setState(() => _exported = file); + widget.snack(t("settings.advanced.exported", params: {"file": filename})); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + Future _openExport() async { + final file = _exported; + if (file == null) return; + final res = await OpenFilex.open(file.path); + if (res.type != ResultType.done && mounted) { + widget.snack(t("settings.advanced.openFailed")); + } + } + + Future _import() async { + setState(() { + _error = null; + _result = null; + }); + + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ["json"], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + final file = picked.files.first; + final bytes = file.bytes ?? (file.path != null ? await File(file.path!).readAsBytes() : null); + if (bytes == null) { + setState(() => _error = t("settings.advanced.notJson")); + return; + } + + Map payload; + try { + payload = Map.from(jsonDecode(utf8.decode(bytes)) as Map); + } catch (_) { + setState(() => _error = t("settings.advanced.notJson")); + return; + } + final cars = payload["cars"]; + if (cars is! List || cars.isEmpty) { + setState(() => _error = t("settings.advanced.notExport")); + return; + } + + if (!mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(t("settings.advanced.confirmImport", params: {"count": cars.length})), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t("settings.advanced.importAction")), + ), + ], + ), + ); + if (ok != true) return; + + setState(() => _importing = true); + try { + final res = await apiClient.importData(payload); + if (mounted) setState(() => _result = res); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _importing = false); + } + } + + @override + Widget build(BuildContext context) { + final muted = TextStyle(fontSize: 12, color: DriverVault.muted(context)); + return _Card( + title: t("settings.advanced.title"), + children: [ + Text(t("settings.advanced.exportTitle"), + style: const TextStyle(fontWeight: FontWeight.w600)), + Text(t("settings.advanced.exportBody"), style: muted), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: _exporting ? null : _export, + icon: const Icon(Icons.download_outlined, size: 18), + label: Text(_exporting + ? t("settings.advanced.preparing") + : t("settings.advanced.exportAction")), + ), + if (_exported != null) ...[ + const SizedBox(width: 8), + TextButton(onPressed: _openExport, child: Text(t("settings.advanced.open"))), + ], + ], + ), + const Divider(height: 24), + Text(t("settings.advanced.importTitle"), + style: const TextStyle(fontWeight: FontWeight.w600)), + Text(t("settings.advanced.importBody"), style: muted), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _importing ? null : _import, + icon: const Icon(Icons.upload_file_outlined, size: 18), + label: Text(_importing + ? t("settings.advanced.importing") + : t("settings.advanced.importAction")), + ), + ), + if (_result != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + t("settings.advanced.imported", params: { + "cars": _result!.cars, + "services": _result!.services, + "parts": _result!.parts, + }), + style: const TextStyle(color: DriverVault.success, fontWeight: FontWeight.w600), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + ], + ); + } +} + // --- Danger zone (account deletion) ---------------------------------------- class _DangerSection extends StatefulWidget { diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index f6b15c3..c1f7697 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_view_sheet.dart"; void main() { // rootBundle (used by loadTranslations) needs the binding initialised. @@ -155,4 +156,139 @@ void main() { final car = Car.fromJson({"id": "c", "name": "Yaris", "technicalCheckIntervalDays": 730}); expect(car.technicalCheckIntervalDays, 730); }); + + test("ChargingSession keeps uncomputed derived fields null, not zero", () { + final partial = ChargingSession.fromJson({ + "id": "a", + "car": "c", + "date": "2026-07-01T00:00:00Z", + "km": 1000, + "kwh": 22.5, + "cost": 30.0, + "fullCharge": false, + "hasFile": false, + }); + expect(partial.consumptionKwh100, isNull); + expect(partial.distanceKm, isNull); + expect(formatKwhConsumption(partial.consumptionKwh100), "—"); + expect(formatKmPerKwh(partial.kmPerKwh), "—"); + expect(formatKwh(partial.kwh), "22.50 kWh"); + + final closed = ChargingSession.fromJson({ + "id": "b", + "car": "c", + "date": "2026-07-20T00:00:00Z", + "km": 1400, + "kwh": 60.0, + "cost": 90.0, + "fullCharge": true, + "pricePerKwh": 1.5, + "distanceKm": 400, + "consumptionKwh100": 15.0, + "kmPerKwh": 6.67, + }); + expect(closed.consumptionKwh100, 15.0); + expect(formatKwhConsumption(closed.consumptionKwh100), "15.0 kWh/100km"); + }); + + test("ChargingStats leaves averages null when nothing is computable", () { + final stats = ChargingStats.fromJson({ + "entries": 1, + "totalKwh": 22.5, + "totalCost": 30.0, + "trackedDistanceKm": 0, + }); + expect(stats.entries, 1); + expect(stats.avgConsumptionKwh100, isNull); + expect(formatKwhConsumption(stats.avgConsumptionKwh100), "—"); + }); + + test("Car carries the provider link and the view arrangement", () { + final car = Car.fromJson({ + "id": "c", + "name": "bZ4X", + "provider": "toyota", + "providerVehicleId": "VIN123", + "hiddenTabs": ["fuel"], + "hiddenFields": ["differentialOil"], + "tabOrder": ["info", "charging"], + "fieldOrder": ["vin"], + "metricOrder": ["evRange", "odometer"], + }); + expect(car.provider, "toyota"); + expect(car.providerVehicleId, "VIN123"); + expect(car.hiddenTabs, ["fuel"]); + expect(car.tabOrder, ["info", "charging"]); + expect(car.metricOrder, ["evRange", "odometer"]); + + // A car nobody has arranged carries empty lists, not nulls — the callers + // read them directly. + final plain = Car.fromJson({"id": "d", "name": "Yaris"}); + expect(plain.provider, ""); + expect(plain.hiddenTabs, isEmpty); + expect(plain.tabOrder, isEmpty); + }); + + test("arrangeKeys: partial orders keep every key, unknown ones are dropped", () { + // The keys the stored order names lead; the rest follow in catalogue order, + // which is what puts a tab added in a later release at the end of somebody's + // page rather than in the middle of it. + expect( + arrangeKeys(kCarTabKeys, ["charging", "info"]).take(2).toList(), + ["charging", "info"], + ); + expect(arrangeKeys(kCarTabKeys, ["charging", "info"]).length, kCarTabKeys.length); + expect(arrangeKeys(kCarTabKeys, ["charging", "info"]).toSet(), kCarTabKeys.toSet()); + + // A key from a newer release, and a duplicate, are both ignored. + final arranged = arrangeKeys(kCarTabKeys, ["tyres", "vin", "fuel", "fuel"]); + expect(arranged.first, "fuel"); + expect(arranged.length, kCarTabKeys.length); + + // No arrangement at all means the catalogue's own order. + expect(arrangeKeys(kCarInfoFieldKeys, const []), kCarInfoFieldKeys); + }); + + test("ProviderSnapshot: an unreachable provider parses as an answer, not a failure", () { + final closed = ProviderSnapshot.fromJson({ + "provider": "toyota", + "label": "MyToyota", + "unavailable": true, + "detail": "this vehicle is not on your MyToyota account", + }); + expect(closed.unavailable, isTrue); + expect(closed.vehicle, isNull); + expect(closed.metrics, isEmpty); + expect(closed.detail, contains("MyToyota")); + + final open = ProviderSnapshot.fromJson({ + "provider": "toyota", + "label": "MyToyota", + "fetchedAt": "2026-08-21T09:30:00Z", + "vehicle": {"id": "VIN123", "vin": "VIN123", "name": "bZ4X", "make": "Toyota", "year": 2024}, + "metrics": [ + {"key": "odometer", "value": "16138", "unit": "km"}, + {"key": "batteryLevel", "value": "72", "unit": "%"}, + ], + "sections": [ + {"id": "telemetry", "status": "ok", "fields": [{"key": "a.b", "value": "1"}]}, + {"id": "notifications", "status": "error", "error": "upstream said no"}, + ], + "suggestedCurrentKm": 16138, + }); + expect(open.vehicle!.subtitle, "Toyota 2024"); + expect(open.metrics.first.key, "odometer"); + expect(open.sections.last.status, "error"); + expect(open.sections.first.fields.single.key, "a.b"); + expect(open.suggestedCurrentKm, 16138); + expect(open.fetchedAt, isNotNull); + }); + + test("ImportResult reads the server's per-collection counts", () { + final res = ImportResult.fromJson( + {"carsImported": 2, "servicesImported": 11, "partsImported": 4}); + expect(res.cars, 2); + expect(res.services, 11); + expect(res.parts, 4); + }); } diff --git a/README.md b/README.md index aea502e..c62d098 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,7 @@ nothing talks to PocketBase directly. | **Car Agent Device** | ESP32 + SIM7600 (LILYGO TTGO) | 🚧 firmware in progress | [Car Agent Device](Car%20Agent%20Device) | | **Home Assistant Plugin** | — | ⬜ later | — | -The Web and Phone apps are at feature parity (the phone omits only data -export/import). +The Web and Phone apps are at feature parity. ## Features