diff --git a/Phone App/README.md b/Phone App/README.md index ee7a40b..fa718fe 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -1,8 +1,9 @@ # Car Control — Phone App (Flutter) A Flutter client for the Car Control maintenance tracker. Talks **only** to the -API Server (same contract and JWT auth as the web app). At full feature parity -with the web app (data export/import is the only deliberate omission). +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). Project name `carcontrol_phone`, package id `com.carcontrole.carcontrol_phone`. **Android is the supported target** — the older Flutter-web build path is @@ -17,20 +18,65 @@ deprecated. a "shared" chip on cars owned by someone else, pull-to-refresh, **Add car** FAB, Settings gear, and an admin action (admins only). - **Car detail** — all spec fields (incl. VIN and transmission / differential / - brake / coolant specs), tabs for **Service history** and **Parts catalog**, - edit car, add/edit/delete service records and parts, a **share** sheet - (owner only), quick odometer update, and delete car (type-to-confirm; cascades). - Actions are gated by the caller's access level (read-only vs write vs owner). + 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: + - **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 + (przegląd techniczny, MOT, TÜV). Result, cost, station, and the certificate's + valid-until, which overrides the car's interval when present. A failed check + derives no next date. + - **Maintenance** — workshop visits and repairs outside the routine schedule: + type/status, workshop, parts used, labour + parts cost, invoice number and a + warranty-until badge. + - **Fuel** — refills with a summary panel (average / best / worst consumption, + 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. + - **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. + - **Reminders** — date- and/or odometer-triggered, one-off or recurring, with a + **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 + with `file_picker`, fetched back through the API Server — never a public URL — + and opened with the phone's own viewer via `open_filex`. - **Settings** — account (name / email verification / password), appearance - (theme + dark mode, locale, date format, font size), profile (avatar via - `image_picker`, bio), **Security** (biometric toggle), active sessions with - remote logout, and the account-deletion state machine. + (theme + dark mode, **language**, **region**, date format, **currency**, font + size), profile (avatar via `image_picker`, bio), **Security** (biometric + toggle), and the account-deletion state machine. Auth relays PocketBase's own + stateless tokens, so there is no per-device session list to show or revoke. - **Admin** — user management screen (list / create / role / reset password / delete), gated by the admin role. Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly` getters that gate the UI, mirroring the server's access checks. +## Language, region & currency + +Language and region are two pickers over the one stored BCP-47 tag (`en-US`), so +the pair can be mixed — English in Poland, say. Currency is display-only: nothing +is converted, so changing it reinterprets existing amounts rather than +recalculating them. All three lists mirror the API's (`validCurrencies` and the +locale pattern in `internal/api/me.go`), with two deliberate differences from the +web app: + +- **Luxembourgish (`lb`) and Romansh (`rm`) are not offered.** `intl` ships no + date/number symbols for them and *throws* rather than falling back, which would + take out every date on screen. The browser has full ICU data behind it and has + no such limit, so the web app can list them. +- **Labels are hand-kept** (endonyms for languages, English for regions and + currencies) because Dart has no `Intl.DisplayNames`. + +Because the server only validates a locale's *shape* (`^[a-z]{2}-[A-Z]{2}$`), a +tag the phone cannot render can still arrive from the web. `format.dart` resolves +through a supported-language check and falls back to `en-US` instead of throwing; +`test/models_format_test.dart` covers it. + ## Biometric / face sign-in & app lock Fingerprint and face-recognition sign-in via `local_auth`, with credentials kept diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index e3df0a6..6303248 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -214,6 +214,145 @@ class ApiClient { Future deletePart(String id) => _send("DELETE", "/parts/$id"); + // --- technical checks --- + Future> listCarTechnicalChecks(String carId) async { + final data = await _send("GET", "/cars/$carId/technical-checks") as List; + return data.map((e) => TechnicalCheck.fromJson(Map.from(e))).toList(); + } + + Future createTechnicalCheck(Map body) async { + final data = await _send("POST", "/technical-checks", body: body); + return TechnicalCheck.fromJson(Map.from(data)); + } + + Future updateTechnicalCheck(String id, Map body) async { + final data = await _send("PATCH", "/technical-checks/$id", body: body); + return TechnicalCheck.fromJson(Map.from(data)); + } + + Future deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id"); + + // --- fuel --- + Future> listCarFuelEntries(String carId) async { + final data = await _send("GET", "/cars/$carId/fuel-entries") as List; + return data.map((e) => FuelEntry.fromJson(Map.from(e))).toList(); + } + + Future getCarFuelStats(String carId) async { + final data = await _send("GET", "/cars/$carId/fuel-stats"); + return FuelStats.fromJson(Map.from(data)); + } + + Future createFuelEntry(Map body) async { + final data = await _send("POST", "/fuel-entries", body: body); + return FuelEntry.fromJson(Map.from(data)); + } + + Future updateFuelEntry(String id, Map body) async { + final data = await _send("PATCH", "/fuel-entries/$id", body: body); + return FuelEntry.fromJson(Map.from(data)); + } + + Future deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id"); + + // --- maintenance --- + Future> listCarMaintenance(String carId) async { + final data = await _send("GET", "/cars/$carId/maintenance") as List; + return data.map((e) => MaintenanceEntry.fromJson(Map.from(e))).toList(); + } + + Future createMaintenance(Map body) async { + final data = await _send("POST", "/maintenance", body: body); + return MaintenanceEntry.fromJson(Map.from(data)); + } + + Future updateMaintenance(String id, Map body) async { + final data = await _send("PATCH", "/maintenance/$id", body: body); + return MaintenanceEntry.fromJson(Map.from(data)); + } + + Future deleteMaintenance(String id) => _send("DELETE", "/maintenance/$id"); + + // --- documents --- + // The path is /car-documents so it can't be mistaken for the user-facing + // account documents other Vault services expose. + Future> listCarDocuments(String carId) async { + final data = await _send("GET", "/cars/$carId/documents") as List; + return data.map((e) => CarDocument.fromJson(Map.from(e))).toList(); + } + + Future createDocument(Map body) async { + final data = await _send("POST", "/car-documents", body: body); + return CarDocument.fromJson(Map.from(data)); + } + + Future updateDocument(String id, Map body) async { + final data = await _send("PATCH", "/car-documents/$id", body: body); + return CarDocument.fromJson(Map.from(data)); + } + + Future deleteDocument(String id) => _send("DELETE", "/car-documents/$id"); + + // --- reminders --- + Future> listCarReminders(String carId) async { + final data = await _send("GET", "/cars/$carId/reminders") as List; + return data.map((e) => Reminder.fromJson(Map.from(e))).toList(); + } + + Future createReminder(Map body) async { + final data = await _send("POST", "/reminders", body: body); + return Reminder.fromJson(Map.from(data)); + } + + Future updateReminder(String id, Map body) async { + final data = await _send("PATCH", "/reminders/$id", body: body); + return Reminder.fromJson(Map.from(data)); + } + + Future deleteReminder(String id) => _send("DELETE", "/reminders/$id"); + + /// Completing a recurring reminder rolls its trigger forward instead of + /// closing it out; the server decides which, so the caller just re-reads. + Future completeReminder(String id) async { + final data = await _send("POST", "/reminders/$id/complete"); + return Reminder.fromJson(Map.from(data)); + } + + // --- attachments --- + // Every attachable collection takes a file on identical terms, so one set of + // helpers is parameterized by the collection's path rather than repeated six + // times. See the API's attachments.go. + + /// Uploads (or replaces) a record's file. Returns the re-read record JSON, so + /// the caller decodes it into whichever model it owns. + Future> uploadAttachment( + String path, String id, List bytes, String filename) async { + final req = http.MultipartRequest("POST", _uri("$path/$id/file")); + if (token != null) req.headers["Authorization"] = "Bearer $token"; + req.files.add(http.MultipartFile.fromBytes("file", bytes, filename: filename)); + final res = await http.Response.fromStream(await req.send()); + if (res.statusCode == 401) { + onUnauthorized?.call(); + throw ApiException(401, "Session expired — please log in again."); + } + final data = jsonDecode(res.body); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase)); + } + return Map.from(data); + } + + /// The attachment's bytes, or null when there is no file. Never a public URL — + /// the server re-checks car access on every fetch. + Future?> getAttachmentBytes(String path, String id) async { + final res = await http.get(_uri("$path/$id/file"), + headers: {if (token != null) "Authorization": "Bearer $token"}); + if (res.statusCode == 200) return res.bodyBytes; + return null; + } + + Future deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file"); + // --- settings: profile / account --- Future getMe() async { final data = await _send("GET", "/me"); diff --git a/Phone App/lib/app_settings.dart b/Phone App/lib/app_settings.dart index 1721bb9..677d970 100644 --- a/Phone App/lib/app_settings.dart +++ b/Phone App/lib/app_settings.dart @@ -3,26 +3,34 @@ import "package:shared_preferences/shared_preferences.dart"; import "models.dart"; -/// App-wide appearance preferences (theme / locale / date format / font size), -/// mirroring the web app's prefs.js. Persisted to SharedPreferences so the -/// chosen theme survives a restart before /api/me loads, and exposed as a +/// App-wide appearance preferences (theme / locale / date format / currency / +/// font size), mirroring the web app's prefs.js. Persisted to SharedPreferences +/// so the chosen theme survives a restart before /api/me loads, and exposed as a /// ChangeNotifier so MaterialApp and date formatting react to changes. class AppSettings extends ChangeNotifier { static const _kTheme = "cc_theme"; static const _kLocale = "cc_locale"; static const _kDateFormat = "cc_dateFormat"; + static const _kCurrency = "cc_currency"; static const _kFontSize = "cc_fontSize"; String theme = "system"; // light | dark | system - String locale = "en-US"; + String locale = "en-US"; // BCP-47 language-REGION String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY + String currency = "USD"; // ISO 4217 code String fontSize = "medium"; // small | medium | large + /// Language and region are two controls over the one stored tag, so the pair + /// can be mixed freely (English in Poland, say). + String get language => locale.split("-").first; + String get region => locale.split("-").length > 1 ? locale.split("-")[1] : "US"; + Future loadFromStorage() async { final prefs = await SharedPreferences.getInstance(); theme = prefs.getString(_kTheme) ?? theme; locale = prefs.getString(_kLocale) ?? locale; dateFormat = prefs.getString(_kDateFormat) ?? dateFormat; + currency = prefs.getString(_kCurrency) ?? currency; fontSize = prefs.getString(_kFontSize) ?? fontSize; notifyListeners(); } @@ -32,6 +40,7 @@ class AppSettings extends ChangeNotifier { await prefs.setString(_kTheme, theme); await prefs.setString(_kLocale, locale); await prefs.setString(_kDateFormat, dateFormat); + await prefs.setString(_kCurrency, currency); await prefs.setString(_kFontSize, fontSize); } @@ -40,6 +49,7 @@ class AppSettings extends ChangeNotifier { theme = p.theme; locale = p.locale; dateFormat = p.dateFormat; + currency = p.currency; fontSize = p.fontSize; _persist(); notifyListeners(); @@ -47,10 +57,17 @@ class AppSettings extends ChangeNotifier { /// Optimistically apply a single changed field (used by Settings so the UI /// reacts instantly while the PATCH is in flight). - void patch({String? theme, String? locale, String? dateFormat, String? fontSize}) { + void patch({ + String? theme, + String? locale, + String? dateFormat, + String? currency, + String? fontSize, + }) { if (theme != null) this.theme = theme; if (locale != null) this.locale = locale; if (dateFormat != null) this.dateFormat = dateFormat; + if (currency != null) this.currency = currency; if (fontSize != null) this.fontSize = fontSize; _persist(); notifyListeners(); diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart index e478c46..0dce188 100644 --- a/Phone App/lib/format.dart +++ b/Phone App/lib/format.dart @@ -5,10 +5,26 @@ import "main.dart"; import "models.dart"; import "theme.dart"; -final _numFmt = NumberFormat.decimalPattern(); +/// The languages intl actually ships symbols for. It throws rather than falling +/// back on the rest, and the API only validates a locale's *shape* +/// (`^[a-z]{2}-[A-Z]{2}$`), so an unsupported tag can legitimately arrive here — +/// set from the web, which has the browser's full ICU data behind it. Guarding +/// once keeps that from throwing out of every date on screen. +final _supportedLanguages = DateFormat.allLocalesWithSymbols().toSet(); + +/// The user's locale when intl can render it, else a safe default. Only the +/// language subtag is checked: intl resolves an unknown *region* by falling back +/// to the language ("en-PL" formats as "en"), but an unknown language throws. +String get _locale => + _supportedLanguages.contains(appSettings.language) ? appSettings.locale : "en-US"; + +/// Every number we render goes through here so the grouping separator follows +/// the user's chosen region rather than the device's own locale — otherwise the +/// odometer disagrees with the dates and costs beside it. +String _num(num value) => NumberFormat.decimalPattern(_locale).format(value); /// Formats a date per the signed-in user's chosen date format (appSettings), -/// mirroring the web app's format.js. +/// mirroring the web app's format.js. Month names follow the locale's language. String formatDate(DateTime? d) { if (d == null) return "—"; final pattern = switch (appSettings.dateFormat) { @@ -17,10 +33,36 @@ String formatDate(DateTime? d) { "MDY" => "MMM dd, yyyy", _ => "yyyy-MM-dd", // YMD }; - return DateFormat(pattern).format(d); + return DateFormat(pattern, _locale).format(d); } -String formatKm(int? km) => (km == null || km == 0) ? "—" : "${_numFmt.format(km)} km"; +String formatKm(int? km) => (km == null || km == 0) ? "—" : "${_num(km)} km"; + +// The fuel figures. The server sends null for anything it could not derive (a +// window with a missed fill, a first-ever tank), which reads as "—" rather than +// a misleading zero. +String formatLiters(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} L"; + +/// Amounts are stored as plain numbers; the user's currency setting only decides +/// how they are displayed. Nothing is converted — a figure entered as 40 reads as +/// 40 in whichever currency is selected. +/// +/// No explicit fraction digits: the currency's own minor unit pins them, which +/// keeps the 2 decimals the fuel figures were written for while still rendering +/// yen without phantom sen. +String formatMoney(double? value) { + if (value == null) return "—"; + return NumberFormat.simpleCurrency(locale: _locale, name: appSettings.currency).format(value); +} + +/// One decimal: the interesting differences between tanks live in tenths, and +/// rounding to whole litres collapses a best of 6.8 and a worst of 7.0 into the +/// same number. +String formatConsumption(double? value) => + value == null ? "—" : "${value.toStringAsFixed(1)} L/100km"; + +String formatKmPerLiter(double? value) => + value == null ? "—" : "${value.toStringAsFixed(2)} km/L"; const int _kmSoon = 1000; @@ -68,9 +110,70 @@ Status _dateSignal(DateTime? nextDate) { Status _kmSignal(int currentKm, int? nextKm) { if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km"); final remaining = nextKm - currentKm; - if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_numFmt.format(remaining.abs())} km"); - if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_numFmt.format(remaining)} km"); - return Status(StatusKey.ok, "${_numFmt.format(remaining)} km left"); + if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_num(remaining.abs())} km"); + if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_num(remaining)} km"); + return Status(StatusKey.ok, "${_num(remaining)} km left"); +} + +/// Maps the server's expiry/reminder state names onto the badge palette. The +/// client never re-derives the date maths — it only chooses the wording. +StatusKey _expiryKey(String state) => switch (state) { + "expired" => StatusKey.overdue, + "expiring_soon" => StatusKey.soon, + "valid" => StatusKey.ok, + _ => StatusKey.unknown, // no_expiry + }; + +/// Renewal badge for a dated document or certificate, driven by the server's +/// expiry assessment. +Status expiryStatus(ExpiryAssessment e) { + final days = e.days; + final label = switch (e.state) { + "expired" => "Expired ${(days ?? 0).abs()}d ago", + "expiring_soon" => days == 0 ? "Expires today" : "Renew in ${days}d", + "valid" => "Valid · ${days}d", + _ => "No expiry", + }; + return Status(_expiryKey(e.state), label); +} + +/// Reminder badge. The server has already picked the worse of the date and +/// odometer signals; this only chooses the wording, leading with whichever +/// trigger is actually closest to firing. +Status reminderStatus(Reminder r) { + final key = switch (r.status) { + "overdue" => StatusKey.overdue, + "due_soon" => StatusKey.soon, + "upcoming" => StatusKey.ok, + _ => StatusKey.unknown, // done | no_trigger + }; + + final days = r.daysLeft; + final km = r.kmLeft; + if (r.status == "done") return const Status(StatusKey.unknown, "Done"); + if (r.status == "no_trigger") return const Status(StatusKey.unknown, "No trigger"); + + final parts = []; + if (r.status == "overdue") { + if (days != null && days < 0) parts.add("${days.abs()}d"); + if (km != null && km < 0) parts.add("${_num(km.abs())} km"); + return Status(key, parts.isEmpty ? "Overdue" : "Overdue ${parts.join(" · ")}"); + } + + if (days != null && days >= 0) parts.add(days == 0 ? "today" : "${days}d"); + if (km != null && km >= 0) parts.add("${_num(km)} km"); + return Status(key, parts.isEmpty ? "Upcoming" : "Due in ${parts.join(" · ")}"); +} + +/// Warranty badge for a maintenance entry. Unlike the others this one has no +/// server-side assessment — only the raw active/days-left pair — so the wording +/// is chosen from those directly. +Status? warrantyStatus(MaintenanceEntry m) { + final days = m.warrantyDaysLeft; + if (m.warrantyActive == null || days == null) return null; + if (!m.warrantyActive!) return Status(StatusKey.unknown, "Warranty expired ${days.abs()}d ago"); + if (days <= 30) return Status(StatusKey.soon, "Warranty ends in ${days}d"); + return Status(StatusKey.ok, "Under warranty · ${days}d"); } /// Combines the date- and km-based signals, returning the worse of the two — diff --git a/Phone App/lib/main.dart b/Phone App/lib/main.dart index 7a4a80e..68eaf56 100644 --- a/Phone App/lib/main.dart +++ b/Phone App/lib/main.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:intl/date_symbol_data_local.dart"; import "api.dart"; import "app_settings.dart"; @@ -16,6 +17,10 @@ final appSettings = AppSettings(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Month and day names are rendered in the user's chosen language, so every + // locale's date symbols have to be loaded before the first formatDate call — + // DateFormat throws on an uninitialized locale rather than falling back. + await initializeDateFormatting(); await apiClient.loadServerUrl(); await appSettings.loadFromStorage(); await authService.loadFromStorage(); diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 165ae17..10caf90 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -3,6 +3,42 @@ int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0); String _asStr(dynamic v) => v == null ? "" : v.toString(); bool _asBool(dynamic v) => v == true; +double _asDouble(dynamic v) => v is num ? v.toDouble() : 0.0; + +/// Nullable variants for the derived fields the server omits when it could not +/// compute them. A missing consumption figure is not zero — it means "unknown", +/// and must stay distinguishable so the UI can render "—" instead of a +/// misleading 0.0. +int? _asIntOrNull(dynamic v) => v is num ? v.toInt() : null; +double? _asDoubleOrNull(dynamic v) => v is num ? v.toDouble() : null; +DateTime? _asDate(dynamic v) => + v == null ? null : DateTime.tryParse(v.toString())?.toLocal(); + +/// 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 +/// from GET /{records}/{id}/file, which re-checks access per request. +mixin HasAttachment { + String get fileName; + bool get hasFile; +} + +/// The server-computed lifecycle state of a dated document or certificate. +/// [days] is null when there is no expiry date at all. +class ExpiryAssessment { + final String state; // no_expiry | valid | expiring_soon | expired + final int? days; + + const ExpiryAssessment({this.state = "no_expiry", this.days}); + + factory ExpiryAssessment.fromJson(Map? j) { + if (j == null) return const ExpiryAssessment(); + return ExpiryAssessment( + state: _asStr(j["state"]).isEmpty ? "no_expiry" : _asStr(j["state"]), + days: _asIntOrNull(j["daysUntilExpiry"]), + ); + } +} class Car { final String id; @@ -23,6 +59,11 @@ class Car { final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only) final int serviceIntervalDays; final int serviceIntervalKm; + + /// The roadworthiness inspection cycle. Only prefills the next date — the + /// interval is set by law rather than by the car, so any check can override it + /// with the date its certificate actually carries. + final int technicalCheckIntervalDays; final int currentKm; /// The requesting user's permission on this car: "owner", "write", or "read". @@ -49,6 +90,7 @@ class Car { this.firstRegistrationDate = "", required this.serviceIntervalDays, required this.serviceIntervalKm, + this.technicalCheckIntervalDays = 0, required this.currentKm, this.access = "owner", }); @@ -72,6 +114,7 @@ class Car { firstRegistrationDate: _asStr(j["firstRegistrationDate"]), serviceIntervalDays: _asInt(j["serviceIntervalDays"]), serviceIntervalKm: _asInt(j["serviceIntervalKm"]), + technicalCheckIntervalDays: _asInt(j["technicalCheckIntervalDays"]), currentKm: _asInt(j["currentKm"]), access: j["access"] == null ? "owner" : _asStr(j["access"]), ); @@ -84,7 +127,7 @@ class Car { [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" "); } -class ServiceRecord { +class ServiceRecord with HasAttachment { final String id; final String car; final DateTime? date; @@ -95,6 +138,10 @@ class ServiceRecord { final String notes; final DateTime? nextServiceDate; final int? nextServiceKm; + @override + final String fileName; + @override + final bool hasFile; ServiceRecord({ required this.id, @@ -107,30 +154,95 @@ class ServiceRecord { required this.notes, required this.nextServiceDate, required this.nextServiceKm, + this.fileName = "", + this.hasFile = false, }); factory ServiceRecord.fromJson(Map j) => ServiceRecord( id: _asStr(j["id"]), car: _asStr(j["car"]), - date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(), + date: _asDate(j["date"]), km: _asInt(j["km"]), changedOil: _asBool(j["changedOil"]), changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]), changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]), notes: _asStr(j["notes"]), - nextServiceDate: j["nextServiceDate"] != null - ? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal() - : null, - nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]), + nextServiceDate: _asDate(j["nextServiceDate"]), + nextServiceKm: _asIntOrNull(j["nextServiceKm"]), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), ); } -class Part { +/// One mandatory roadworthiness inspection — przegląd techniczny, MOT, TÜV, +/// contrôle technique, depending on where the car is registered. Shaped like a +/// [ServiceRecord] but recurring on time alone: an inspection falls due on a +/// date whatever the odometer says. +class TechnicalCheck with HasAttachment { + final String id; + final String car; + final DateTime? date; + final String result; // passed | failed + final double cost; + final String station; + final String notes; + + /// The expiry printed on the certificate. When set it wins over the car's + /// interval, because it is the date that actually governs. + final DateTime? validUntil; + + final DateTime? nextCheckDate; + final ExpiryAssessment expiry; + @override + final String fileName; + @override + final bool hasFile; + + TechnicalCheck({ + required this.id, + required this.car, + required this.date, + required this.result, + required this.cost, + this.station = "", + this.notes = "", + this.validUntil, + this.nextCheckDate, + this.expiry = const ExpiryAssessment(), + this.fileName = "", + this.hasFile = false, + }); + + factory TechnicalCheck.fromJson(Map j) => TechnicalCheck( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + date: _asDate(j["date"]), + result: _asStr(j["result"]).isEmpty ? "passed" : _asStr(j["result"]), + cost: _asDouble(j["cost"]), + station: _asStr(j["station"]), + notes: _asStr(j["notes"]), + validUntil: _asDate(j["validUntil"]), + nextCheckDate: _asDate(j["nextCheckDate"]), + expiry: ExpiryAssessment.fromJson( + j["expiry"] == null ? null : Map.from(j["expiry"])), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), + ); + + bool get passed => result == "passed"; +} + +class Part with HasAttachment { final String id; final String car; final String name; final String partNumber; final String category; + final String notes; + @override + final String fileName; + @override + final bool hasFile; Part({ required this.id, @@ -138,6 +250,9 @@ class Part { required this.name, required this.partNumber, this.category = "", + this.notes = "", + this.fileName = "", + this.hasFile = false, }); factory Part.fromJson(Map j) => Part( @@ -146,9 +261,326 @@ class Part { name: _asStr(j["name"]), partNumber: _asStr(j["partNumber"]), category: _asStr(j["category"]), + notes: _asStr(j["notes"]), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), ); } +/// One refuelling stop. The efficiency figures are derived by the server using +/// the full-tank method and are null wherever it could not compute them — a +/// window with a missed fill, or the first tank ever logged. +class FuelEntry with HasAttachment { + final String id; + final String car; + final DateTime? date; + final int km; + final double liters; + final double cost; + final bool fullTank; + final bool missedFill; + final String station; + final String notes; + + final double? pricePerLiter; + final int? distanceKm; + final double? litersUsed; + final double? consumptionL100; + final double? kmPerLiter; + final double? costPerKm; + @override + final String fileName; + @override + final bool hasFile; + + FuelEntry({ + required this.id, + required this.car, + required this.date, + required this.km, + required this.liters, + required this.cost, + this.fullTank = true, + this.missedFill = false, + this.station = "", + this.notes = "", + this.pricePerLiter, + this.distanceKm, + this.litersUsed, + this.consumptionL100, + this.kmPerLiter, + this.costPerKm, + this.fileName = "", + this.hasFile = false, + }); + + factory FuelEntry.fromJson(Map j) => FuelEntry( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + date: _asDate(j["date"]), + km: _asInt(j["km"]), + liters: _asDouble(j["liters"]), + cost: _asDouble(j["cost"]), + fullTank: _asBool(j["fullTank"]), + missedFill: _asBool(j["missedFill"]), + station: _asStr(j["station"]), + notes: _asStr(j["notes"]), + pricePerLiter: _asDoubleOrNull(j["pricePerLiter"]), + distanceKm: _asIntOrNull(j["distanceKm"]), + litersUsed: _asDoubleOrNull(j["litersUsed"]), + consumptionL100: _asDoubleOrNull(j["consumptionL100"]), + kmPerLiter: _asDoubleOrNull(j["kmPerLiter"]), + costPerKm: _asDoubleOrNull(j["costPerKm"]), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), + ); +} + +/// A summary of a car's whole refill history. [trackedDistanceKm] is the +/// distance covered by computable full-tank windows — less than the odometer +/// span whenever the history starts or ends on a partial fill. The averages +/// describe exactly this distance. +class FuelStats { + final int entries; + final double totalLiters; + final double totalCost; + final int trackedDistanceKm; + final double? avgConsumptionL100; + final double? bestConsumptionL100; + final double? worstConsumptionL100; + final double? avgKmPerLiter; + final double? avgPricePerLiter; + final double? costPerKm; + final DateTime? firstDate; + final DateTime? lastDate; + + const FuelStats({ + this.entries = 0, + this.totalLiters = 0, + this.totalCost = 0, + this.trackedDistanceKm = 0, + this.avgConsumptionL100, + this.bestConsumptionL100, + this.worstConsumptionL100, + this.avgKmPerLiter, + this.avgPricePerLiter, + this.costPerKm, + this.firstDate, + this.lastDate, + }); + + factory FuelStats.fromJson(Map j) => FuelStats( + entries: _asInt(j["entries"]), + totalLiters: _asDouble(j["totalLiters"]), + totalCost: _asDouble(j["totalCost"]), + trackedDistanceKm: _asInt(j["trackedDistanceKm"]), + avgConsumptionL100: _asDoubleOrNull(j["avgConsumptionL100"]), + bestConsumptionL100: _asDoubleOrNull(j["bestConsumptionL100"]), + worstConsumptionL100: _asDoubleOrNull(j["worstConsumptionL100"]), + avgKmPerLiter: _asDoubleOrNull(j["avgKmPerLiter"]), + avgPricePerLiter: _asDoubleOrNull(j["avgPricePerLiter"]), + 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. +class MaintenanceEntry with HasAttachment { + final String id; + final String car; + final DateTime? date; + final int km; + final String type; // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other + final String status; // scheduled|in_progress|completed + final String workshop; + final String location; + final String description; + final String partsUsed; + final double laborCost; + final double partsCost; + final String invoiceNumber; + final DateTime? warrantyUntil; + final String notes; + + final double totalCost; + final bool? warrantyActive; + final int? warrantyDaysLeft; + @override + final String fileName; + @override + final bool hasFile; + + MaintenanceEntry({ + required this.id, + required this.car, + required this.date, + required this.km, + required this.type, + required this.status, + this.workshop = "", + this.location = "", + this.description = "", + this.partsUsed = "", + this.laborCost = 0, + this.partsCost = 0, + this.invoiceNumber = "", + this.warrantyUntil, + this.notes = "", + this.totalCost = 0, + this.warrantyActive, + this.warrantyDaysLeft, + this.fileName = "", + this.hasFile = false, + }); + + factory MaintenanceEntry.fromJson(Map j) => MaintenanceEntry( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + date: _asDate(j["date"]), + km: _asInt(j["km"]), + type: _asStr(j["type"]).isEmpty ? "repair" : _asStr(j["type"]), + status: _asStr(j["status"]).isEmpty ? "completed" : _asStr(j["status"]), + workshop: _asStr(j["workshop"]), + location: _asStr(j["location"]), + description: _asStr(j["description"]), + partsUsed: _asStr(j["partsUsed"]), + laborCost: _asDouble(j["laborCost"]), + partsCost: _asDouble(j["partsCost"]), + invoiceNumber: _asStr(j["invoiceNumber"]), + warrantyUntil: _asDate(j["warrantyUntil"]), + notes: _asStr(j["notes"]), + totalCost: _asDouble(j["totalCost"]), + warrantyActive: j["warrantyActive"] == null ? null : _asBool(j["warrantyActive"]), + warrantyDaysLeft: _asIntOrNull(j["warrantyDaysLeft"]), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), + ); +} + +/// A piece of paperwork tied to a car — insurance, emissions certificate, +/// registration papers. The renewal date is the point of the record: an expired +/// policy is a car that cannot legally be driven, so [expiry] is computed live +/// by the server on every read. +class CarDocument with HasAttachment { + final String id; + final String car; + final String type; // insurance|pollution|registration|inspection|roadTax|warranty|other + final String title; + final String provider; + final String reference; + final DateTime? issueDate; + final DateTime? expiryDate; // blank = never expires + final double cost; + final String notes; + final ExpiryAssessment expiry; + @override + final String fileName; + @override + final bool hasFile; + + CarDocument({ + required this.id, + required this.car, + required this.type, + required this.title, + this.provider = "", + this.reference = "", + this.issueDate, + this.expiryDate, + this.cost = 0, + this.notes = "", + this.expiry = const ExpiryAssessment(), + this.fileName = "", + this.hasFile = false, + }); + + factory CarDocument.fromJson(Map j) => CarDocument( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]), + title: _asStr(j["title"]), + provider: _asStr(j["provider"]), + reference: _asStr(j["reference"]), + issueDate: _asDate(j["issueDate"]), + expiryDate: _asDate(j["expiryDate"]), + cost: _asDouble(j["cost"]), + notes: _asStr(j["notes"]), + expiry: ExpiryAssessment.fromJson( + j["expiry"] == null ? null : Map.from(j["expiry"])), + fileName: _asStr(j["fileName"]), + hasFile: _asBool(j["hasFile"]), + ); +} + +/// Something the user wants to be told about: a booked workshop slot, an +/// insurance renewal, a tyre swap. Fires on a date, an odometer reading, or +/// both — whichever comes first. [auto] marks a reminder the server derived from +/// a document or service record, which is read-only. +class Reminder { + final String id; + final String car; + final String title; + final String type; // maintenance|document|service|inspection|other + final DateTime? dueDate; + final int dueKm; + final int repeatDays; + final int repeatKm; + final bool done; + final DateTime? doneAt; + final String notes; + + final String status; // done | overdue | due_soon | upcoming | no_trigger + final int? daysLeft; + final int? kmLeft; + final bool auto; + final String sourceRef; + + Reminder({ + required this.id, + required this.car, + required this.title, + required this.type, + this.dueDate, + this.dueKm = 0, + this.repeatDays = 0, + this.repeatKm = 0, + this.done = false, + this.doneAt, + this.notes = "", + this.status = "no_trigger", + this.daysLeft, + this.kmLeft, + this.auto = false, + this.sourceRef = "", + }); + + factory Reminder.fromJson(Map j) => Reminder( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + title: _asStr(j["title"]), + type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]), + dueDate: _asDate(j["dueDate"]), + dueKm: _asInt(j["dueKm"]), + repeatDays: _asInt(j["repeatDays"]), + repeatKm: _asInt(j["repeatKm"]), + done: _asBool(j["done"]), + doneAt: _asDate(j["doneAt"]), + notes: _asStr(j["notes"]), + status: _asStr(j["status"]).isEmpty ? "no_trigger" : _asStr(j["status"]), + daysLeft: _asIntOrNull(j["daysLeft"]), + kmLeft: _asIntOrNull(j["kmLeft"]), + auto: _asBool(j["auto"]), + sourceRef: _asStr(j["sourceRef"]), + ); + + /// Recurring reminders roll their trigger forward on completion instead of + /// closing out. + bool get repeats => repeatDays > 0 || repeatKm > 0; +} + /// A sharing grant: another user's access to one of your cars. class CarShare { final String userId; @@ -240,8 +672,9 @@ class UserProfile { final String bio; final bool hasAvatar; final String theme; // light | dark | system - final String locale; + final String locale; // BCP-47 language-REGION, e.g. "en-US" final String dateFormat; // YMD | DMY_NUM | DMY | MDY + final String currency; // ISO 4217 code, e.g. "EUR" final String fontSize; // small | medium | large final String role; // user | admin final DateTime? deletionRequestedAt; @@ -256,6 +689,7 @@ class UserProfile { required this.theme, required this.locale, required this.dateFormat, + this.currency = "USD", required this.fontSize, required this.role, required this.deletionRequestedAt, @@ -271,6 +705,7 @@ class UserProfile { theme: j["theme"] == null ? "system" : _asStr(j["theme"]), locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]), dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]), + currency: j["currency"] == null ? "USD" : _asStr(j["currency"]), fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]), role: j["role"] == null ? "user" : _asStr(j["role"]), deletionRequestedAt: j["deletionRequestedAt"] == null diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index 84c355c..7943daf 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -1,10 +1,13 @@ import "package:flutter/material.dart"; +import "../api.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; import "../theme.dart"; +import "../widgets/attachment_field.dart"; import "car_form_sheet.dart"; +import "record_form_sheets.dart"; /// Serializes a car to the full update payload. The API's updateCar rewrites /// every column from the body, so partial payloads would blank omitted fields — @@ -27,6 +30,7 @@ Map _carPayload(Car car, {int? currentKm}) => { "firstRegistrationDate": car.firstRegistrationDate, "serviceIntervalDays": car.serviceIntervalDays, "serviceIntervalKm": car.serviceIntervalKm, + "technicalCheckIntervalDays": car.technicalCheckIntervalDays, "currentKm": currentKm ?? car.currentKm, }; @@ -40,8 +44,25 @@ class CarDetailScreen extends StatefulWidget { class _CarDetailData { final Car car; final List services; + final List technicalChecks; + final List maintenance; + final List fuel; + final FuelStats fuelStats; + final List documents; final List parts; - _CarDetailData(this.car, this.services, this.parts); + final List reminders; + + _CarDetailData({ + required this.car, + required this.services, + required this.technicalChecks, + required this.maintenance, + required this.fuel, + required this.fuelStats, + required this.documents, + required this.parts, + required this.reminders, + }); } class _CarDetailScreenState extends State { @@ -53,16 +74,31 @@ class _CarDetailScreenState extends State { _future = _load(); } + // One round trip per collection, all in flight together: the tabs are all + // rendered by the same FutureBuilder, so waiting for them serially would show + // the spinner for the sum of the requests rather than the slowest one. Future<_CarDetailData> _load() async { final results = await Future.wait([ apiClient.getCar(widget.carId), apiClient.listCarServices(widget.carId), + apiClient.listCarTechnicalChecks(widget.carId), + apiClient.listCarMaintenance(widget.carId), + apiClient.listCarFuelEntries(widget.carId), + apiClient.getCarFuelStats(widget.carId), + apiClient.listCarDocuments(widget.carId), apiClient.listCarParts(widget.carId), + apiClient.listCarReminders(widget.carId), ]); return _CarDetailData( - results[0] as Car, - results[1] as List, - results[2] as List, + car: results[0] as Car, + services: results[1] as List, + technicalChecks: results[2] as List, + 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, ); } @@ -135,6 +171,38 @@ class _CarDetailScreenState extends State { } } + /// Opens one of the record sheets and reloads if it saved. Every sheet pops + /// `true` on success, so they all route through here. + Future _sheet(Widget sheet) async { + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => sheet, + ); + if (saved == true) _reload(); + } + + /// Confirms, deletes, then reloads. [what] names the record in the prompt. + Future _deleteRecord(String what, Future Function() delete) async { + final ok = await _confirm("Delete this $what?"); + if (!ok) return; + try { + await delete(); + _reload(); + } catch (e) { + _snack("Delete failed: $e"); + } + } + + Future _completeReminder(Reminder r) async { + try { + await apiClient.completeReminder(r.id); + _reload(); + } catch (e) { + _snack("Could not complete: $e"); + } + } + Future _confirm(String message) async { final res = await showDialog( context: context, @@ -237,7 +305,7 @@ class _CarDetailScreenState extends State { final status = serviceStatus(latest, car); return DefaultTabController( - length: 3, + length: 8, child: Scaffold( appBar: AppBar( title: Text(car.name), @@ -268,12 +336,18 @@ class _CarDetailScreenState extends State { _deleteCar(car, data.services.length, data.parts.length), ), ], + // Same order as the web app's CarDetail tabs. bottom: const 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"), ], ), ), @@ -309,6 +383,87 @@ class _CarDetailScreenState extends State { )) .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, @@ -322,6 +477,33 @@ class _CarDetailScreenState extends State { )) .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(), + ), ], ), ), @@ -437,6 +619,8 @@ class _InfoTab extends StatelessWidget { _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)), @@ -526,6 +710,7 @@ class _ServiceTile extends StatelessWidget { const SizedBox(height: 8), Wrap(spacing: 6, runSpacing: 6, children: chips), ], + _AttachmentLine(path: "/service-records", id: record.id, record: record), if (record.notes.isNotEmpty) ...[ const SizedBox(height: 6), Text(record.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), @@ -563,8 +748,19 @@ class _PartTile extends StatelessWidget { child: ListTile( dense: true, title: Text(part.name, style: const TextStyle(fontWeight: FontWeight.w600)), - subtitle: Text(part.partNumber.isEmpty ? "—" : part.partNumber, - style: DriverVault.mono(context, size: 12, weight: FontWeight.w400, color: DriverVault.muted(context))), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(part.partNumber.isEmpty ? "—" : part.partNumber, + style: DriverVault.mono(context, + size: 12, weight: FontWeight.w400, color: DriverVault.muted(context))), + if (part.notes.isNotEmpty) + Text(part.notes, + style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + _AttachmentLine(path: "/parts", id: part.id, record: part), + ], + ), trailing: (onEdit != null || onDelete != null) ? _RowMenu(onEdit: onEdit, onDelete: onDelete) : null, @@ -573,11 +769,13 @@ class _PartTile extends StatelessWidget { } } -/// Overflow menu with Edit/Delete for a service/part row (write-gated). +/// Overflow menu with Edit/Delete for a record row (write-gated). [onComplete] +/// adds the reminders' extra action. class _RowMenu extends StatelessWidget { final VoidCallback? onEdit; final VoidCallback? onDelete; - const _RowMenu({this.onEdit, this.onDelete}); + final VoidCallback? onComplete; + const _RowMenu({this.onEdit, this.onDelete, this.onComplete}); @override Widget build(BuildContext context) { return PopupMenuButton( @@ -586,8 +784,11 @@ class _RowMenu extends StatelessWidget { onSelected: (v) { if (v == "edit") onEdit?.call(); if (v == "delete") onDelete?.call(); + if (v == "complete") onComplete?.call(); }, itemBuilder: (_) => [ + if (onComplete != null) + const PopupMenuItem(value: "complete", child: Text("Mark done")), if (onEdit != null) const PopupMenuItem(value: "edit", child: Text("Edit")), if (onDelete != null) const PopupMenuItem( @@ -597,6 +798,509 @@ class _RowMenu extends StatelessWidget { } } +/// The card every record tile sits in. +class _RecordCard extends StatelessWidget { + final Widget child; + const _RecordCard({required this.child}); + @override + Widget build(BuildContext context) => Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: Padding(padding: const EdgeInsets.all(12), child: child), + ); +} + +/// The status pill used by the record tiles, in the shared badge colours. +class _Badge extends StatelessWidget { + final Status status; + const _Badge(this.status); + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: + BoxDecoration(color: status.bg(dark), borderRadius: BorderRadius.circular(999)), + child: Text(status.label, + style: TextStyle( + color: status.fg(dark), fontSize: 11, fontWeight: FontWeight.w600)), + ); + } +} + +/// A tappable "Receipt.pdf" line that fetches the record's attachment and hands +/// it to the phone's viewer. Renders nothing when there is no file. +class _AttachmentLine extends StatelessWidget { + final String path; + final String id; + final HasAttachment record; + const _AttachmentLine({required this.path, required this.id, required this.record}); + + @override + Widget build(BuildContext context) { + if (!record.hasFile) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: InkWell( + onTap: () => openAttachment(context, path, id, record.fileName), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.attach_file, size: 14, color: DriverVault.brandOnTint(context)), + const SizedBox(width: 4), + Flexible( + child: Text( + record.fileName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: DriverVault.brandOnTint(context), + fontWeight: FontWeight.w500), + ), + ), + ]), + ), + ); + } +} + +/// A muted secondary line, used for the tiles' detail rows. +Widget _sub(BuildContext context, String text) => Text( + text, + style: TextStyle(fontSize: 12, color: DriverVault.muted(context)), + ); + +class _TechnicalCheckTile extends StatelessWidget { + final TechnicalCheck check; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _TechnicalCheckTile({required this.check, this.onEdit, this.onDelete}); + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDate(check.date), style: const TextStyle(fontWeight: FontWeight.w600)), + Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: check.passed + ? (dark ? DriverVault.successSoftDark : DriverVault.successSoft) + : (dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft), + borderRadius: BorderRadius.circular(6), + ), + child: Text(check.passed ? "Passed" : "Failed", + style: TextStyle( + color: check.passed ? DriverVault.success : DriverVault.danger, + fontSize: 11, + fontWeight: FontWeight.w600)), + ), + if (onEdit != null || onDelete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete), + ]), + ], + ), + const SizedBox(height: 4), + // A failed check derives no next date, so the badge is only meaningful + // when there is one. + if (check.nextCheckDate != null) + Row(children: [ + Expanded(child: _sub(context, "Next: ${formatDate(check.nextCheckDate)}")), + _Badge(expiryStatus(check.expiry)), + ]) + else + _sub(context, "No next date derived from a failed check."), + if (check.cost > 0 || check.station.isNotEmpty) ...[ + const SizedBox(height: 4), + _sub( + context, + [ + if (check.cost > 0) formatMoney(check.cost), + if (check.station.isNotEmpty) check.station, + ].join(" · ")), + ], + _AttachmentLine(path: "/technical-checks", id: check.id, record: check), + if (check.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(check.notes, + style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ); + } +} + +class _MaintenanceTile extends StatelessWidget { + final MaintenanceEntry entry; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _MaintenanceTile({required this.entry, this.onEdit, this.onDelete}); + + static const _typeLabels = { + "repair": "Repair", + "inspection": "Inspection", + "bodywork": "Bodywork", + "tyres": "Tyres", + "diagnostics": "Diagnostics", + "recall": "Recall", + "warranty": "Warranty work", + "other": "Other", + }; + static const _statusLabels = { + "scheduled": "Scheduled", + "in_progress": "In progress", + "completed": "Completed", + }; + + @override + Widget build(BuildContext context) { + final warranty = warrantyStatus(entry); + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(entry.description.isEmpty ? "—" : entry.description, + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + if (onEdit != null || onDelete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete), + ], + ), + const SizedBox(height: 4), + _sub( + context, + [ + formatDate(entry.date), + if (entry.km > 0) formatKm(entry.km), + _typeLabels[entry.type] ?? entry.type, + _statusLabels[entry.status] ?? entry.status, + ].join(" · ")), + if (entry.workshop.isNotEmpty || entry.location.isNotEmpty) ...[ + const SizedBox(height: 4), + _sub( + context, + [ + if (entry.workshop.isNotEmpty) entry.workshop, + if (entry.location.isNotEmpty) entry.location, + ].join(" · ")), + ], + if (entry.partsUsed.isNotEmpty) ...[ + const SizedBox(height: 4), + _sub(context, "Parts: ${entry.partsUsed}"), + ], + if (entry.totalCost > 0) ...[ + const SizedBox(height: 4), + Text("Total: ${formatMoney(entry.totalCost)}", + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + ], + if (warranty != null) ...[ + const SizedBox(height: 6), + _Badge(warranty), + ], + _AttachmentLine(path: "/maintenance", id: entry.id, record: entry), + if (entry.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(entry.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ); + } +} + +/// The refill history's summary panel. Every figure here is the server's; the +/// nulls it sends for anything it could not derive read as "—". +class _FuelStatsPanel extends StatelessWidget { + final FuelStats stats; + const _FuelStatsPanel({required this.stats}); + + @override + Widget build(BuildContext context) { + if (stats.entries == 0) return const SizedBox.shrink(); + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Fuel summary", style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _stat(context, "Average", formatConsumption(stats.avgConsumptionL100)), + _stat(context, "Best", formatConsumption(stats.bestConsumptionL100)), + _stat(context, "Worst", formatConsumption(stats.worstConsumptionL100)), + _stat(context, "Average", formatKmPerLiter(stats.avgKmPerLiter)), + _stat(context, "Cost per km", formatMoney(stats.costPerKm)), + _stat(context, "Price per litre", formatMoney(stats.avgPricePerLiter)), + _stat(context, "Total litres", formatLiters(stats.totalLiters)), + _stat(context, "Total cost", formatMoney(stats.totalCost)), + _stat(context, "Refills", "${stats.entries}"), + _stat(context, "Tracked distance", formatKm(stats.trackedDistanceKm)), + ], + ), + const SizedBox(height: 8), + // Without this the tracked distance reads as a mistake whenever the + // history starts or ends on a partial fill. + _sub( + context, + "Averages cover the distance between full tanks — the stretches the litres on" + " record actually account for."), + ], + ), + ); + } + + 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 _FuelTile extends StatelessWidget { + final FuelEntry entry; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _FuelTile({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, + [ + formatLiters(entry.liters), + if (entry.cost > 0) formatMoney(entry.cost), + if (entry.pricePerLiter != null) + "${entry.pricePerLiter!.toStringAsFixed(3)}/L", + ].join(" · ")), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 6, children: [ + _tag(context, entry.fullTank ? "Full tank" : "Partial fill", + muted: !entry.fullTank), + if (entry.missedFill) _tag(context, "Missed fill before", warn: true), + if (entry.station.isNotEmpty) _tag(context, entry.station, muted: true), + ]), + // Consumption exists only on a full tank that closes a computable + // window; anything else has nothing to report. + if (entry.consumptionL100 != null) ...[ + const SizedBox(height: 6), + Text( + "${formatConsumption(entry.consumptionL100)} · ${formatKmPerLiter(entry.kmPerLiter)}" + " over ${formatKm(entry.distanceKm)}", + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ], + _AttachmentLine(path: "/fuel-entries", 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(color: fg, fontSize: 11, fontWeight: FontWeight.w500)), + ); + } +} + +class _DocumentTile extends StatelessWidget { + final CarDocument doc; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _DocumentTile({required this.doc, this.onEdit, this.onDelete}); + + static const _typeLabels = { + "insurance": "Insurance", + "pollution": "Pollution certificate", + "registration": "Registration", + "inspection": "Inspection", + "roadTax": "Road tax", + "warranty": "Warranty", + "other": "Other", + }; + + @override + Widget build(BuildContext context) { + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(doc.title.isEmpty ? "—" : doc.title, + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Row(children: [ + _Badge(expiryStatus(doc.expiry)), + if (onEdit != null || onDelete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete), + ]), + ], + ), + const SizedBox(height: 4), + _sub( + context, + [ + _typeLabels[doc.type] ?? doc.type, + if (doc.provider.isNotEmpty) doc.provider, + if (doc.reference.isNotEmpty) doc.reference, + ].join(" · ")), + const SizedBox(height: 4), + _sub( + context, + "Issued ${formatDate(doc.issueDate)} · Renews ${formatDate(doc.expiryDate)}" + "${doc.cost > 0 ? " · ${formatMoney(doc.cost)}" : ""}"), + _AttachmentLine(path: "/car-documents", id: doc.id, record: doc), + if (doc.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(doc.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ); + } +} + +class _ReminderTile extends StatelessWidget { + final Reminder reminder; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onComplete; + const _ReminderTile({ + required this.reminder, + this.onEdit, + this.onDelete, + this.onComplete, + }); + + static const _typeLabels = { + "maintenance": "Maintenance", + "document": "Document renewal", + "service": "Service", + "inspection": "Inspection", + "other": "Other", + }; + + @override + Widget build(BuildContext context) { + final r = reminder; + return _RecordCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + r.title.isEmpty ? "—" : r.title, + style: TextStyle( + fontWeight: FontWeight.w600, + decoration: r.done ? TextDecoration.lineThrough : null, + color: r.done ? DriverVault.muted(context) : null, + ), + ), + ), + Row(children: [ + _Badge(reminderStatus(r)), + if (onEdit != null || onDelete != null || onComplete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete, onComplete: onComplete), + ]), + ], + ), + const SizedBox(height: 4), + _sub( + context, + [ + _typeLabels[r.type] ?? r.type, + if (r.dueDate != null) "on ${formatDate(r.dueDate)}", + if (r.dueKm > 0) "at ${formatKm(r.dueKm)}", + ].join(" · ")), + if (r.repeats) ...[ + const SizedBox(height: 4), + _sub( + context, + "Repeats every ${[ + if (r.repeatDays > 0) "${r.repeatDays}d", + if (r.repeatKm > 0) formatKm(r.repeatKm), + ].join(" · ")}"), + ], + if (r.auto) ...[ + const SizedBox(height: 6), + _sub(context, "Added automatically — edit the record it came from to change it."), + ], + if (r.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(r.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ); + } +} + class _Empty extends StatelessWidget { final String text; const _Empty(this.text); @@ -908,6 +1612,7 @@ class _ServiceSheetState extends State<_ServiceSheet> { late final TextEditingController _km; late final TextEditingController _notes; late bool _oil, _engine, _cabin; + final _pending = PendingAttachment(); bool _saving = false; String? _error; @@ -947,10 +1652,15 @@ class _ServiceSheetState extends State<_ServiceSheet> { "notes": _notes.text.trim(), }; try { - if (_isEdit) { - await apiClient.updateService(widget.record!.id, payload); - } else { - await apiClient.createService(payload); + final saved = _isEdit + ? await apiClient.updateService(widget.record!.id, payload) + : await apiClient.createService(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/service-records", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Record saved, but the receipt did not upload: $e"); + } } if (mounted) Navigator.pop(context, true); } catch (e) { @@ -1035,6 +1745,15 @@ class _ServiceSheetState extends State<_ServiceSheet> { decoration: const InputDecoration(labelText: "Notes", border: OutlineInputBorder()), ), const SizedBox(height: 8), + AttachmentField( + path: "/service-records", + record: widget.record, + recordId: widget.record?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Receipt or service-book page", + ), + const SizedBox(height: 8), Text( "Next service (+${widget.car.serviceIntervalDays}d / +${formatKm(widget.car.serviceIntervalKm)}) is computed automatically.", style: const TextStyle(color: Colors.grey, fontSize: 12), @@ -1068,6 +1787,8 @@ class _PartSheet extends StatefulWidget { class _PartSheetState extends State<_PartSheet> { late final TextEditingController _name; late final TextEditingController _partNumber; + late final TextEditingController _notes; + final _pending = PendingAttachment(); bool _saving = false; String? _error; @@ -1078,12 +1799,14 @@ class _PartSheetState extends State<_PartSheet> { super.initState(); _name = TextEditingController(text: widget.part?.name ?? ""); _partNumber = TextEditingController(text: widget.part?.partNumber ?? ""); + _notes = TextEditingController(text: widget.part?.notes ?? ""); } @override void dispose() { _name.dispose(); _partNumber.dispose(); + _notes.dispose(); super.dispose(); } @@ -1101,12 +1824,18 @@ class _PartSheetState extends State<_PartSheet> { "car": widget.carId, "name": name, "partNumber": _partNumber.text.trim(), + "notes": _notes.text.trim(), }; try { - if (_isEdit) { - await apiClient.updatePart(widget.part!.id, payload); - } else { - await apiClient.createPart(payload); + final saved = _isEdit + ? await apiClient.updatePart(widget.part!.id, payload) + : await apiClient.createPart(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/parts", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Part saved, but the photo did not upload: $e"); + } } if (mounted) Navigator.pop(context, true); } catch (e) { @@ -1145,7 +1874,25 @@ class _PartSheetState extends State<_PartSheet> { const SizedBox(height: 8), TextField( controller: _partNumber, - decoration: const InputDecoration(labelText: "Part number", border: OutlineInputBorder()), + decoration: const InputDecoration( + labelText: "Part number", hintText: "04152-YZZA7", border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + TextField( + controller: _notes, + decoration: const InputDecoration( + labelText: "Notes", + hintText: "Fits 2015–2020 · buy in pairs", + border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + AttachmentField( + path: "/parts", + record: widget.part, + recordId: widget.part?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Photo or spec sheet", ), const SizedBox(height: 12), SizedBox( diff --git a/Phone App/lib/screens/car_form_sheet.dart b/Phone App/lib/screens/car_form_sheet.dart index 7058855..c85487c 100644 --- a/Phone App/lib/screens/car_form_sheet.dart +++ b/Phone App/lib/screens/car_form_sheet.dart @@ -50,6 +50,8 @@ class _CarFormSheetState extends State { TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""), "serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"), "serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"), + "technicalCheckIntervalDays": TextEditingController( + text: "${(car?.technicalCheckIntervalDays ?? 0) > 0 ? car!.technicalCheckIntervalDays : 365}"), }; } @@ -92,6 +94,7 @@ class _CarFormSheetState extends State { "currentKm": _int("currentKm", 0), "serviceIntervalDays": _int("serviceIntervalDays", 365), "serviceIntervalKm": _int("serviceIntervalKm", 15000), + "technicalCheckIntervalDays": _int("technicalCheckIntervalDays", 365), }; try { final saved = _isEdit @@ -110,14 +113,22 @@ class _CarFormSheetState extends State { : "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"; Widget _field(String key, String label, - {TextInputType? keyboard, TextCapitalization caps = TextCapitalization.none}) { + {TextInputType? keyboard, + TextCapitalization caps = TextCapitalization.none, + String? helper}) { return Padding( padding: const EdgeInsets.only(bottom: 10), child: TextField( controller: _c[key], keyboardType: keyboard, textCapitalization: caps, - decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), isDense: true), + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + helperText: helper, + helperMaxLines: 3, + ), ), ); } @@ -236,6 +247,10 @@ class _CarFormSheetState extends State { child: _field("serviceIntervalKm", "Service interval (km)", keyboard: TextInputType.number)), ]), + _field("technicalCheckIntervalDays", "Technical check interval (days)", + keyboard: TextInputType.number, + helper: "Prefills each check's next-due date. Any check can override it with the" + " date printed on its certificate."), const SizedBox(height: 8), SizedBox( width: double.infinity, diff --git a/Phone App/lib/screens/record_form_sheets.dart b/Phone App/lib/screens/record_form_sheets.dart new file mode 100644 index 0000000..c65f165 --- /dev/null +++ b/Phone App/lib/screens/record_form_sheets.dart @@ -0,0 +1,985 @@ +import "package:flutter/material.dart"; + +import "../api.dart"; +import "../format.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. + +/// 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 +/// the whole of the value — converting a local midnight to UTC instead would +/// hand the server the previous day everywhere east of Greenwich. +String _isoDate(DateTime d) => DateTime.utc(d.year, d.month, d.day).toIso8601String(); + +String? _isoDateOrNull(DateTime? d) => d == null ? null : _isoDate(d); + +/// Shared chrome for these sheets: title, error banner, scrolling body, and a +/// save button that reflects the in-flight state. +class _SheetScaffold extends StatelessWidget { + final String title; + final String? error; + final bool saving; + final String saveLabel; + final VoidCallback? onSave; + final List children; + + const _SheetScaffold({ + required this.title, + required this.saving, + required this.saveLabel, + required this.onSave, + required this.children, + this.error, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(error!, style: const TextStyle(color: DriverVault.danger)), + ), + ...children, + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: saving ? null : onSave, + child: Text(saving ? "Saving…" : saveLabel), + ), + ), + ], + ), + ), + ); + } +} + +/// A plain text field with the sheets' shared decoration. +Widget _field( + TextEditingController c, + String label, { + TextInputType? keyboard, + String? hint, + String? helper, + TextCapitalization caps = TextCapitalization.none, +}) => + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: c, + keyboardType: keyboard, + textCapitalization: caps, + decoration: InputDecoration( + labelText: label, + hintText: hint, + helperText: helper, + helperMaxLines: 4, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ); + +/// A tappable read-only field that opens a date picker. [onChanged] receives +/// null when the value is cleared, which is how an optional date is unset. +Widget _dateField( + BuildContext context, + String label, + DateTime? value, + ValueChanged onChanged, { + bool clearable = true, + String? helper, +}) => + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: value ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365 * 10)), + ); + if (picked != null) onChanged(picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + helperText: helper, + helperMaxLines: 4, + suffixIcon: value == null || !clearable + ? const Icon(Icons.calendar_today, size: 18) + : IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () => onChanged(null), + ), + ), + child: Text(value == null ? "—" : formatDate(value)), + ), + ), + ); + +Widget _dropdown( + String label, + T value, + List<(T, String)> options, + ValueChanged onChanged, +) => + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: DropdownButtonFormField( + initialValue: value, + decoration: InputDecoration( + labelText: label, border: const OutlineInputBorder(), isDense: true), + items: [ + for (final o in options) DropdownMenuItem(value: o.$1, child: Text(o.$2)), + ], + onChanged: onChanged, + ), + ); + +/// A bordered group with a caption and a hint beneath, standing in for the web's +///
. +Widget _group(BuildContext context, String legend, List children, {String? hint}) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InputDecorator( + decoration: InputDecoration( + labelText: legend, + border: const OutlineInputBorder(), + helperText: hint, + helperMaxLines: 5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ); + +double? _numOrNull(TextEditingController c) => double.tryParse(c.text.trim().replaceAll(",", ".")); +double _num(TextEditingController c) => _numOrNull(c) ?? 0; +int _int(TextEditingController c) => int.tryParse(c.text.trim()) ?? 0; + +// --- technical checks --- + +/// Mirrors the web TechnicalCheckFormModal. Shaped like a service record minus +/// the odometer: an inspection falls due on a date whatever the mileage reads. +class TechnicalCheckSheet extends StatefulWidget { + final String carId; + final Car car; + final TechnicalCheck? check; // null => create + const TechnicalCheckSheet({super.key, required this.carId, required this.car, this.check}); + + @override + State createState() => _TechnicalCheckSheetState(); +} + +class _TechnicalCheckSheetState extends State { + late DateTime _date; + late String _result; + DateTime? _validUntil; + late final TextEditingController _cost, _station, _notes; + final _pending = PendingAttachment(); + bool _saving = false; + String? _error; + + bool get _isEdit => widget.check != null; + + @override + void initState() { + super.initState(); + final c = widget.check; + _date = c?.date ?? DateTime.now(); + _result = c?.result ?? "passed"; + _validUntil = c?.validUntil; + _cost = TextEditingController(text: (c != null && c.cost > 0) ? "${c.cost}" : ""); + _station = TextEditingController(text: c?.station ?? ""); + _notes = TextEditingController(text: c?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [_cost, _station, _notes]) { + c.dispose(); + } + super.dispose(); + } + + /// What the server will derive if valid-until is left blank, shown so the + /// effect of leaving it empty is visible before saving rather than after. + String? get _derivedHint { + if (_result == "failed") { + return "A failed check certifies nothing, so no next date is derived from it."; + } + final days = widget.car.technicalCheckIntervalDays > 0 + ? widget.car.technicalCheckIntervalDays + : 365; + final next = _date.add(Duration(days: days)); + return "Leave blank to use the car's interval (+${days}d → ${formatDate(next)})." + " Enter the date on the certificate when it differs."; + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "date": _isoDate(_date), + "result": _result, + // Blank means "derive it from the car's interval" — send null, not a date, + // so clearing the field on an edit actually removes the override. + "validUntil": _isoDateOrNull(_validUntil), + "cost": _num(_cost), + "station": _station.text.trim(), + "notes": _notes.text.trim(), + }; + try { + final saved = _isEdit + ? await apiClient.updateTechnicalCheck(widget.check!.id, payload) + : await apiClient.createTechnicalCheck(payload); + await _applyFile(saved.id); + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // The metadata is already committed by the time this runs, so a file failure + // is reported as an attachment error rather than a failed save. + Future _applyFile(String id) async { + if (_pending.isEmpty) return; + try { + await applyAttachment("/technical-checks", id, _pending); + } catch (e) { + throw ApiException(0, "Check saved, but the certificate did not upload: $e"); + } + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + title: _isEdit ? "Edit technical check" : "Add technical check", + error: _error, + saving: _saving, + saveLabel: _isEdit ? "Save changes" : "Add check", + onSave: _save, + children: [ + Row(children: [ + Expanded( + child: _dateField(context, "Check date *", _date, + (v) => setState(() => _date = v ?? _date), + clearable: false), + ), + const SizedBox(width: 8), + Expanded( + child: _dropdown("Result *", _result, const [("passed", "Passed"), ("failed", "Failed")], + (v) => setState(() => _result = v ?? "passed")), + ), + ]), + _dateField(context, "Valid until", _validUntil, (v) => setState(() => _validUntil = v), + helper: _derivedHint), + Row(children: [ + Expanded(child: _field(_cost, "Cost", keyboard: const TextInputType.numberWithOptions(decimal: true))), + const SizedBox(width: 8), + Expanded(child: _field(_station, "Station", hint: "Stacja Kontroli Pojazdów")), + ]), + AttachmentField( + path: "/technical-checks", + record: widget.check, + recordId: widget.check?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Inspection certificate", + ), + const SizedBox(height: 10), + _field(_notes, "Notes"), + ], + ); + } +} + +// --- fuel --- + +/// Mirrors the web FuelFormModal. +class FuelSheet extends StatefulWidget { + final String carId; + final FuelEntry? entry; // null => create + const FuelSheet({super.key, required this.carId, this.entry}); + + @override + State createState() => _FuelSheetState(); +} + +class _FuelSheetState extends State { + late DateTime _date; + late final TextEditingController _km, _liters, _cost, _station, _notes; + late bool _fullTank, _missedFill; + 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 > 0) ? "${e.km}" : ""); + _liters = TextEditingController(text: (e != null && e.liters > 0) ? "${e.liters}" : ""); + _cost = TextEditingController(text: (e != null && e.cost > 0) ? "${e.cost}" : ""); + // A full tank is the common case and the one that makes the entry count + // towards efficiency, so it is the default. + _fullTank = e?.fullTank ?? true; + _missedFill = e?.missedFill ?? false; + _station = TextEditingController(text: e?.station ?? ""); + _notes = TextEditingController(text: e?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [_km, _liters, _cost, _station, _notes]) { + c.dispose(); + } + super.dispose(); + } + + String? get _pricePerLiter { + final l = _numOrNull(_liters); + final c = _numOrNull(_cost); + if (l == null || c == null || l == 0 || c == 0) return null; + return (c / l).toStringAsFixed(3); + } + + Future _save() async { + final km = _int(_km); + final liters = _num(_liters); + if (km <= 0) { + setState(() => _error = "Odometer is required."); + return; + } + if (liters <= 0) { + setState(() => _error = "Litres are required."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "date": _isoDate(_date), + "km": km, + "liters": liters, + "cost": _num(_cost), + "fullTank": _fullTank, + "missedFill": _missedFill, + "station": _station.text.trim(), + "notes": _notes.text.trim(), + }; + try { + final saved = _isEdit + ? await apiClient.updateFuelEntry(widget.entry!.id, payload) + : await apiClient.createFuelEntry(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/fuel-entries", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Refill 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 ppl = _pricePerLiter; + return _SheetScaffold( + title: _isEdit ? "Edit refill" : "Log refill", + error: _error, + saving: _saving, + saveLabel: _isEdit ? "Save changes" : "Log refill", + onSave: _save, + children: [ + Row(children: [ + Expanded( + child: _dateField(context, "Date *", _date, (v) => setState(() => _date = v ?? _date), + clearable: false), + ), + const SizedBox(width: 8), + Expanded( + child: _field(_km, "Odometer (km) *", + keyboard: TextInputType.number, hint: "16138")), + ]), + Row(children: [ + Expanded( + child: _field(_liters, "Litres *", + keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "42.5")), + const SizedBox(width: 8), + Expanded( + child: _field(_cost, "Total cost", + keyboard: const TextInputType.numberWithOptions(decimal: true), hint: "285.00")), + ]), + if (ppl != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text("Price per litre: $ppl", style: Theme.of(context).textTheme.bodySmall), + ), + _group( + context, + "Tank", + [ + CheckboxListTile( + value: _fullTank, + onChanged: (v) => setState(() => _fullTank = v ?? false), + title: const Text("Filled to full"), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ), + CheckboxListTile( + value: _missedFill, + onChanged: (v) => setState(() => _missedFill = v ?? false), + title: const Text("I missed logging a refill before this one"), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + ), + ], + hint: "Consumption is measured between full tanks, so partial fills count towards the" + " next full one. Flagging a missed refill leaves that stretch out of the figures" + " instead of reporting it as unrealistically economical.", + ), + Row(children: [ + Expanded(child: _field(_station, "Station", hint: "Orlen")), + const SizedBox(width: 8), + Expanded(child: _field(_notes, "Notes")), + ]), + AttachmentField( + path: "/fuel-entries", + record: widget.entry, + recordId: widget.entry?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Receipt", + ), + ], + ); + } +} + +// --- maintenance --- + +/// Mirrors the web MaintenanceFormModal — a workshop visit or repair, i.e. work +/// outside the routine service schedule. +class MaintenanceSheet extends StatefulWidget { + final String carId; + final MaintenanceEntry? entry; // null => create + const MaintenanceSheet({super.key, required this.carId, this.entry}); + + @override + State createState() => _MaintenanceSheetState(); +} + +const _maintenanceTypes = [ + ("repair", "Repair"), + ("inspection", "Inspection"), + ("bodywork", "Bodywork"), + ("tyres", "Tyres"), + ("diagnostics", "Diagnostics"), + ("recall", "Recall"), + ("warranty", "Warranty work"), + ("other", "Other"), +]; + +const _maintenanceStatuses = [ + ("scheduled", "Scheduled"), + ("in_progress", "In progress"), + ("completed", "Completed"), +]; + +class _MaintenanceSheetState extends State { + late DateTime _date; + late String _type, _status; + DateTime? _warrantyUntil; + late final TextEditingController _km, + _workshop, + _location, + _description, + _partsUsed, + _laborCost, + _partsCost, + _invoiceNumber, + _notes; + 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(); + _type = e?.type ?? "repair"; + _status = e?.status ?? "completed"; + _warrantyUntil = e?.warrantyUntil; + _km = TextEditingController(text: (e != null && e.km > 0) ? "${e.km}" : ""); + _workshop = TextEditingController(text: e?.workshop ?? ""); + _location = TextEditingController(text: e?.location ?? ""); + _description = TextEditingController(text: e?.description ?? ""); + _partsUsed = TextEditingController(text: e?.partsUsed ?? ""); + _laborCost = TextEditingController(text: (e != null && e.laborCost > 0) ? "${e.laborCost}" : ""); + _partsCost = TextEditingController(text: (e != null && e.partsCost > 0) ? "${e.partsCost}" : ""); + _invoiceNumber = TextEditingController(text: e?.invoiceNumber ?? ""); + _notes = TextEditingController(text: e?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [ + _km, + _workshop, + _location, + _description, + _partsUsed, + _laborCost, + _partsCost, + _invoiceNumber, + _notes + ]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final description = _description.text.trim(); + if (description.isEmpty) { + setState(() => _error = "Describe what was done."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "date": _isoDate(_date), + "km": _int(_km), + "type": _type, + "status": _status, + "workshop": _workshop.text.trim(), + "location": _location.text.trim(), + "description": description, + "partsUsed": _partsUsed.text.trim(), + "laborCost": _num(_laborCost), + "partsCost": _num(_partsCost), + "invoiceNumber": _invoiceNumber.text.trim(), + "warrantyUntil": _isoDateOrNull(_warrantyUntil), + "notes": _notes.text.trim(), + }; + try { + final saved = _isEdit + ? await apiClient.updateMaintenance(widget.entry!.id, payload) + : await apiClient.createMaintenance(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/maintenance", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Visit saved, but the invoice 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 total = _num(_laborCost) + _num(_partsCost); + return _SheetScaffold( + title: _isEdit ? "Edit workshop visit" : "Log workshop visit", + error: _error, + saving: _saving, + saveLabel: _isEdit ? "Save changes" : "Log visit", + onSave: _save, + children: [ + Row(children: [ + Expanded( + child: _dateField(context, "Date *", _date, (v) => setState(() => _date = v ?? _date), + clearable: false), + ), + const SizedBox(width: 8), + Expanded( + child: _field(_km, "Odometer (km)", keyboard: TextInputType.number, hint: "16138")), + ]), + Row(children: [ + Expanded( + child: _dropdown("Type", _type, _maintenanceTypes, + (v) => setState(() => _type = v ?? "repair"))), + const SizedBox(width: 8), + Expanded( + child: _dropdown("Status", _status, _maintenanceStatuses, + (v) => setState(() => _status = v ?? "completed"))), + ]), + _field(_description, "What was done *", hint: "Replaced alternator and drive belt"), + Row(children: [ + Expanded(child: _field(_workshop, "Workshop", hint: "Auto Serwis Kowalski")), + const SizedBox(width: 8), + Expanded(child: _field(_location, "Location", hint: "Kraków")), + ]), + _field(_partsUsed, "Parts replaced", hint: "Alternator 27060-0T010, belt 90916-02660"), + Row(children: [ + Expanded( + child: _field(_laborCost, "Labour cost", + keyboard: const TextInputType.numberWithOptions(decimal: true))), + const SizedBox(width: 8), + Expanded( + child: _field(_partsCost, "Parts cost", + keyboard: const TextInputType.numberWithOptions(decimal: true))), + ]), + if (total > 0) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: + Text("Total: ${formatMoney(total)}", style: Theme.of(context).textTheme.bodySmall), + ), + Row(children: [ + Expanded(child: _field(_invoiceNumber, "Invoice number")), + const SizedBox(width: 8), + Expanded( + child: _dateField(context, "Warranty until", _warrantyUntil, + (v) => setState(() => _warrantyUntil = v)), + ), + ]), + AttachmentField( + path: "/maintenance", + record: widget.entry, + recordId: widget.entry?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Invoice", + ), + const SizedBox(height: 10), + _field(_notes, "Notes"), + ], + ); + } +} + +// --- documents --- + +const _documentTypes = [ + ("insurance", "Insurance"), + ("pollution", "Pollution certificate"), + ("registration", "Registration"), + ("inspection", "Inspection"), + ("roadTax", "Road tax"), + ("warranty", "Warranty"), + ("other", "Other"), +]; + +/// Mirrors the web DocumentFormModal. +class DocumentSheet extends StatefulWidget { + final String carId; + final CarDocument? doc; // null => create + const DocumentSheet({super.key, required this.carId, this.doc}); + + @override + State createState() => _DocumentSheetState(); +} + +class _DocumentSheetState extends State { + late String _type; + DateTime? _issueDate, _expiryDate; + late final TextEditingController _title, _provider, _reference, _cost, _notes; + final _pending = PendingAttachment(); + bool _saving = false; + String? _error; + + bool get _isEdit => widget.doc != null; + + @override + void initState() { + super.initState(); + final d = widget.doc; + _type = d?.type ?? "insurance"; + _issueDate = d?.issueDate; + _expiryDate = d?.expiryDate; + _title = TextEditingController(text: d?.title ?? ""); + _provider = TextEditingController(text: d?.provider ?? ""); + _reference = TextEditingController(text: d?.reference ?? ""); + _cost = TextEditingController(text: (d != null && d.cost > 0) ? "${d.cost}" : ""); + _notes = TextEditingController(text: d?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [_title, _provider, _reference, _cost, _notes]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final title = _title.text.trim(); + if (title.isEmpty) { + setState(() => _error = "Title is required."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "type": _type, + "title": title, + "provider": _provider.text.trim(), + "reference": _reference.text.trim(), + "issueDate": _isoDateOrNull(_issueDate), + "expiryDate": _isoDateOrNull(_expiryDate), + "cost": _num(_cost), + "notes": _notes.text.trim(), + }; + try { + final saved = _isEdit + ? await apiClient.updateDocument(widget.doc!.id, payload) + : await apiClient.createDocument(payload); + if (!_pending.isEmpty) { + try { + await applyAttachment("/car-documents", saved.id, _pending); + } catch (e) { + throw ApiException(0, "Document saved, but the scan 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) { + return _SheetScaffold( + title: _isEdit ? "Edit document" : "Add document", + error: _error, + saving: _saving, + saveLabel: _isEdit ? "Save changes" : "Add document", + onSave: _save, + children: [ + _dropdown("Type", _type, _documentTypes, (v) => setState(() => _type = v ?? "insurance")), + _field(_title, "Title *", hint: "Third-party liability 2026"), + Row(children: [ + Expanded(child: _field(_provider, "Provider", hint: "PZU")), + const SizedBox(width: 8), + Expanded(child: _field(_reference, "Policy / certificate no.")), + ]), + Row(children: [ + Expanded( + child: _dateField( + context, "Issued", _issueDate, (v) => setState(() => _issueDate = v))), + const SizedBox(width: 8), + Expanded( + child: _dateField( + context, "Renewal date", _expiryDate, (v) => setState(() => _expiryDate = v))), + ]), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + "Leave the renewal date blank for a document that never expires. Setting it adds a" + " reminder automatically.", + style: Theme.of(context).textTheme.bodySmall, + ), + ), + _field(_cost, "Cost", keyboard: const TextInputType.numberWithOptions(decimal: true)), + AttachmentField( + path: "/car-documents", + record: widget.doc, + recordId: widget.doc?.id, + pending: _pending, + onChanged: () => setState(() {}), + legend: "Scan or photo", + ), + const SizedBox(height: 10), + _field(_notes, "Notes"), + ], + ); + } +} + +// --- reminders --- + +const _reminderTypes = [ + ("maintenance", "Maintenance"), + ("document", "Document renewal"), + ("service", "Service"), + ("inspection", "Inspection"), + ("other", "Other"), +]; + +/// Mirrors the web ReminderFormModal. Reminders carry no attachment. +class ReminderSheet extends StatefulWidget { + final String carId; + final Car car; + final Reminder? reminder; // null => create + const ReminderSheet({super.key, required this.carId, required this.car, this.reminder}); + + @override + State createState() => _ReminderSheetState(); +} + +class _ReminderSheetState extends State { + late String _type; + DateTime? _dueDate; + late final TextEditingController _title, _dueKm, _repeatDays, _repeatKm, _notes; + bool _saving = false; + String? _error; + + bool get _isEdit => widget.reminder != null; + + /// Mirrors the server's rule: a reminder with neither trigger would never fire. + bool get _hasTrigger => _dueDate != null || _int(_dueKm) > 0; + bool get _isRecurring => _int(_repeatDays) > 0 || _int(_repeatKm) > 0; + + @override + void initState() { + super.initState(); + final r = widget.reminder; + _type = r?.type ?? "maintenance"; + _dueDate = r?.dueDate; + _title = TextEditingController(text: r?.title ?? ""); + _dueKm = TextEditingController(text: (r != null && r.dueKm > 0) ? "${r.dueKm}" : ""); + _repeatDays = + TextEditingController(text: (r != null && r.repeatDays > 0) ? "${r.repeatDays}" : ""); + _repeatKm = TextEditingController(text: (r != null && r.repeatKm > 0) ? "${r.repeatKm}" : ""); + _notes = TextEditingController(text: r?.notes ?? ""); + } + + @override + void dispose() { + for (final c in [_title, _dueKm, _repeatDays, _repeatKm, _notes]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + if (_title.text.trim().isEmpty) { + setState(() => _error = "Title is required."); + return; + } + if (!_hasTrigger) { + setState(() => _error = "Set a due date, a due odometer reading, or both."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "title": _title.text.trim(), + "type": _type, + "dueDate": _isoDateOrNull(_dueDate), + "dueKm": _int(_dueKm), + "repeatDays": _int(_repeatDays), + "repeatKm": _int(_repeatKm), + // Editing never silently closes a reminder; that is what Done does. + "done": widget.reminder?.done ?? false, + "notes": _notes.text.trim(), + }; + try { + if (_isEdit) { + await apiClient.updateReminder(widget.reminder!.id, payload); + } else { + await apiClient.createReminder(payload); + } + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final atKm = widget.car.currentKm > 0 + ? " The car is at ${formatKm(widget.car.currentKm)} now." + : ""; + return _SheetScaffold( + title: _isEdit ? "Edit reminder" : "Add reminder", + error: _error, + saving: _saving, + saveLabel: _isEdit ? "Save changes" : "Add reminder", + onSave: _hasTrigger ? _save : null, + children: [ + _field(_title, "Title *", hint: "Swap to winter tyres"), + _dropdown("Type", _type, _reminderTypes, (v) => setState(() => _type = v ?? "maintenance")), + _group( + context, + "Remind me", + [ + _dateField(context, "On date", _dueDate, (v) => setState(() => _dueDate = v)), + _field(_dueKm, "At odometer (km)", keyboard: TextInputType.number, hint: "30000"), + ], + hint: "Set either or both — with both, whichever comes first wins.$atKm", + ), + _group( + context, + "Repeat (optional)", + [ + _field(_repeatDays, "Every … days", keyboard: TextInputType.number, hint: "365"), + _field(_repeatKm, "Every … km", keyboard: TextInputType.number, hint: "15000"), + ], + hint: _isRecurring + ? "Marking this done will roll it forward instead of closing it." + : "Leave blank for a one-off reminder that closes when you mark it done.", + ), + _field(_notes, "Notes"), + ], + ); + } +} diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 5a4cbae..88fe26c 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -322,6 +322,67 @@ class _AccountSectionState extends State<_AccountSection> { // --- Appearance ------------------------------------------------------------ +// Language and region are two controls over the one stored BCP-47 tag, so the +// pair can be mixed freely (English in Poland, say) rather than being limited to +// the handful of combinations a single list could offer. +// +// Europe here means the sovereign states of the Council of Europe, plus Belarus, +// Russia, Vatican City and Kosovo — geographically European but not members. +// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries. +// US stays on the region list because it was there before this became a Europe +// list. The lists mirror the web app's, minus Luxembourgish and Romansh: intl +// ships no symbols for those two and throws rather than falling back, so +// offering them would break every date on screen. The browser has full ICU data +// behind it and does not have that limit. +// +// Labels are hand-kept because Dart has no Intl.DisplayNames — languages read as +// endonyms (the name a speaker would recognise regardless of the current UI +// language), regions and currencies in English. +const _languages = [ + ("sq", "Shqip"), ("hy", "Հայերեն"), ("az", "Azərbaycan"), ("eu", "Euskara"), + ("be", "Беларуская"), ("bs", "Bosanski"), ("bg", "Български"), ("ca", "Català"), + ("hr", "Hrvatski"), ("cs", "Čeština"), ("da", "Dansk"), ("nl", "Nederlands"), + ("en", "English"), ("et", "Eesti"), ("fi", "Suomi"), ("fr", "Français"), + ("gl", "Galego"), ("ka", "ქართული"), ("de", "Deutsch"), ("el", "Ελληνικά"), + ("hu", "Magyar"), ("is", "Íslenska"), ("ga", "Gaeilge"), ("it", "Italiano"), + ("lv", "Latviešu"), ("lt", "Lietuvių"), ("mk", "Македонски"), ("mt", "Malti"), + ("no", "Norsk"), ("pl", "Polski"), ("pt", "Português"), ("ro", "Română"), + ("ru", "Русский"), ("sr", "Српски"), ("sk", "Slovenčina"), ("sl", "Slovenščina"), + ("es", "Español"), ("sv", "Svenska"), ("tr", "Türkçe"), ("uk", "Українська"), + ("cy", "Cymraeg"), +]; + +const _regions = [ + ("AD", "Andorra"), ("AL", "Albania"), ("AM", "Armenia"), ("AT", "Austria"), + ("AZ", "Azerbaijan"), ("BA", "Bosnia & Herzegovina"), ("BE", "Belgium"), + ("BG", "Bulgaria"), ("BY", "Belarus"), ("CH", "Switzerland"), ("CY", "Cyprus"), + ("CZ", "Czechia"), ("DE", "Germany"), ("DK", "Denmark"), ("EE", "Estonia"), + ("ES", "Spain"), ("FI", "Finland"), ("FR", "France"), ("GB", "United Kingdom"), + ("GE", "Georgia"), ("GR", "Greece"), ("HR", "Croatia"), ("HU", "Hungary"), + ("IE", "Ireland"), ("IS", "Iceland"), ("IT", "Italy"), ("LI", "Liechtenstein"), + ("LT", "Lithuania"), ("LU", "Luxembourg"), ("LV", "Latvia"), ("MC", "Monaco"), + ("MD", "Moldova"), ("ME", "Montenegro"), ("MK", "North Macedonia"), ("MT", "Malta"), + ("NL", "Netherlands"), ("NO", "Norway"), ("PL", "Poland"), ("PT", "Portugal"), + ("RO", "Romania"), ("RS", "Serbia"), ("RU", "Russia"), ("SE", "Sweden"), + ("SI", "Slovenia"), ("SK", "Slovakia"), ("SM", "San Marino"), ("TR", "Türkiye"), + ("UA", "Ukraine"), ("US", "United States"), ("VA", "Vatican City"), ("XK", "Kosovo"), +]; + +/// Mirrors validCurrencies in the API's me.go and the users.currency select in +/// setup-pocketbase.mjs — all three have to list the same codes. +const _currencies = [ + ("EUR", "Euro"), ("GBP", "British pound"), ("CHF", "Swiss franc"), + ("PLN", "Polish złoty"), ("CZK", "Czech koruna"), ("HUF", "Hungarian forint"), + ("RON", "Romanian leu"), ("BGN", "Bulgarian lev"), ("DKK", "Danish krone"), + ("SEK", "Swedish krona"), ("NOK", "Norwegian krone"), ("ISK", "Icelandic króna"), + ("ALL", "Albanian lek"), ("AMD", "Armenian dram"), ("AZN", "Azerbaijani manat"), + ("BAM", "Bosnia-Herzegovina mark"), ("BYN", "Belarusian ruble"), + ("GEL", "Georgian lari"), ("MDL", "Moldovan leu"), ("MKD", "Macedonian denar"), + ("RSD", "Serbian dinar"), ("RUB", "Russian ruble"), ("TRY", "Turkish lira"), + ("UAH", "Ukrainian hryvnia"), ("USD", "US dollar"), ("CAD", "Canadian dollar"), + ("AUD", "Australian dollar"), ("JPY", "Japanese yen"), +]; + class _AppearanceSection extends StatefulWidget { final void Function(String) onError; const _AppearanceSection({required this.onError}); @@ -336,12 +397,14 @@ class _AppearanceSectionState extends State<_AppearanceSection> { "theme": appSettings.theme, "locale": appSettings.locale, "dateFormat": appSettings.dateFormat, + "currency": appSettings.currency, "fontSize": appSettings.fontSize, }; appSettings.patch( theme: patch["theme"], locale: patch["locale"], dateFormat: patch["dateFormat"], + currency: patch["currency"], fontSize: patch["fontSize"], ); setState(() {}); @@ -352,6 +415,7 @@ class _AppearanceSectionState extends State<_AppearanceSection> { theme: prev["theme"], locale: prev["locale"], dateFormat: prev["dateFormat"], + currency: prev["currency"], fontSize: prev["fontSize"], ); setState(() {}); @@ -359,6 +423,24 @@ class _AppearanceSectionState extends State<_AppearanceSection> { } } + /// Language and region write the one locale field, so either control sends the + /// joined tag. The half the user did not touch is read back from the pickers + /// rather than from the raw locale, so changing only one of them cannot save a + /// code the other picker is not showing. + Future _saveLocale({String? lang, String? region}) => _save({ + "locale": "${lang ?? _language}-${region ?? _region}", + }); + + String get _language => _knownOr(_languages, appSettings.language, "en"); + String get _region => _knownOr(_regions, appSettings.region, "US"); + + /// A value the picker cannot show would render blank and silently reset on the + /// next save, so an unknown code (a locale set from the web, whose lists are + /// wider than these) falls back to the list's default rather than being + /// dropped. + static String _knownOr(List<(String, String)> options, String value, String fallback) => + options.any((o) => o.$1 == value) ? value : fallback; + @override Widget build(BuildContext context) { return _Card( @@ -376,20 +458,55 @@ class _AppearanceSectionState extends State<_AppearanceSection> { onSelectionChanged: (s) => _save({"theme": s.first}), ), const SizedBox(height: 16), - const Text("Language & region", style: TextStyle(fontWeight: FontWeight.w500)), + const Text("Language", style: TextStyle(fontWeight: FontWeight.w500)), const SizedBox(height: 6), DropdownButtonFormField( - initialValue: appSettings.locale, + initialValue: _language, decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), - items: const [ - DropdownMenuItem(value: "en-US", child: Text("English (US)")), - DropdownMenuItem(value: "en-GB", child: Text("English (UK)")), - DropdownMenuItem(value: "pl-PL", child: Text("Polski")), - DropdownMenuItem(value: "de-DE", child: Text("Deutsch")), - DropdownMenuItem(value: "fr-FR", child: Text("Français")), - DropdownMenuItem(value: "es-ES", child: Text("Español")), + items: [ + for (final l in _languages) DropdownMenuItem(value: l.$1, child: Text(l.$2)), ], - onChanged: (v) => v == null ? null : _save({"locale": v}), + onChanged: (v) => v == null ? null : _saveLocale(lang: v), + ), + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text("Names of months and days.", + style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + const SizedBox(height: 16), + const Text("Region", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: _region, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final r in _regions) DropdownMenuItem(value: r.$1, child: Text(r.$2)), + ], + onChanged: (v) => v == null ? null : _saveLocale(region: v), + ), + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text("Number and currency layout.", + style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + const SizedBox(height: 16), + const Text("Currency", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: _knownOr(_currencies, appSettings.currency, "USD"), + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final c in _currencies) + DropdownMenuItem(value: c.$1, child: Text("${c.$2} (${c.$1})")), + ], + onChanged: (v) => v == null ? null : _save({"currency": v}), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + "Example: ${formatMoney(1234.5)}. Amounts are display-only — nothing is converted.", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), ), const SizedBox(height: 16), const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)), diff --git a/Phone App/lib/widgets/attachment_field.dart b/Phone App/lib/widgets/attachment_field.dart new file mode 100644 index 0000000..7a4233a --- /dev/null +++ b/Phone App/lib/widgets/attachment_field.dart @@ -0,0 +1,241 @@ +import "dart:io"; + +import "package:file_picker/file_picker.dart"; +import "package:flutter/material.dart"; +import "package:open_filex/open_filex.dart"; +import "package:path_provider/path_provider.dart"; + +import "../api.dart"; +import "../main.dart"; +import "../models.dart"; +import "../theme.dart"; + +/// The extensions an attachment may carry, mirroring attachmentFileTypes in the +/// API's attachments.go. The list is restrictive on purpose: these are scans and +/// photos, and the server rejects anything else. +const _allowedExtensions = ["pdf", "jpg", "jpeg", "png", "webp", "heic"]; + +/// A form's pending attachment change: a newly picked file, or a request to +/// detach whatever is already there. Both empty means "leave it alone". +class PendingAttachment { + PlatformFile? file; + bool remove = false; + + bool get isEmpty => file == null && !remove; +} + +/// Applies a form's pending attachment change to the record it has just saved. +/// +/// This necessarily runs after the metadata write: the file endpoints address a +/// record that must already exist. The order means a create-with-file is two +/// calls, and the second one failing leaves a saved record with no attachment — +/// which is why callers report it as an attachment error rather than a failed +/// save, because the metadata is already committed. +Future applyAttachment(String path, String id, PendingAttachment pending) async { + final picked = pending.file; + if (picked != null) { + final bytes = picked.bytes ?? + (picked.path != null ? await File(picked.path!).readAsBytes() : null); + if (bytes == null) throw ApiException(0, "could not read the picked file"); + await apiClient.uploadAttachment(path, id, bytes, picked.name); + return; + } + if (pending.remove) await apiClient.deleteAttachment(path, id); +} + +/// Downloads a record's attachment and hands it to the phone's viewer for that +/// file type. The bytes are fetched through the API Server (never a public URL), +/// then cached to a temp file because the OS viewers open paths, not buffers. +Future openAttachment( + BuildContext context, + String path, + String id, + String fileName, +) async { + final messenger = ScaffoldMessenger.of(context); + try { + final bytes = await apiClient.getAttachmentBytes(path, id); + if (bytes == null) { + messenger.showSnackBar(const SnackBar(content: Text("No file attached."))); + return; + } + final dir = await getTemporaryDirectory(); + final safe = fileName.isEmpty ? "attachment" : fileName.split(RegExp(r"[\\/]")).last; + final f = File("${dir.path}/$safe"); + await f.writeAsBytes(bytes); + final res = await OpenFilex.open(f.path); + if (res.type != ResultType.done) { + messenger.showSnackBar(SnackBar(content: Text("Could not open: ${res.message}"))); + } + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text("Could not open attachment: $e"))); + } +} + +/// The attachment picker shared by every form that can carry a file. +/// +/// It only collects intent into [pending] — actually moving the bytes is the +/// parent's job (see [applyAttachment]), because the endpoint addresses a record +/// that must already exist. +class AttachmentField extends StatefulWidget { + /// The saved record, when editing; null while creating. Read for the name of + /// whatever is already attached. + final HasAttachment? record; + + /// The collection's API path (e.g. "/service-records"), used to fetch an + /// existing attachment for viewing. + final String path; + + /// The saved record's id; null while creating (nothing to view yet). + final String? recordId; + + final PendingAttachment pending; + final VoidCallback onChanged; + final String legend; + final String hint; + + const AttachmentField({ + super.key, + required this.path, + required this.pending, + required this.onChanged, + this.record, + this.recordId, + this.legend = "Attachment", + this.hint = "PDF or image, up to 10MB.", + }); + + @override + State createState() => _AttachmentFieldState(); +} + +class _AttachmentFieldState extends State { + Future _pick() async { + final res = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: _allowedExtensions, + withData: true, + ); + if (res == null || res.files.isEmpty) return; + setState(() { + widget.pending.file = res.files.first; + // Picking a replacement supersedes a pending detach. + widget.pending.remove = false; + }); + widget.onChanged(); + } + + void _setRemove(bool v) { + setState(() { + widget.pending.remove = v; + if (v) widget.pending.file = null; + }); + widget.onChanged(); + } + + void _clearPick() { + setState(() => widget.pending.file = null); + widget.onChanged(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.color; + final picked = widget.pending.file; + final existing = widget.record; + final hasExisting = existing?.hasFile == true; + + return InputDecorator( + decoration: InputDecoration( + labelText: widget.legend, + border: const OutlineInputBorder(), + helperText: widget.hint, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + OutlinedButton.icon( + onPressed: _pick, + icon: const Icon(Icons.attach_file, size: 18), + label: Text(hasExisting || picked != null ? "Replace" : "Choose file"), + ), + const SizedBox(width: 8), + if (picked != null) + Expanded( + child: Text( + picked.name, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ), + if (picked != null) + IconButton( + onPressed: _clearPick, + icon: const Icon(Icons.close, size: 18), + tooltip: "Clear", + ), + ], + ), + if (picked == null && hasExisting && !widget.pending.remove) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text("Attached: ${existing!.fileName}", style: theme.textTheme.bodySmall), + if (widget.recordId != null) + _LinkButton( + label: "View", + onPressed: () => openAttachment( + context, widget.path, widget.recordId!, existing.fileName), + ), + _LinkButton( + label: "Remove", + color: DriverVault.danger, + onPressed: () => _setRemove(true), + ), + ], + ), + ), + if (widget.pending.remove) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + Text("Attachment will be removed on save.", + style: theme.textTheme.bodySmall?.copyWith(color: muted)), + _LinkButton(label: "Undo", onPressed: () => _setRemove(false)), + ], + ), + ), + ], + ), + ); + } +} + +class _LinkButton extends StatelessWidget { + final String label; + final Color? color; + final VoidCallback onPressed; + const _LinkButton({required this.label, required this.onPressed, this.color}); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onPressed, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color ?? DriverVault.brandOnTint(context), + fontWeight: FontWeight.w600, + ), + ), + ); +} diff --git a/Phone App/pubspec.lock b/Phone App/pubspec.lock index 06bbc42..4eaa0df 100644 --- a/Phone App/pubspec.lock +++ b/Phone App/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" fake_async: dependency: transitive description: @@ -121,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" file_selector_linux: dependency: transitive description: @@ -480,6 +496,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -497,7 +521,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 diff --git a/Phone App/pubspec.yaml b/Phone App/pubspec.yaml index 3787fdc..5daf3d7 100644 --- a/Phone App/pubspec.yaml +++ b/Phone App/pubspec.yaml @@ -14,6 +14,11 @@ dependencies: intl: ^0.19.0 cupertino_icons: ^1.0.8 image_picker: ^1.1.2 + # Attachments are PDFs as well as images, which image_picker cannot offer. + file_picker: ^10.1.2 + # Opens a downloaded attachment with whatever viewer the phone has for it. + open_filex: ^4.5.0 + path_provider: ^2.1.4 local_auth: ^2.3.0 local_auth_android: ^1.0.46 flutter_secure_storage: ^9.2.2 diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart new file mode 100644 index 0000000..734157d --- /dev/null +++ b/Phone App/test/models_format_test.dart @@ -0,0 +1,147 @@ +// Parses server-shaped JSON through the models and checks the status/format +// helpers. +// +// These cover the parts where a mistake is invisible until it reaches a user: a +// derived field the server left out must stay null rather than becoming a +// plausible-looking zero, the badge wording has to match the web app's, and an +// unsupported locale (settable from the web, whose lists are wider than the +// phone's) must not throw out of every date on screen. +import "package:flutter_test/flutter_test.dart"; +import "package:intl/date_symbol_data_local.dart"; +import "package:carcontrol_phone/format.dart"; +import "package:carcontrol_phone/main.dart"; +import "package:carcontrol_phone/models.dart"; + +void main() { + setUpAll(() async { + await initializeDateFormatting(); + appSettings.locale = "pl-PL"; + appSettings.currency = "PLN"; + appSettings.dateFormat = "DMY"; + }); + + test("FuelEntry keeps uncomputed derived fields null, not zero", () { + final partial = FuelEntry.fromJson({ + "id": "a", + "car": "c", + "date": "2026-07-01T00:00:00Z", + "km": 1000, + "liters": 20.0, + "cost": 120.0, + "fullTank": false, + "hasFile": false, + }); + expect(partial.consumptionL100, isNull); + expect(partial.distanceKm, isNull); + expect(formatConsumption(partial.consumptionL100), "—"); + + final full = FuelEntry.fromJson({ + "id": "b", + "car": "c", + "date": "2026-07-10T00:00:00Z", + "km": 1500, + "liters": 35.0, + "cost": 210.0, + "fullTank": true, + "consumptionL100": 6.85, + "distanceKm": 500, + "kmPerLiter": 14.6, + "pricePerLiter": 6.0, + "fileName": "receipt.pdf", + "hasFile": true, + }); + expect(full.consumptionL100, 6.85); + // 6.85 as a float64 is really 6.8499…, so one-decimal rounding yields 6.8 — + // the same answer JS toFixed(1) gives the web app. + expect(formatConsumption(full.consumptionL100), "6.8 L/100km"); + expect(full.hasFile, isTrue); + expect(full.fileName, "receipt.pdf"); + }); + + test("expiryStatus wording follows the server assessment", () { + CarDocument doc(String state, int? days) => CarDocument.fromJson({ + "id": "d", + "car": "c", + "type": "insurance", + "title": "OC", + "expiry": {"state": state, "daysUntilExpiry": days}, + "hasFile": false, + }); + expect(expiryStatus(doc("expired", -5).expiry).label, "Expired 5d ago"); + expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Expires today"); + expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Renew in 12d"); + expect(expiryStatus(doc("valid", 200).expiry).label, "Valid · 200d"); + expect(expiryStatus(doc("no_expiry", null).expiry).label, "No expiry"); + expect(expiryStatus(doc("expired", -5).expiry).key, StatusKey.overdue); + }); + + test("reminderStatus leads with the driving trigger", () { + Reminder rem(Map extra) => + Reminder.fromJson({"id": "r", "car": "c", "title": "t", "type": "service", ...extra}); + + expect(reminderStatus(rem({"status": "done", "done": true})).label, "Done"); + expect(reminderStatus(rem({"status": "no_trigger"})).label, "No trigger"); + expect( + reminderStatus(rem({"status": "overdue", "daysLeft": -3, "kmLeft": -200})).label, + "Overdue 3d · 200 km"); + expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Due in today"); + // The km count is grouped per the chosen locale (pl-PL uses a space), which + // is the whole point of routing every number through the one helper. + expect(reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label, + "Due in 40d · 5 000 km"); + }); + + test("TechnicalCheck: a failed check derives no next date", () { + final failed = TechnicalCheck.fromJson({ + "id": "t", + "car": "c", + "date": "2026-07-01T00:00:00Z", + "result": "failed", + "cost": 99.0, + "expiry": {"state": "no_expiry", "daysUntilExpiry": null}, + "hasFile": false, + }); + expect(failed.nextCheckDate, isNull); + expect(failed.passed, isFalse); + }); + + test("Maintenance derived warranty + total", () { + final m = MaintenanceEntry.fromJson({ + "id": "m", + "car": "c", + "date": "2026-07-01T00:00:00Z", + "km": 1000, + "type": "repair", + "status": "completed", + "laborCost": 100.0, + "partsCost": 50.0, + "totalCost": 150.0, + "warrantyActive": true, + "warrantyDaysLeft": 10, + "hasFile": false, + }); + expect(m.totalCost, 150.0); + expect(warrantyStatus(m)!.label, "Warranty ends in 10d"); + expect(warrantyStatus(m)!.key, StatusKey.soon); + + final noWarranty = MaintenanceEntry.fromJson( + {"id": "m", "car": "c", "date": "2026-07-01T00:00:00Z", "hasFile": false}); + expect(warrantyStatus(noWarranty), isNull); + }); + + test("money and numbers follow the chosen locale/currency", () { + expect(formatMoney(1234.5).contains("zł"), isTrue); + expect(formatMoney(null), "—"); + // An unsupported language must not throw — it can be set from the web. + appSettings.locale = "rm-CH"; + expect(() => formatDate(DateTime(2026, 7, 17)), returnsNormally); + expect(() => formatMoney(10), returnsNormally); + expect(() => formatKm(15000), returnsNormally); + appSettings.locale = "pl-PL"; + }); + + test("Car carries the technical check interval", () { + final car = Car.fromJson({"id": "c", "name": "Yaris", "technicalCheckIntervalDays": 730}); + expect(car.technicalCheckIntervalDays, 730); + }); +}