Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.
A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.
The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.
Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.
Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.
Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.
Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f7caeaf907
commit
6b7abb4b84
+21
-8
@@ -27,12 +27,14 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
||||
|
||||
**Which tabs a car shows, and in what order, belongs to the car** — the same
|
||||
arrangement the web app reads, so everyone it is shared with sees the same
|
||||
page. Edit it under the **tune** icon: switch tabs and Information rows on or
|
||||
off, and drag either list by its handle to reorder it. (The web rearranges by
|
||||
dragging the tab bar itself; on a touch screen that gesture is the tab bar's,
|
||||
so both arrangements are made in the picker instead.) Information cannot be
|
||||
switched off — a page with no tabs left would be a dead end — but it can be
|
||||
moved. The tabs, in their default order:
|
||||
page. Edit it under the **tune** icon: switch tabs, Information rows and
|
||||
Service history columns on or off, and drag any of the three lists by its
|
||||
handle to reorder it. (The web rearranges by dragging the tab bar and the
|
||||
column headings themselves; on a touch screen those gestures belong to the tab
|
||||
bar and the scroll, so every arrangement is made in the picker instead.) Two
|
||||
things cannot be switched off: Information — a page with no tabs left would be
|
||||
a dead end — and the service Date, since a history with the day taken out
|
||||
stops being one. Both can still be moved. The tabs, in their default order:
|
||||
- **Connected service** — everything the manufacturer's own app knows about
|
||||
this car, read live under *your* account: headline readings (odometer, fuel
|
||||
or battery, range, charging state, position), the vehicle record, and one
|
||||
@@ -45,7 +47,17 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
||||
else shows data here only if that vehicle is on your account too — it says
|
||||
so rather than failing.
|
||||
- **Service history** — date/odometer plus which parts were changed, with the
|
||||
next-due date/km derived by the server.
|
||||
next-due date/km derived by the server. The web lays those out as table
|
||||
columns; a phone has no width for a seven-column table, so a card renders the
|
||||
same columns as pieces — consecutive short ones share a wrapping line, and
|
||||
the parts, the notes and the file each take a line of their own. It reads the
|
||||
car's own hidden set and order, so a column switched off on either app is off
|
||||
on both, and the arrangement decides the grouping: move Notes between Km and
|
||||
Next date and the short columns split around it. Every part a service can
|
||||
have changed shares the one **parts** column — they are a growing list, and a
|
||||
column apiece would widen the web's table without end — and both that column
|
||||
and the form's Changed parts section come from `lib/service_parts.dart`, the
|
||||
twin of the web's `lib/serviceParts.js`.
|
||||
- **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
|
||||
@@ -188,6 +200,7 @@ lib/
|
||||
├── i18n.dart # translation lookup — t("key"); en/pl/da with en fallback
|
||||
├── theme.dart # shared colours/tones (status badges, charging tiles)
|
||||
├── format.dart # date/km formatting + next-service status (worst-of date/km)
|
||||
├── service_parts.dart # the parts a service can change, in one list
|
||||
├── main.dart # app root; routes Login / Lock / RootShell; lifecycle re-lock
|
||||
├── widgets/
|
||||
│ └── attachment_field.dart # pick / view / clear a record's attached file
|
||||
@@ -195,7 +208,7 @@ lib/
|
||||
├── root_shell.dart # bottom-nav shell: Garage, Charging, Settings, Users
|
||||
├── login_screen.dart lock_screen.dart dashboard_screen.dart
|
||||
├── car_detail_screen.dart car_form_sheet.dart record_form_sheets.dart
|
||||
├── car_view_sheet.dart # which tabs/rows a car shows + the key catalogue
|
||||
├── car_view_sheet.dart # which tabs/rows/columns a car shows + the catalogues
|
||||
├── provider_tab.dart # the connected-service tab (MyToyota)
|
||||
└── charging_screen.dart settings_screen.dart admin_users_screen.dart
|
||||
```
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"fieldsHeading": "Oplysninger",
|
||||
"alwaysOn": "{tab} er altid tilgængelig.",
|
||||
"fieldsOrderHint": "Træk felterne på fanen Oplysninger for at ændre deres rækkefølge.",
|
||||
"serviceColumnsHeading": "Kolonner i servicehistorik",
|
||||
"columnAlwaysOn": "{column} vises altid.",
|
||||
"reorderHint": "Træk en række i håndtaget for at ændre rækkefølgen.",
|
||||
"tabsReorder": "Faneblade",
|
||||
"fieldsReorder": "Informationsfelter"
|
||||
@@ -462,7 +464,6 @@
|
||||
"colNotes": "Noter",
|
||||
"colFile": "Fil",
|
||||
"confirmDelete": "Slet denne servicepost?",
|
||||
"next": "Næste: {date} · {km}",
|
||||
"chipOil": "Olie og filter",
|
||||
"chipEngineFilter": "Motorluft",
|
||||
"chipCabinFilter": "Kabineluft"
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"fieldsHeading": "Information fields",
|
||||
"alwaysOn": "{tab} is always available.",
|
||||
"fieldsOrderHint": "Drag the fields on the Information tab to change the order they appear in.",
|
||||
"serviceColumnsHeading": "Service history columns",
|
||||
"columnAlwaysOn": "{column} is always shown.",
|
||||
"reorderHint": "Drag a row by its handle to change the order.",
|
||||
"tabsReorder": "Tabs",
|
||||
"fieldsReorder": "Information fields"
|
||||
@@ -462,7 +464,6 @@
|
||||
"colNotes": "Notes",
|
||||
"colFile": "File",
|
||||
"confirmDelete": "Delete this service record?",
|
||||
"next": "Next: {date} · {km}",
|
||||
"chipOil": "Oil & filter",
|
||||
"chipEngineFilter": "Engine air",
|
||||
"chipCabinFilter": "Cabin air"
|
||||
|
||||
@@ -379,6 +379,8 @@
|
||||
"fieldsHeading": "Pola informacji",
|
||||
"alwaysOn": "Zakładka {tab} jest zawsze dostępna.",
|
||||
"fieldsOrderHint": "Przeciągnij pola na zakładce Informacje, aby zmienić ich kolejność.",
|
||||
"serviceColumnsHeading": "Kolumny historii serwisowej",
|
||||
"columnAlwaysOn": "Kolumna {column} jest zawsze widoczna.",
|
||||
"reorderHint": "Przeciągnij wiersz za uchwyt, aby zmienić kolejność.",
|
||||
"tabsReorder": "Zakładki",
|
||||
"fieldsReorder": "Pola informacyjne"
|
||||
@@ -468,7 +470,6 @@
|
||||
"colNotes": "Notatki",
|
||||
"colFile": "Plik",
|
||||
"confirmDelete": "Usunąć ten wpis serwisowy?",
|
||||
"next": "Następny: {date} · {km}",
|
||||
"chipOil": "Olej i filtr",
|
||||
"chipEngineFilter": "Filtr powietrza",
|
||||
"chipCabinFilter": "Filtr kabinowy"
|
||||
|
||||
@@ -144,6 +144,8 @@ class ApiClient {
|
||||
List<String>? tabOrder,
|
||||
List<String>? fieldOrder,
|
||||
List<String>? metricOrder,
|
||||
List<String>? hiddenServiceColumns,
|
||||
List<String>? serviceColumnOrder,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
if (hiddenTabs != null) "hiddenTabs": hiddenTabs,
|
||||
@@ -151,6 +153,8 @@ class ApiClient {
|
||||
if (tabOrder != null) "tabOrder": tabOrder,
|
||||
if (fieldOrder != null) "fieldOrder": fieldOrder,
|
||||
if (metricOrder != null) "metricOrder": metricOrder,
|
||||
if (hiddenServiceColumns != null) "hiddenServiceColumns": hiddenServiceColumns,
|
||||
if (serviceColumnOrder != null) "serviceColumnOrder": serviceColumnOrder,
|
||||
};
|
||||
final data = await _send("PUT", "/cars/$id/view", body: body);
|
||||
return Car.fromJson(Map<String, dynamic>.from(data));
|
||||
|
||||
@@ -99,6 +99,14 @@ class Car {
|
||||
final List<String> fieldOrder;
|
||||
final List<String> metricOrder;
|
||||
|
||||
/// The columns of the Service history the car does not show, and the order it
|
||||
/// lays out the ones it does. The same rules as the lists above, with one
|
||||
/// difference: "date" is never in the hidden set — a service is the day it
|
||||
/// happened, and a history with the day taken out stops being a history — but
|
||||
/// it is in the order, because there is no reason it has to come first.
|
||||
final List<String> hiddenServiceColumns;
|
||||
final List<String> serviceColumnOrder;
|
||||
|
||||
Car({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -128,6 +136,8 @@ class Car {
|
||||
this.tabOrder = const [],
|
||||
this.fieldOrder = const [],
|
||||
this.metricOrder = const [],
|
||||
this.hiddenServiceColumns = const [],
|
||||
this.serviceColumnOrder = const [],
|
||||
});
|
||||
|
||||
factory Car.fromJson(Map<String, dynamic> j) => Car(
|
||||
@@ -159,6 +169,8 @@ class Car {
|
||||
tabOrder: _asStrList(j["tabOrder"]),
|
||||
fieldOrder: _asStrList(j["fieldOrder"]),
|
||||
metricOrder: _asStrList(j["metricOrder"]),
|
||||
hiddenServiceColumns: _asStrList(j["hiddenServiceColumns"]),
|
||||
serviceColumnOrder: _asStrList(j["serviceColumnOrder"]),
|
||||
);
|
||||
|
||||
bool get isOwner => access == "owner";
|
||||
|
||||
@@ -5,6 +5,7 @@ import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../format.dart";
|
||||
import "../service_parts.dart";
|
||||
import "../theme.dart";
|
||||
import "../widgets/attachment_field.dart";
|
||||
import "car_form_sheet.dart";
|
||||
@@ -464,6 +465,11 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
return _InfoTab(car: car, latest: latest);
|
||||
|
||||
case "services":
|
||||
// The columns this car shows, in its arrangement — computed once for the
|
||||
// whole list rather than per tile, since every row shows the same ones.
|
||||
final columns = arrangeKeys(kServiceColumnKeys, car.serviceColumnOrder)
|
||||
.where((key) => !car.hiddenServiceColumns.contains(key))
|
||||
.toList();
|
||||
return _TabList(
|
||||
empty: data.services.isEmpty ? t("car.services.empty") : null,
|
||||
onAdd: car.canWrite ? () => _addService(car) : null,
|
||||
@@ -471,6 +477,7 @@ class _CarDetailScreenState extends State<CarDetailScreen> {
|
||||
children: data.services
|
||||
.map((s) => _ServiceTile(
|
||||
record: s,
|
||||
columns: columns,
|
||||
onEdit: car.canWrite ? () => _editService(car, s) : null,
|
||||
onDelete: car.canWrite ? () => _deleteService(s) : null,
|
||||
))
|
||||
@@ -802,19 +809,51 @@ class _InfoTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// One service record, laid out from the columns the car shows and in the order
|
||||
/// it arranges them — the same two lists the web app drives its table from, so a
|
||||
/// column switched off there is off here too.
|
||||
///
|
||||
/// A card is not a table, so the columns render as pieces rather than cells:
|
||||
/// consecutive short ones share a wrapping line (which flows in reading order,
|
||||
/// keeping the arrangement intact), and the three that need room of their own —
|
||||
/// the parts, the notes and the file — each break onto their own. The date is
|
||||
/// the one piece that carries no heading: it is what the record *is*, which is
|
||||
/// also why the server refuses to let it be switched off.
|
||||
class _ServiceTile extends StatelessWidget {
|
||||
final ServiceRecord record;
|
||||
|
||||
/// The visible column keys, already arranged. Never empty: date cannot be
|
||||
/// hidden.
|
||||
final List<String> columns;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
const _ServiceTile({required this.record, this.onEdit, this.onDelete});
|
||||
const _ServiceTile({
|
||||
required this.record,
|
||||
required this.columns,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chips = <Widget>[
|
||||
if (record.changedOil) _chip(context, t("car.services.chipOil")),
|
||||
if (record.changedEngineAirFilter) _chip(context, t("car.services.chipEngineFilter")),
|
||||
if (record.changedCabinAirFilter) _chip(context, t("car.services.chipCabinFilter")),
|
||||
// One widget per run. A run of short columns is a wrapping line; the other
|
||||
// three are a run of one and render as themselves.
|
||||
final pieces = [
|
||||
for (final run in serviceColumnRuns(columns))
|
||||
if (kInlineServiceColumns.contains(run.first))
|
||||
Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 4,
|
||||
children: [for (final key in run) _value(context, key)],
|
||||
)
|
||||
else
|
||||
switch (run.first) {
|
||||
"parts" => _parts(context),
|
||||
"notes" => _notes(context),
|
||||
_ => _file(context),
|
||||
},
|
||||
];
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -827,39 +866,80 @@ class _ServiceTile extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// The menu rides the first piece rather than a header line of its
|
||||
// own, so hiding columns shortens the card instead of leaving a gap
|
||||
// where the date used to be.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(formatDate(record.date), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Row(children: [
|
||||
Text(formatKm(record.km)),
|
||||
if (onEdit != null || onDelete != null)
|
||||
_RowMenu(onEdit: onEdit, onDelete: onDelete),
|
||||
]),
|
||||
Expanded(child: pieces.first),
|
||||
if (onEdit != null || onDelete != null)
|
||||
_RowMenu(onEdit: onEdit, onDelete: onDelete),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t("car.services.next", params: {
|
||||
"date": formatDate(record.nextServiceDate),
|
||||
"km": formatKm(record.nextServiceKm),
|
||||
}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
if (chips.isNotEmpty) ...[
|
||||
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)),
|
||||
],
|
||||
for (final piece in pieces.skip(1))
|
||||
Padding(padding: const EdgeInsets.only(top: 6), child: piece),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One short column: its heading and its value side by side. The date goes
|
||||
/// bare and bold — a card list is read down its dates, and "Date" in front of
|
||||
/// one says nothing the date doesn't.
|
||||
Widget _value(BuildContext context, String key) {
|
||||
final text = switch (key) {
|
||||
"date" => formatDate(record.date),
|
||||
"km" => formatKm(record.km),
|
||||
"nextDate" => formatDate(record.nextServiceDate),
|
||||
_ => formatKm(record.nextServiceKm),
|
||||
};
|
||||
if (key == "date") {
|
||||
return Text(text, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14));
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("${serviceColumnLabel(key)} ",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.muted(context))),
|
||||
Text(text, style: const TextStyle(fontSize: 13)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The parts column: a chip per part this service changed. The web has to fit
|
||||
/// them in one table cell and so names the first and counts the rest behind a
|
||||
/// panel; a card has the width to simply show them all.
|
||||
Widget _parts(BuildContext context) {
|
||||
final changed = changedParts(record);
|
||||
if (changed.isEmpty) return _empty(context, "parts");
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final part in changed) _chip(context, t(part.chipLabel))],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notes(BuildContext context) {
|
||||
if (record.notes.isEmpty) return _empty(context, "notes");
|
||||
return Text(record.notes,
|
||||
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic));
|
||||
}
|
||||
|
||||
Widget _file(BuildContext context) {
|
||||
if (!record.hasFile) return _empty(context, "file");
|
||||
return _AttachmentLine(path: "/service-records", id: record.id, record: record, top: 0);
|
||||
}
|
||||
|
||||
/// A column that is on but has nothing to show. Named rather than blank: it
|
||||
/// was switched on deliberately, and a card that silently drops it reads as a
|
||||
/// record where a part or a receipt might simply not have loaded.
|
||||
Widget _empty(BuildContext context, String key) => Text(
|
||||
"${serviceColumnLabel(key)} ${t("common.empty")}",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
|
||||
);
|
||||
|
||||
Widget _chip(BuildContext context, String label) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -980,13 +1060,23 @@ class _AttachmentLine extends StatelessWidget {
|
||||
final String path;
|
||||
final String id;
|
||||
final HasAttachment record;
|
||||
const _AttachmentLine({required this.path, required this.id, required this.record});
|
||||
|
||||
/// The gap above the line. Zero for a caller that already spaces the rows it
|
||||
/// stacks — the service card, whose file is one piece among several — and 6
|
||||
/// for the tiles that append this to a block of prose.
|
||||
final double top;
|
||||
const _AttachmentLine({
|
||||
required this.path,
|
||||
required this.id,
|
||||
required this.record,
|
||||
this.top = 6,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!record.hasFile) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
padding: EdgeInsets.only(top: top),
|
||||
child: InkWell(
|
||||
onTap: () => openAttachment(context, path, id, record.fileName),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
@@ -1882,7 +1972,11 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
late DateTime _date;
|
||||
late final TextEditingController _km;
|
||||
late final TextEditingController _notes;
|
||||
late bool _oil, _engine, _cabin;
|
||||
|
||||
/// Which parts this record says were changed, keyed the way [kServiceParts]
|
||||
/// keys them, so a part added to that list turns up in this sheet without a
|
||||
/// second edit here.
|
||||
late final Map<String, bool> _changed;
|
||||
final _pending = PendingAttachment();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
@@ -1896,9 +1990,12 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
_date = r?.date ?? DateTime.now();
|
||||
_km = TextEditingController(text: r == null ? "" : "${r.km}");
|
||||
_notes = TextEditingController(text: r?.notes ?? "");
|
||||
_oil = r?.changedOil ?? true;
|
||||
_engine = r?.changedEngineAirFilter ?? false;
|
||||
_cabin = r?.changedCabinAirFilter ?? false;
|
||||
// An existing record is read through the part's own accessor; a new one
|
||||
// starts from the part's default, which is why an oil change comes ticked.
|
||||
_changed = {
|
||||
for (final part in kServiceParts)
|
||||
part.key: r == null ? part.initial : part.changed(r),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1922,9 +2019,7 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
"car": widget.carId,
|
||||
"date": _date.toUtc().toIso8601String(),
|
||||
"km": km,
|
||||
"changedOil": _oil,
|
||||
"changedEngineAirFilter": _engine,
|
||||
"changedCabinAirFilter": _cabin,
|
||||
for (final part in kServiceParts) part.field: _changed[part.key],
|
||||
"notes": _notes.text.trim(),
|
||||
};
|
||||
try {
|
||||
@@ -1997,27 +2092,14 @@ class _ServiceSheetState extends State<_ServiceSheet> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: _oil,
|
||||
onChanged: (v) => setState(() => _oil = v ?? false),
|
||||
title: Text(t("forms.service.oil")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: _engine,
|
||||
onChanged: (v) => setState(() => _engine = v ?? false),
|
||||
title: Text(t("forms.service.engineFilter")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: _cabin,
|
||||
onChanged: (v) => setState(() => _cabin = v ?? false),
|
||||
title: Text(t("forms.service.cabinFilter")),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
for (final part in kServiceParts)
|
||||
CheckboxListTile(
|
||||
value: _changed[part.key] ?? false,
|
||||
onChanged: (v) => setState(() => _changed[part.key] = v ?? false),
|
||||
title: Text(t(part.label)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
TextField(
|
||||
controller: _notes,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -58,6 +58,76 @@ const List<String> kCarInfoFieldKeys = [
|
||||
"firstRegistration",
|
||||
];
|
||||
|
||||
/// The Service history columns, in their default order. Keys mirror
|
||||
/// arrangeableServiceColumns in the API's cars.go, and the same list in the web
|
||||
/// CarDetail — the server rejects anything else. "date" is here because it can
|
||||
/// be moved, and absent from [kHideableServiceColumnKeys] because it cannot be
|
||||
/// switched off.
|
||||
///
|
||||
/// Every part a service can have changed shares the one "parts" column. They are
|
||||
/// a growing list, and a column apiece would widen the web's table without end;
|
||||
/// the phone shows them as chips, but the key set has to be the same one the
|
||||
/// server validates against.
|
||||
const List<String> kServiceColumnKeys = [
|
||||
"date",
|
||||
"km",
|
||||
"nextDate",
|
||||
"nextKm",
|
||||
"parts",
|
||||
"notes",
|
||||
"file",
|
||||
];
|
||||
|
||||
/// The columns that can be switched off — all but the date. Derived rather than
|
||||
/// written out again, the way the API derives hideableServiceColumns' companion,
|
||||
/// so the two cannot drift as columns are added.
|
||||
final List<String> kHideableServiceColumnKeys =
|
||||
kServiceColumnKeys.where((key) => key != "date").toList();
|
||||
|
||||
/// The heading a column carries. The labels were translated as car.services.col*
|
||||
/// long before they became keys, so the two are mapped rather than derived —
|
||||
/// renaming a dozen strings in three languages to save this table would be the
|
||||
/// wrong trade. The parts column borrows the form's heading on purpose: the
|
||||
/// column and the form's section are the same thing.
|
||||
const Map<String, String> _kServiceColumnLabels = {
|
||||
"date": "car.services.colDate",
|
||||
"km": "car.services.colKm",
|
||||
"nextDate": "car.services.colNextDate",
|
||||
"nextKm": "car.services.colNextKm",
|
||||
"parts": "forms.service.changedParts",
|
||||
"notes": "car.services.colNotes",
|
||||
"file": "car.services.colFile",
|
||||
};
|
||||
|
||||
String serviceColumnLabel(String key) => t(_kServiceColumnLabels[key] ?? key);
|
||||
|
||||
/// The columns that are a value on a line, as against the ones that need a
|
||||
/// block to themselves: what a service changed, what was written about it, and
|
||||
/// what was filed with it.
|
||||
const Set<String> kInlineServiceColumns = {"date", "km", "nextDate", "nextKm"};
|
||||
|
||||
/// A card's columns, grouped into the runs it lays them out in.
|
||||
///
|
||||
/// A card is not a table: the web app gives every column a cell of its own on
|
||||
/// one line, which a phone has no width for. Consecutive short columns instead
|
||||
/// share a wrapping line — a wrap flows left to right and then down, so the
|
||||
/// arrangement survives intact — and each of the three that need room breaks
|
||||
/// onto its own. Which means the grouping follows the car's order rather than
|
||||
/// the catalogue's: move Notes between Km and Next date and it splits the short
|
||||
/// columns into two runs, because that is what the arrangement asked for.
|
||||
List<List<String>> serviceColumnRuns(List<String> columns) {
|
||||
final runs = <List<String>>[];
|
||||
for (final key in columns) {
|
||||
final inline = kInlineServiceColumns.contains(key);
|
||||
if (inline && runs.isNotEmpty && kInlineServiceColumns.contains(runs.last.last)) {
|
||||
runs.last.add(key);
|
||||
} else {
|
||||
runs.add([key]);
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// Applies a car's stored arrangement to a catalogue of keys.
|
||||
///
|
||||
/// The stored order may be partial and may name keys this release does not know:
|
||||
@@ -105,8 +175,10 @@ class CarViewSheet extends StatefulWidget {
|
||||
class _CarViewSheetState extends State<CarViewSheet> {
|
||||
late List<String> _tabOrder;
|
||||
late List<String> _fieldOrder;
|
||||
late List<String> _columnOrder;
|
||||
late Set<String> _visibleTabs;
|
||||
late Set<String> _visibleFields;
|
||||
late Set<String> _visibleColumns;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
@@ -118,6 +190,10 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
_fieldOrder = arrangeKeys(kCarInfoFieldKeys, car.fieldOrder);
|
||||
_visibleTabs = kHideableCarTabKeys.where((k) => !car.hiddenTabs.contains(k)).toSet();
|
||||
_visibleFields = kCarInfoFieldKeys.where((k) => !car.hiddenFields.contains(k)).toSet();
|
||||
_columnOrder = arrangeKeys(kServiceColumnKeys, car.serviceColumnOrder);
|
||||
_visibleColumns = kHideableServiceColumnKeys
|
||||
.where((k) => !car.hiddenServiceColumns.contains(k))
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Whether [key] is one of the rows on offer below: Information never is
|
||||
@@ -158,6 +234,10 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
setState(() => _fieldOrder.insert(newIndex, _fieldOrder.removeAt(oldIndex)));
|
||||
}
|
||||
|
||||
void _moveColumn(int oldIndex, int newIndex) {
|
||||
setState(() => _columnOrder.insert(newIndex, _columnOrder.removeAt(oldIndex)));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
@@ -168,8 +248,11 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
widget.car.id,
|
||||
hiddenTabs: kHideableCarTabKeys.where((k) => !_visibleTabs.contains(k)).toList(),
|
||||
hiddenFields: kCarInfoFieldKeys.where((k) => !_visibleFields.contains(k)).toList(),
|
||||
hiddenServiceColumns:
|
||||
kHideableServiceColumnKeys.where((k) => !_visibleColumns.contains(k)).toList(),
|
||||
tabOrder: _tabOrder,
|
||||
fieldOrder: _fieldOrder,
|
||||
serviceColumnOrder: _columnOrder,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, car);
|
||||
} catch (e) {
|
||||
@@ -262,6 +345,42 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_heading(context, t("car.viewPicker.serviceColumnsHeading")),
|
||||
ReorderableListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: _moveColumn,
|
||||
children: [
|
||||
for (var i = 0; i < _columnOrder.length; i++)
|
||||
_row(
|
||||
context,
|
||||
index: i,
|
||||
key: _columnOrder[i],
|
||||
label: serviceColumnLabel(_columnOrder[i]),
|
||||
// Date is checked and locked rather than left out of
|
||||
// the list: it cannot be switched off, but it can be
|
||||
// moved, and a row missing from here would be a row
|
||||
// nothing on this screen can drag.
|
||||
on: _columnOrder[i] == "date" ||
|
||||
_visibleColumns.contains(_columnOrder[i]),
|
||||
onChanged: _columnOrder[i] == "date"
|
||||
? null
|
||||
: (v) => setState(() => v
|
||||
? _visibleColumns.add(_columnOrder[i])
|
||||
: _visibleColumns.remove(_columnOrder[i])),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
"${t("car.viewPicker.columnAlwaysOn", params: {"column": serviceColumnLabel("date")})}"
|
||||
" ${t("car.viewPicker.reorderHint")}",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
@@ -309,20 +428,25 @@ class _CarViewSheetState extends State<CarViewSheet> {
|
||||
/// One switchable, draggable row. The handle is explicit rather than
|
||||
/// long-press-anywhere: the row's own tap target is the checkbox, and a
|
||||
/// long-press drag would fight it.
|
||||
/// A null [onChanged] locks the checkbox on — the row is still draggable,
|
||||
/// which is the whole point of showing a column that cannot be switched off.
|
||||
Widget _row(
|
||||
BuildContext context, {
|
||||
required int index,
|
||||
required String key,
|
||||
required String label,
|
||||
required bool on,
|
||||
required ValueChanged<bool> onChanged,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
}) =>
|
||||
Padding(
|
||||
key: ValueKey(key),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(value: on, onChanged: (v) => onChanged(v ?? false)),
|
||||
Checkbox(
|
||||
value: on,
|
||||
onChanged: onChanged == null ? null : (v) => onChanged(v ?? false),
|
||||
),
|
||||
Expanded(child: Text(label, style: const TextStyle(fontSize: 14))),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import "models.dart";
|
||||
|
||||
/// The parts a service record can say were changed.
|
||||
///
|
||||
/// One list, because each part has to appear in three places at once — the
|
||||
/// Changed parts section of the service form, the parts column of a service
|
||||
/// card, and the panel that column opens — and three hand-kept copies would
|
||||
/// drift the first time somebody adds a part. Adding one here (plus its boolean
|
||||
/// on the API's service_records collection) is the whole job.
|
||||
///
|
||||
/// Mirrors SERVICE_PARTS in the web app's lib/serviceParts.js. The two lists
|
||||
/// share the [label] keys on purpose: a part named one thing here and another
|
||||
/// there would be the same service reading differently on the two screens.
|
||||
class ServicePart {
|
||||
/// What the part is known by wherever a part has to be identified. Not a
|
||||
/// column key — every part shares the one "parts" column, which is what keeps
|
||||
/// the table from widening as parts are added.
|
||||
final String key;
|
||||
|
||||
/// The record's own boolean, as the API names it in JSON. Only the form needs
|
||||
/// it, to build the payload it sends.
|
||||
final String field;
|
||||
|
||||
/// The translation key, shared with the form so the card and the dialog
|
||||
/// cannot end up wording the same part differently.
|
||||
final String label;
|
||||
|
||||
/// The shorter wording, for the chip on a service card. A chip has room for a
|
||||
/// phrase and not a sentence; the web's table cell has the same problem and
|
||||
/// answers it by truncating the list instead.
|
||||
final String chipLabel;
|
||||
|
||||
/// What a *new* record starts with: an oil change is the reason for most
|
||||
/// services, the filters are the exception.
|
||||
final bool initial;
|
||||
|
||||
/// Whether this record says the part was changed. A function rather than the
|
||||
/// field name because Dart cannot index a class the way the web app indexes
|
||||
/// the record object it got back as JSON.
|
||||
final bool Function(ServiceRecord) changed;
|
||||
|
||||
const ServicePart({
|
||||
required this.key,
|
||||
required this.field,
|
||||
required this.label,
|
||||
required this.chipLabel,
|
||||
required this.initial,
|
||||
required this.changed,
|
||||
});
|
||||
}
|
||||
|
||||
final List<ServicePart> kServiceParts = [
|
||||
ServicePart(
|
||||
key: "oil",
|
||||
field: "changedOil",
|
||||
label: "forms.service.oil",
|
||||
chipLabel: "car.services.chipOil",
|
||||
initial: true,
|
||||
changed: (s) => s.changedOil,
|
||||
),
|
||||
ServicePart(
|
||||
key: "engineFilter",
|
||||
field: "changedEngineAirFilter",
|
||||
label: "forms.service.engineFilter",
|
||||
chipLabel: "car.services.chipEngineFilter",
|
||||
initial: false,
|
||||
changed: (s) => s.changedEngineAirFilter,
|
||||
),
|
||||
ServicePart(
|
||||
key: "cabinFilter",
|
||||
field: "changedCabinAirFilter",
|
||||
label: "forms.service.cabinFilter",
|
||||
chipLabel: "car.services.chipCabinFilter",
|
||||
initial: false,
|
||||
changed: (s) => s.changedCabinAirFilter,
|
||||
),
|
||||
];
|
||||
|
||||
/// The parts this record says were changed. A record written before a part
|
||||
/// existed simply doesn't carry its field, which the model reads as false —
|
||||
/// "not changed" rather than "unknown", because that service genuinely didn't
|
||||
/// change it.
|
||||
List<ServicePart> changedParts(ServiceRecord service) =>
|
||||
kServiceParts.where((part) => part.changed(service)).toList();
|
||||
@@ -0,0 +1,138 @@
|
||||
// The view picker and the layout it drives.
|
||||
//
|
||||
// What the picker saves is a property of the car, read by the web app as well,
|
||||
// so the two things worth guarding are that it offers exactly the keys the
|
||||
// server accepts — with the date shown but locked on, since a hidden date is a
|
||||
// 400 — and that a service card lays the columns out in the order it was given
|
||||
// rather than the catalogue's.
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:intl/date_symbol_data_local.dart";
|
||||
import "package:drivervault_phone/i18n.dart";
|
||||
import "package:drivervault_phone/main.dart";
|
||||
import "package:drivervault_phone/models.dart";
|
||||
import "package:drivervault_phone/screens/car_view_sheet.dart";
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await initializeDateFormatting();
|
||||
await loadTranslations();
|
||||
appSettings.locale = "en-GB";
|
||||
appSettings.dateFormat = "DMY_NUM";
|
||||
});
|
||||
|
||||
group("serviceColumnRuns", () {
|
||||
test("the short columns share a line, the other three stand alone", () {
|
||||
expect(
|
||||
serviceColumnRuns(kServiceColumnKeys),
|
||||
[
|
||||
["date", "km", "nextDate", "nextKm"],
|
||||
["parts"],
|
||||
["notes"],
|
||||
["file"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("a column moved between two short ones splits the line", () {
|
||||
// The grouping follows the car's arrangement, not the catalogue's: this is
|
||||
// the part a hand-written card would get wrong by grouping the short
|
||||
// columns first and laying the blocks out after them, which would quietly
|
||||
// undo the arrangement.
|
||||
expect(
|
||||
serviceColumnRuns(["date", "km", "notes", "nextDate", "nextKm"]),
|
||||
[
|
||||
["date", "km"],
|
||||
["notes"],
|
||||
["nextDate", "nextKm"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden columns simply are not there", () {
|
||||
expect(
|
||||
serviceColumnRuns(["date", "notes"]),
|
||||
[
|
||||
["date"],
|
||||
["notes"],
|
||||
],
|
||||
);
|
||||
// Date cannot be hidden, so a card always has at least one run — the tile
|
||||
// reads pieces.first without checking.
|
||||
expect(serviceColumnRuns(["date"]), [
|
||||
["date"]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group("CarViewSheet", () {
|
||||
// A surface tall enough for the whole sheet. Its list is lazy, so on a
|
||||
// phone-sized one the columns — the last of the three sections — are simply
|
||||
// not built and nothing below can be found.
|
||||
Future<void> pump(WidgetTester tester, Car car) async {
|
||||
tester.view.physicalSize = const Size(800, 5000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CarViewSheet(car: car, showProvider: false),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Car car({List<String> hiddenColumns = const [], List<String> columnOrder = const []}) =>
|
||||
Car.fromJson({
|
||||
"id": "car1",
|
||||
"name": "bZ4X",
|
||||
"hiddenServiceColumns": hiddenColumns,
|
||||
"serviceColumnOrder": columnOrder,
|
||||
});
|
||||
|
||||
testWidgets("offers every column, with the date locked on", (tester) async {
|
||||
await pump(tester, car());
|
||||
|
||||
// The section headings are rendered upper-cased.
|
||||
expect(find.text(t("car.viewPicker.serviceColumnsHeading").toUpperCase()),
|
||||
findsOneWidget);
|
||||
for (final key in kServiceColumnKeys) {
|
||||
expect(find.text(serviceColumnLabel(key)), findsWidgets,
|
||||
reason: "the $key column is not on offer");
|
||||
}
|
||||
|
||||
// The date's checkbox is on and cannot be turned off; every other column's
|
||||
// can. A picker that let the date be unticked would save a hidden set the
|
||||
// server refuses.
|
||||
final boxes = tester.widgetList<Checkbox>(find.byType(Checkbox)).toList();
|
||||
final locked = boxes.where((b) => b.onChanged == null).toList();
|
||||
expect(locked, hasLength(1));
|
||||
expect(locked.single.value, isTrue);
|
||||
});
|
||||
|
||||
testWidgets("a switched-off column comes back unticked", (tester) async {
|
||||
await pump(tester, car(hiddenColumns: ["parts", "file"]));
|
||||
|
||||
// Read the ticks in the order the rows are laid out — the sheet lists the
|
||||
// tabs and the Information rows above the columns, so the columns are the
|
||||
// last seven.
|
||||
final ticks = tester
|
||||
.widgetList<Checkbox>(find.byType(Checkbox))
|
||||
.map((b) => b.value ?? false)
|
||||
.toList();
|
||||
final columns = ticks.sublist(ticks.length - kServiceColumnKeys.length);
|
||||
expect(columns, [true, true, true, true, false, true, false]);
|
||||
});
|
||||
|
||||
testWidgets("the rows follow the car's arrangement", (tester) async {
|
||||
await pump(tester, car(columnOrder: ["notes", "date"]));
|
||||
|
||||
final notes = tester.getTopLeft(find.text(serviceColumnLabel("notes")).last).dy;
|
||||
final date = tester.getTopLeft(find.text(serviceColumnLabel("date")).last).dy;
|
||||
final km = tester.getTopLeft(find.text(serviceColumnLabel("km")).last).dy;
|
||||
expect(notes, lessThan(date));
|
||||
expect(date, lessThan(km));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import "package:drivervault_phone/main.dart";
|
||||
import "package:drivervault_phone/models.dart";
|
||||
import "package:drivervault_phone/screens/car_form_sheet.dart";
|
||||
import "package:drivervault_phone/screens/car_view_sheet.dart";
|
||||
import "package:drivervault_phone/service_parts.dart";
|
||||
|
||||
void main() {
|
||||
// rootBundle (used by loadTranslations) needs the binding initialised.
|
||||
@@ -236,12 +237,16 @@ void main() {
|
||||
"tabOrder": ["info", "charging"],
|
||||
"fieldOrder": ["vin"],
|
||||
"metricOrder": ["evRange", "odometer"],
|
||||
"hiddenServiceColumns": ["parts"],
|
||||
"serviceColumnOrder": ["notes", "date"],
|
||||
});
|
||||
expect(car.provider, "toyota");
|
||||
expect(car.providerVehicleId, "VIN123");
|
||||
expect(car.hiddenTabs, ["fuel"]);
|
||||
expect(car.tabOrder, ["info", "charging"]);
|
||||
expect(car.metricOrder, ["evRange", "odometer"]);
|
||||
expect(car.hiddenServiceColumns, ["parts"]);
|
||||
expect(car.serviceColumnOrder, ["notes", "date"]);
|
||||
|
||||
// A car nobody has arranged carries empty lists, not nulls — the callers
|
||||
// read them directly.
|
||||
@@ -249,6 +254,63 @@ void main() {
|
||||
expect(plain.provider, "");
|
||||
expect(plain.hiddenTabs, isEmpty);
|
||||
expect(plain.tabOrder, isEmpty);
|
||||
expect(plain.hiddenServiceColumns, isEmpty);
|
||||
expect(plain.serviceColumnOrder, isEmpty);
|
||||
});
|
||||
|
||||
test("the Service history columns: date arranges but never hides", () {
|
||||
// Mirrors hideableServiceColumns/arrangeableServiceColumns in the API's
|
||||
// cars.go: the arrangeable set is the hideable one plus the date, and the
|
||||
// server rejects a hidden set that names anything else — so the picker must
|
||||
// never offer the date as something to switch off.
|
||||
expect(kHideableServiceColumnKeys, isNot(contains("date")));
|
||||
expect(kServiceColumnKeys, contains("date"));
|
||||
expect({...kHideableServiceColumnKeys, "date"}, kServiceColumnKeys.toSet());
|
||||
|
||||
// A field key is not a column key. The two catalogues are separate lists of
|
||||
// similar-looking strings, and sending one where the other belongs is a 400
|
||||
// from the server with nothing on screen to explain it.
|
||||
for (final key in kCarInfoFieldKeys) {
|
||||
expect(kServiceColumnKeys, isNot(contains(key)));
|
||||
}
|
||||
|
||||
// Every part shares the one "parts" column, so adding a part must not add a
|
||||
// column — that is what keeps the key set from growing without end.
|
||||
for (final part in kServiceParts) {
|
||||
expect(kServiceColumnKeys, isNot(contains(part.key)));
|
||||
}
|
||||
|
||||
// A column switched back on returns to where it was rather than to the end,
|
||||
// because the stored order covers the hidden columns too.
|
||||
final arranged = arrangeKeys(kServiceColumnKeys, ["notes", "km", "date"]);
|
||||
expect(arranged.take(3).toList(), ["notes", "km", "date"]);
|
||||
expect(arranged.toSet(), kServiceColumnKeys.toSet());
|
||||
});
|
||||
|
||||
test("changedParts reads a record through the shared catalogue", () {
|
||||
ServiceRecord record({bool oil = false, bool engine = false, bool cabin = false}) =>
|
||||
ServiceRecord.fromJson({
|
||||
"id": "s",
|
||||
"car": "c",
|
||||
"date": "2026-03-01T00:00:00Z",
|
||||
"km": 90000,
|
||||
"changedOil": oil,
|
||||
"changedEngineAirFilter": engine,
|
||||
"changedCabinAirFilter": cabin,
|
||||
});
|
||||
|
||||
expect(changedParts(record()).isEmpty, isTrue);
|
||||
expect(changedParts(record(oil: true)).map((p) => p.key), ["oil"]);
|
||||
// Catalogue order, not the order the fields happen to be read in.
|
||||
expect(
|
||||
changedParts(record(cabin: true, oil: true)).map((p) => p.key),
|
||||
["oil", "cabinFilter"],
|
||||
);
|
||||
|
||||
// A record written before a part existed carries no field for it, which
|
||||
// reads as "not changed" rather than as a missing value.
|
||||
final old = ServiceRecord.fromJson({"id": "s", "car": "c", "km": 0});
|
||||
expect(changedParts(old).isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test("arrangeKeys: partial orders keep every key, unknown ones are dropped", () {
|
||||
@@ -332,6 +394,29 @@ void main() {
|
||||
expectLabelled("car.tabs.connected");
|
||||
});
|
||||
|
||||
test("Service history columns", () {
|
||||
// serviceColumnLabel maps rather than derives, so a column added to the
|
||||
// catalogue without an entry in that map would render as its own key.
|
||||
for (final key in kServiceColumnKeys) {
|
||||
for (final lang in translatedLanguages) {
|
||||
appSettings.locale = "$lang-${lang.toUpperCase()}";
|
||||
expect(serviceColumnLabel(key), isNot(key),
|
||||
reason: "the $key column has no label in $lang.json");
|
||||
expect(serviceColumnLabel(key).trim(), isNotEmpty,
|
||||
reason: "the $key column is blank in $lang.json");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the parts a service can change", () {
|
||||
// Both wordings: the form's and the card's chip. A part with only one of
|
||||
// them reaches a screen as a raw key path.
|
||||
for (final part in kServiceParts) {
|
||||
expectLabelled(part.label);
|
||||
expectLabelled(part.chipLabel);
|
||||
}
|
||||
});
|
||||
|
||||
test("Information rows", () {
|
||||
for (final key in kCarInfoFieldKeys) {
|
||||
expectLabelled("car.info.$key");
|
||||
|
||||
+2
-1
@@ -108,7 +108,8 @@ file, as do the units.
|
||||
`test/models_format_test.dart` guards two things the analyzer cannot see. The
|
||||
lookups built from a key at render time (`car.tabs.$key`, `enums.fuelType.$v`,
|
||||
`admin.roles.$r`, the delete dialog's plural counts, the connected service's
|
||||
readings) must resolve to a real label in every language — a catalogue entry
|
||||
readings, the Service history columns and the parts a service can change) must
|
||||
resolve to a real label in every language — a catalogue entry
|
||||
with no translation fails the test rather than reaching a screen as a raw key
|
||||
path. And every key `en.json` carries must exist in `pl.json` and `da.json`,
|
||||
so a phrase added in English alone is caught at the point it is added rather
|
||||
|
||||
Reference in New Issue
Block a user