Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
19 KiB
Dart
422 lines
19 KiB
Dart
import "package:flutter/material.dart";
|
||
import "package:intl/intl.dart";
|
||
|
||
import "i18n.dart";
|
||
import "main.dart";
|
||
import "models.dart";
|
||
import "theme.dart";
|
||
|
||
/// 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. Month names follow the locale's language.
|
||
String formatDate(DateTime? d) {
|
||
if (d == null) return "—";
|
||
final pattern = switch (appSettings.dateFormat) {
|
||
"DMY_NUM" => "dd-MM-yyyy",
|
||
"DMY" => "dd MMM yyyy",
|
||
"MDY" => "MMM dd, yyyy",
|
||
_ => "yyyy-MM-dd", // YMD
|
||
};
|
||
return DateFormat(pattern, _locale).format(d);
|
||
}
|
||
|
||
/// A date somebody may only half know, as an ISO 8601 reduced-precision date:
|
||
/// "2015", "2015-03" or "2015-03-10". Mirrors formatPartialDate in the web
|
||
/// app's format.js, which is where such a value is entered — a car's build date
|
||
/// is often only a year, and this app must not print a day nobody supplied.
|
||
///
|
||
/// Taken apart as a string rather than parsed: DateTime.parse rejects "2015"
|
||
/// and "2015-03" outright, which is exactly why the value has to be handled
|
||
/// here at all.
|
||
String formatPartialDate(String iso) {
|
||
final value = iso.trim();
|
||
if (value.isEmpty) return "—";
|
||
final match = RegExp(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$").firstMatch(value);
|
||
if (match == null) return formatDate(DateTime.tryParse(value));
|
||
final year = match.group(1)!;
|
||
final month = match.group(2);
|
||
if (match.group(3) != null) return formatDate(DateTime.tryParse(value));
|
||
if (month == null) return year;
|
||
|
||
// Month and year: the month's name where the user's format spells months out,
|
||
// its number where the format is numeric, in the app's usual order either way.
|
||
final when = DateTime(int.parse(year), int.parse(month));
|
||
return switch (appSettings.dateFormat) {
|
||
"DMY_NUM" => "$month-$year",
|
||
"DMY" || "MDY" => DateFormat("MMM yyyy", _locale).format(when),
|
||
_ => "$year-$month",
|
||
};
|
||
}
|
||
|
||
/// A timestamp: the user's chosen date format with the wall clock beside it.
|
||
/// Only the provider snapshot needs one — everything else in the app is
|
||
/// date-only — but it follows the same settings as [formatDate] so the two never
|
||
/// disagree on screen.
|
||
String formatDateTime(DateTime? d) {
|
||
if (d == null) return "—";
|
||
return "${formatDate(d)} ${formatTime(d)}";
|
||
}
|
||
|
||
/// The clock alone. "auto" leaves the reading to the region, which is what every
|
||
/// time in the app said before there was a setting; the other two are for the
|
||
/// people whose region and habit disagree — plenty of Poles read 12-hour clocks
|
||
/// and plenty of Americans read 24-hour ones, and the region picker also decides
|
||
/// how money and numbers are grouped, so it is the wrong lever to reach for.
|
||
///
|
||
/// The setting decides *which* clock; this file decides how it is punctuated.
|
||
/// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is
|
||
/// a real difference in how people tell the time. It is not worth asking whether
|
||
/// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one
|
||
/// screen of DriverVault writing 13.45 while the next writes 13:45 is not local
|
||
/// colour, it is an inconsistency. So every time this app prints comes out of
|
||
/// the two lines below, the same as the web app's format.js.
|
||
///
|
||
/// The cost is that the am/pm marker reads in English everywhere. It is the same
|
||
/// trade the setting itself makes: a 12-hour clock is not a convention most of
|
||
/// these regions use, so choosing one — or living in a region that does — is
|
||
/// choosing the clock that comes with it.
|
||
String formatTime(DateTime? d) {
|
||
if (d == null) return "—";
|
||
final mm = d.minute.toString().padLeft(2, "0");
|
||
if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm";
|
||
// 12 for both noon and midnight, and midnight is the am one.
|
||
final h = d.hour % 12 == 0 ? 12 : d.hour % 12;
|
||
return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}";
|
||
}
|
||
|
||
/// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is
|
||
/// no date to hand [formatTime] — read on the clock the user chose. Anything
|
||
/// that is not a time of day comes back as it arrived.
|
||
String formatClock(String hhmm) {
|
||
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim());
|
||
if (m == null) return hhmm;
|
||
final h = int.parse(m.group(1)!);
|
||
if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}";
|
||
final twelve = h % 12 == 0 ? 12 : h % 12;
|
||
return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}";
|
||
}
|
||
|
||
/// Whether times are written on a 12-hour clock right now: what the setting says
|
||
/// outright, or what the region says when it is left on auto.
|
||
///
|
||
/// Not only [formatTime]'s business. A control that lets somebody *enter* a time
|
||
/// has to offer the same clock, and a box that reads 13:45 beside a picker that
|
||
/// says 01:45 PM is the disagreement this setting exists to end — see
|
||
/// widgets/time_field.dart.
|
||
bool clockIsTwelveHour() {
|
||
switch (appSettings.timeFormat) {
|
||
case "12":
|
||
return true;
|
||
case "24":
|
||
return false;
|
||
default:
|
||
return _regionReadsTwelveHour();
|
||
}
|
||
}
|
||
|
||
/// Whether the chosen region tells the time on a 12-hour clock — the one
|
||
/// question "auto" asks it. Cached because this is asked once per timestamp on a
|
||
/// page that can hold a great many, and the answer only changes with the region.
|
||
final Map<String, bool> _twelveHourRegions = {};
|
||
|
||
bool _regionReadsTwelveHour() {
|
||
return _twelveHourRegions.putIfAbsent(_locale, () {
|
||
try {
|
||
// DateFormat.j() is the locale's own preferred hour field: "h" where it is
|
||
// read on a 12-hour clock, "H" where it is not.
|
||
return DateFormat.j(_locale).pattern?.contains("h") ?? false;
|
||
} catch (_) {
|
||
// An unusable locale is not a reason to print nothing; 24-hour is the
|
||
// safer default, being the one that cannot be read as the wrong half of
|
||
// the day.
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Weekdays ---------------------------------------------------------------
|
||
//
|
||
// A week does not start on the same day everywhere: Monday across most of
|
||
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
|
||
// that always begins on Sunday reads wrong to half the people looking at it,
|
||
// and reads wrong in a way that is easy to mistap — Settings › Appearance ›
|
||
// First day of the week is the answer, with "auto" following the chosen region
|
||
// the way the clock setting does.
|
||
//
|
||
// Everything that lays weekdays out in a row goes through these, so there is one
|
||
// answer to "which day comes first" rather than one per screen. Days are
|
||
// numbered the way the scheduler's stored tasks number them: 0 = Sunday …
|
||
// 6 = Saturday.
|
||
|
||
/// Whether weeks are drawn as starting on Monday right now: what the setting
|
||
/// says outright, or what the region says when it is left on auto.
|
||
bool weekStartsOnMonday() {
|
||
switch (appSettings.weekStart) {
|
||
case "monday":
|
||
return true;
|
||
case "sunday":
|
||
return false;
|
||
default:
|
||
return _regionStartsOnMonday();
|
||
}
|
||
}
|
||
|
||
final Map<String, bool> _mondayRegions = {};
|
||
|
||
bool _regionStartsOnMonday() {
|
||
return _mondayRegions.putIfAbsent(_locale, () {
|
||
try {
|
||
// intl carries the region's own answer in its date symbols, numbered
|
||
// 0 = Monday … 6 = Sunday (the Closure convention its data came from).
|
||
return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0;
|
||
} catch (_) {
|
||
// Monday is the safer default: it is ISO 8601's, and the convention in
|
||
// every region this app's own currency list covers bar one.
|
||
return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The seven days in the order they should be drawn, as day numbers.
|
||
List<int> weekdaysInOrder() =>
|
||
weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6];
|
||
|
||
/// One day's short name in the user's own language, so a row reads Pn Wt Śr in
|
||
/// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
|
||
/// sits, so the offset lands each number on its own day.
|
||
String weekdayShortName(int day) {
|
||
try {
|
||
return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day));
|
||
} catch (_) {
|
||
return "$day";
|
||
}
|
||
}
|
||
|
||
/// A set of days, listed in the order this account reads a week in — so the same
|
||
/// three days always come out in the same order wherever they are shown.
|
||
List<int> sortWeekdays(Iterable<int> days) {
|
||
final order = weekdaysInOrder();
|
||
return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
|
||
}
|
||
|
||
// 0 km is a reading — a car collected new — not a blank. See format.js.
|
||
String formatKm(int? km) => km == null ? "—" : "${_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";
|
||
|
||
/// The charging figures. Same rule as the fuel ones: null is "could not be
|
||
/// derived", which reads as "—" rather than a misleading zero.
|
||
String formatKwh(double? value) => value == null ? "—" : "${value.toStringAsFixed(2)} kWh";
|
||
|
||
String formatKwhConsumption(double? value) =>
|
||
value == null ? "—" : "${value.toStringAsFixed(1)} kWh/100km";
|
||
|
||
String formatKmPerKwh(double? value) =>
|
||
value == null ? "—" : "${value.toStringAsFixed(2)} km/kWh";
|
||
|
||
const int _kmSoon = 1000;
|
||
|
||
enum StatusKey { unknown, ok, soon, overdue }
|
||
|
||
class Status {
|
||
final StatusKey key;
|
||
final String label;
|
||
const Status(this.key, this.label);
|
||
|
||
/// Soft tint background for the status pill — DriverVault semantic colours,
|
||
/// re-cut for dark surfaces.
|
||
Color bg(bool dark) => switch (key) {
|
||
StatusKey.overdue => dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft,
|
||
StatusKey.soon => dark ? DriverVault.warningSoftDark : DriverVault.warningSoft,
|
||
StatusKey.ok => dark ? DriverVault.successSoftDark : DriverVault.successSoft,
|
||
StatusKey.unknown => dark ? DriverVault.darkSunken : DriverVault.ink50,
|
||
};
|
||
Color fg(bool dark) => switch (key) {
|
||
StatusKey.overdue => DriverVault.danger,
|
||
StatusKey.soon => DriverVault.warning,
|
||
StatusKey.ok => DriverVault.success,
|
||
StatusKey.unknown => dark ? DriverVault.darkTextMuted : DriverVault.ink500,
|
||
};
|
||
}
|
||
|
||
int _rank(StatusKey k) => switch (k) {
|
||
StatusKey.unknown => 0,
|
||
StatusKey.ok => 1,
|
||
StatusKey.soon => 2,
|
||
StatusKey.overdue => 3,
|
||
};
|
||
|
||
/// One of the two service triggers: its badge state and its own wording, plus
|
||
/// the number behind that wording so a badge holding both can quote the two
|
||
/// side by side.
|
||
typedef _Signal = ({StatusKey key, String label, int? value});
|
||
|
||
_Signal _dateSignal(DateTime? nextDate) {
|
||
if (nextDate == null) return (key: StatusKey.unknown, label: t("status.noData"), value: null);
|
||
final today = DateTime.now();
|
||
final days = DateTime(nextDate.year, nextDate.month, nextDate.day)
|
||
.difference(DateTime(today.year, today.month, today.day))
|
||
.inDays;
|
||
if (days < 0) {
|
||
return (key: StatusKey.overdue, label: t("status.serviceOverdueDays", params: {"days": days.abs()}), value: days);
|
||
}
|
||
if (days <= 30) {
|
||
return (key: StatusKey.soon, label: t("status.dueInDays", params: {"days": days}), value: days);
|
||
}
|
||
return (key: StatusKey.ok, label: t("status.okDays", params: {"days": days}), value: days);
|
||
}
|
||
|
||
_Signal _kmSignal(int currentKm, int? nextKm) {
|
||
if (nextKm == null) return (key: StatusKey.unknown, label: t("status.noKm"), value: null);
|
||
final remaining = nextKm - currentKm;
|
||
if (remaining < 0) {
|
||
return (
|
||
key: StatusKey.overdue,
|
||
label: t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}),
|
||
value: remaining,
|
||
);
|
||
}
|
||
if (remaining <= _kmSoon) {
|
||
return (key: StatusKey.soon, label: t("status.inKm", params: {"km": _num(remaining)}), value: remaining);
|
||
}
|
||
return (key: StatusKey.ok, label: t("status.kmLeft", params: {"km": _num(remaining)}), value: remaining);
|
||
}
|
||
|
||
/// 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" => t("status.expiredAgo", params: {"days": (days ?? 0).abs()}),
|
||
"expiring_soon" => days == 0 ? t("status.expiresToday") : t("status.renewInDays", params: {"days": days}),
|
||
"valid" => t("status.validDays", params: {"days": days}),
|
||
_ => t("status.noExpiry"),
|
||
};
|
||
return Status(_expiryKey(e.state), label);
|
||
}
|
||
|
||
/// 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 Status(StatusKey.unknown, t("status.done"));
|
||
if (r.status == "no_trigger") return Status(StatusKey.unknown, t("status.noTrigger"));
|
||
|
||
final parts = <String>[];
|
||
if (r.status == "overdue") {
|
||
if (days != null && days < 0) parts.add(t("status.days", params: {"days": days.abs()}));
|
||
if (km != null && km < 0) parts.add(t("status.km", params: {"km": _num(km.abs())}));
|
||
return Status(key, parts.isEmpty ? t("status.overdue") : t("status.overdueBy", params: {"parts": parts.join(" · ")}));
|
||
}
|
||
|
||
if (days != null && days >= 0) parts.add(days == 0 ? t("status.today") : t("status.days", params: {"days": days}));
|
||
if (km != null && km >= 0) parts.add(t("status.km", params: {"km": _num(km)}));
|
||
return Status(key, parts.isEmpty ? t("status.upcoming") : t("status.dueIn", params: {"parts": parts.join(" · ")}));
|
||
}
|
||
|
||
/// Warranty badge for a maintenance entry. Unlike the others this one has no
|
||
/// 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, t("status.warrantyExpiredAgo", params: {"days": days.abs()}));
|
||
if (days <= 30) return Status(StatusKey.soon, t("status.warrantyEndsIn", params: {"days": days}));
|
||
return Status(StatusKey.ok, t("status.underWarranty", params: {"days": days}));
|
||
}
|
||
|
||
/// Combines the date- and km-based signals, returning the worse of the two —
|
||
/// the same logic as the web app and the spreadsheet idea.
|
||
Status serviceStatus(ServiceRecord? latest, Car car) {
|
||
final date = _dateSignal(latest?.nextServiceDate);
|
||
final km = _kmSignal(car.currentKm, latest?.nextServiceKm);
|
||
final worse = _rank(km.key) > _rank(date.key) ? km : date;
|
||
|
||
if (date.key == StatusKey.unknown && km.key == StatusKey.unknown) {
|
||
return Status(StatusKey.unknown, t("status.noData"));
|
||
}
|
||
// With one signal to go on, that signal's own sentence says it best.
|
||
if (date.key == StatusKey.unknown) return Status(km.key, km.label);
|
||
if (km.key == StatusKey.unknown) return Status(date.key, date.label);
|
||
return Status(worse.key, _bothSignals(date, km, worse.key));
|
||
}
|
||
|
||
/// Words a badge that has a due date AND an odometer target. A service falls
|
||
/// due on whichever arrives first, so "OK · 354d" on its own left out half of
|
||
/// what the badge is watching: the distance still to run belongs beside the
|
||
/// days. One headline for the severity, then each trigger as a bare quantity -
|
||
/// the shape reminderStatus already uses, it having had the two-trigger
|
||
/// problem first.
|
||
String _bothSignals(_Signal date, _Signal km, StatusKey key) {
|
||
final parts = <String>[];
|
||
if (key == StatusKey.overdue) {
|
||
// Only what has actually passed. The other trigger is not late, and its
|
||
// comfortable remainder under an "Overdue" headline would read as one.
|
||
if (date.key == StatusKey.overdue) parts.add(t("status.days", params: {"days": date.value!.abs()}));
|
||
if (km.key == StatusKey.overdue) parts.add(t("status.km", params: {"km": _num(km.value!.abs())}));
|
||
return t("status.serviceOverdueBy", params: {"parts": parts.join(" · ")});
|
||
}
|
||
parts.add(t("status.days", params: {"days": date.value}));
|
||
parts.add(t("status.km", params: {"km": _num(km.value!)}));
|
||
return t(key == StatusKey.soon ? "status.dueIn" : "status.okIn", params: {"parts": parts.join(" · ")});
|
||
}
|