Bring the Phone App up to parity with the web app
Four rounds of web-app features never reached the phone: fuel, maintenance, document and reminder tracking; attachments; the currency setting and the locale split; and technical check history. The README claimed full parity throughout, so the gap was invisible. Catch the phone up, mirroring the web components field for field. Car detail grows the web app's tabs, in its order: technical checks, maintenance, fuel (with the summary panel), documents and reminders, beside the existing service and parts lists. The derived figures are the server's and are rendered as "—" wherever it sent null — a window with a missed fill has no consumption, and a plausible-looking 0.0 there would be a lie. Attachments hang off service records, technical checks, workshop visits, refills, documents and parts on identical terms, so one field and one apply helper cover all six rather than being copied per form. As on the web, the form only collects intent: the file endpoints address a record that must already exist, so a create-with-file is two calls, and a failure on the second reports as an attachment error because the metadata is committed. Two bugs fixed on the way: - _carPayload omitted technicalCheckIntervalDays. The API rewrites every column from the body, so any car edit — including the one-tap odometer update — silently zeroed the car's inspection interval. - main() never called initializeDateFormatting, so month names ignored the chosen language that the new Language picker exists to set. Luxembourgish and Romansh are deliberately left off the language list: intl ships no symbols for them and throws rather than falling back, which would take out every date on screen. The browser has full ICU data and has no such limit, so the web app can offer them. The server only validates a locale's shape, so an unrenderable tag can still arrive from the web; format.dart resolves through a supported-language check and falls back to en-US. Labels for the language/region/currency lists are hand-kept because Dart has no Intl.DisplayNames. The lists mirror validCurrencies in me.go. file_picker is pinned to ^10: v8 compiles against android-34, which no longer builds against the other plugins' compileSdk requirement of 36. Adds the project's first test, covering the parts that fail silently rather than loudly — null derived fields, the badge wording, and the locale guard. The phone was not authorized over ADB, so the UI was not exercised on a device: this is analyzer-, test- and build-clean, and every JSON field name and route was cross-checked against models.go and server.go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f77cbf0e73
commit
ee28b522c7
+55
-9
@@ -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
|
||||
|
||||
@@ -214,6 +214,145 @@ class ApiClient {
|
||||
|
||||
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
|
||||
|
||||
// --- technical checks ---
|
||||
Future<List<TechnicalCheck>> listCarTechnicalChecks(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/technical-checks") as List;
|
||||
return data.map((e) => TechnicalCheck.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<TechnicalCheck> createTechnicalCheck(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/technical-checks", body: body);
|
||||
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<TechnicalCheck> updateTechnicalCheck(String id, Map<String, dynamic> body) async {
|
||||
final data = await _send("PATCH", "/technical-checks/$id", body: body);
|
||||
return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<void> deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id");
|
||||
|
||||
// --- fuel ---
|
||||
Future<List<FuelEntry>> listCarFuelEntries(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/fuel-entries") as List;
|
||||
return data.map((e) => FuelEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<FuelStats> getCarFuelStats(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/fuel-stats");
|
||||
return FuelStats.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<FuelEntry> createFuelEntry(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/fuel-entries", body: body);
|
||||
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<FuelEntry> updateFuelEntry(String id, Map<String, dynamic> body) async {
|
||||
final data = await _send("PATCH", "/fuel-entries/$id", body: body);
|
||||
return FuelEntry.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<void> deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id");
|
||||
|
||||
// --- maintenance ---
|
||||
Future<List<MaintenanceEntry>> listCarMaintenance(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/maintenance") as List;
|
||||
return data.map((e) => MaintenanceEntry.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<MaintenanceEntry> createMaintenance(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/maintenance", body: body);
|
||||
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<MaintenanceEntry> updateMaintenance(String id, Map<String, dynamic> body) async {
|
||||
final data = await _send("PATCH", "/maintenance/$id", body: body);
|
||||
return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<void> 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<List<CarDocument>> listCarDocuments(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/documents") as List;
|
||||
return data.map((e) => CarDocument.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<CarDocument> createDocument(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/car-documents", body: body);
|
||||
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<CarDocument> updateDocument(String id, Map<String, dynamic> body) async {
|
||||
final data = await _send("PATCH", "/car-documents/$id", body: body);
|
||||
return CarDocument.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<void> deleteDocument(String id) => _send("DELETE", "/car-documents/$id");
|
||||
|
||||
// --- reminders ---
|
||||
Future<List<Reminder>> listCarReminders(String carId) async {
|
||||
final data = await _send("GET", "/cars/$carId/reminders") as List;
|
||||
return data.map((e) => Reminder.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<Reminder> createReminder(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/reminders", body: body);
|
||||
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<Reminder> updateReminder(String id, Map<String, dynamic> body) async {
|
||||
final data = await _send("PATCH", "/reminders/$id", body: body);
|
||||
return Reminder.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<void> 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<Reminder> completeReminder(String id) async {
|
||||
final data = await _send("POST", "/reminders/$id/complete");
|
||||
return Reminder.fromJson(Map<String, dynamic>.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<Map<String, dynamic>> uploadAttachment(
|
||||
String path, String id, List<int> 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<String, dynamic>.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<List<int>?> 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<void> deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file");
|
||||
|
||||
// --- settings: profile / account ---
|
||||
Future<UserProfile> getMe() async {
|
||||
final data = await _send("GET", "/me");
|
||||
|
||||
@@ -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<void> 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();
|
||||
|
||||
+110
-7
@@ -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 = <String>[];
|
||||
if (r.status == "overdue") {
|
||||
if (days != null && days < 0) parts.add("${days.abs()}d");
|
||||
if (km != null && km < 0) parts.add("${_num(km.abs())} km");
|
||||
return Status(key, parts.isEmpty ? "Overdue" : "Overdue ${parts.join(" · ")}");
|
||||
}
|
||||
|
||||
if (days != null && days >= 0) parts.add(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 —
|
||||
|
||||
@@ -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<void> 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();
|
||||
|
||||
+443
-8
@@ -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<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>.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<String, dynamic> 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
|
||||
|
||||
@@ -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<String, dynamic> _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<ServiceRecord> services;
|
||||
final List<TechnicalCheck> technicalChecks;
|
||||
final List<MaintenanceEntry> maintenance;
|
||||
final List<FuelEntry> fuel;
|
||||
final FuelStats fuelStats;
|
||||
final List<CarDocument> documents;
|
||||
final List<Part> parts;
|
||||
_CarDetailData(this.car, this.services, this.parts);
|
||||
final List<Reminder> 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<CarDetailScreen> {
|
||||
@@ -53,16 +74,31 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
_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<ServiceRecord>,
|
||||
results[2] as List<Part>,
|
||||
car: results[0] as Car,
|
||||
services: results[1] as List<ServiceRecord>,
|
||||
technicalChecks: results[2] as List<TechnicalCheck>,
|
||||
maintenance: results[3] as List<MaintenanceEntry>,
|
||||
fuel: results[4] as List<FuelEntry>,
|
||||
fuelStats: results[5] as FuelStats,
|
||||
documents: results[6] as List<CarDocument>,
|
||||
parts: results[7] as List<Part>,
|
||||
reminders: results[8] as List<Reminder>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +171,38 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens one of the record sheets and reloads if it saved. Every sheet pops
|
||||
/// `true` on success, so they all route through here.
|
||||
Future<void> _sheet(Widget sheet) async {
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => sheet,
|
||||
);
|
||||
if (saved == true) _reload();
|
||||
}
|
||||
|
||||
/// Confirms, deletes, then reloads. [what] names the record in the prompt.
|
||||
Future<void> _deleteRecord(String what, Future<void> Function() delete) async {
|
||||
final ok = await _confirm("Delete this $what?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await delete();
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_snack("Delete failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completeReminder(Reminder r) async {
|
||||
try {
|
||||
await apiClient.completeReminder(r.id);
|
||||
_reload();
|
||||
} catch (e) {
|
||||
_snack("Could not complete: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirm(String message) async {
|
||||
final res = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -237,7 +305,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
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<CarDetailScreen> {
|
||||
_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<CarDetailScreen> {
|
||||
))
|
||||
.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<CarDetailScreen> {
|
||||
))
|
||||
.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<String>(
|
||||
@@ -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(
|
||||
|
||||
@@ -50,6 +50,8 @@ class _CarFormSheetState extends State<CarFormSheet> {
|
||||
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<CarFormSheet> {
|
||||
"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<CarFormSheet> {
|
||||
: "${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<CarFormSheet> {
|
||||
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,
|
||||
|
||||
@@ -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<Widget> 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<DateTime?> 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<T>(
|
||||
String label,
|
||||
T value,
|
||||
List<(T, String)> options,
|
||||
ValueChanged<T?> onChanged,
|
||||
) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: DropdownButtonFormField<T>(
|
||||
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
|
||||
/// <fieldset>.
|
||||
Widget _group(BuildContext context, String legend, List<Widget> 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<TechnicalCheckSheet> createState() => _TechnicalCheckSheetState();
|
||||
}
|
||||
|
||||
class _TechnicalCheckSheetState extends State<TechnicalCheckSheet> {
|
||||
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<void> _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<void> _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<FuelSheet> createState() => _FuelSheetState();
|
||||
}
|
||||
|
||||
class _FuelSheetState extends State<FuelSheet> {
|
||||
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<void> _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<MaintenanceSheet> 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<MaintenanceSheet> {
|
||||
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<void> _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<DocumentSheet> createState() => _DocumentSheetState();
|
||||
}
|
||||
|
||||
class _DocumentSheetState extends State<DocumentSheet> {
|
||||
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<void> _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<ReminderSheet> createState() => _ReminderSheetState();
|
||||
}
|
||||
|
||||
class _ReminderSheetState extends State<ReminderSheet> {
|
||||
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<void> _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"),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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)),
|
||||
|
||||
@@ -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<void> 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<void> 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<AttachmentField> createState() => _AttachmentFieldState();
|
||||
}
|
||||
|
||||
class _AttachmentFieldState extends State<AttachmentField> {
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+25
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, dynamic> extra) =>
|
||||
Reminder.fromJson({"id": "r", "car": "c", "title": "t", "type": "service", ...extra});
|
||||
|
||||
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Done");
|
||||
expect(reminderStatus(rem({"status": "no_trigger"})).label, "No trigger");
|
||||
expect(
|
||||
reminderStatus(rem({"status": "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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user