diff --git a/Phone App/README.md b/Phone App/README.md index 4dd4512..86052f1 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -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 ``` diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index e9df189..80b7336 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -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" diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 4bef423..bbacb18 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -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" diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 2d9991c..db7d0e2 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -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" diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index c36ee47..52aaa6e 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -144,6 +144,8 @@ class ApiClient { List? tabOrder, List? fieldOrder, List? metricOrder, + List? hiddenServiceColumns, + List? serviceColumnOrder, }) async { final body = { 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.from(data)); diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 5728237..7906a32 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -99,6 +99,14 @@ class Car { final List fieldOrder; final List 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 hiddenServiceColumns; + final List 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 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"; diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index a34318d..5a62765 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -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 { 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 { 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 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 = [ - 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 _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( diff --git a/Phone App/lib/screens/car_view_sheet.dart b/Phone App/lib/screens/car_view_sheet.dart index c65792c..cf4f77b 100644 --- a/Phone App/lib/screens/car_view_sheet.dart +++ b/Phone App/lib/screens/car_view_sheet.dart @@ -58,6 +58,76 @@ const List 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 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 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 _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 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> serviceColumnRuns(List columns) { + final runs = >[]; + 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 { late List _tabOrder; late List _fieldOrder; + late List _columnOrder; late Set _visibleTabs; late Set _visibleFields; + late Set _visibleColumns; bool _saving = false; String? _error; @@ -118,6 +190,10 @@ class _CarViewSheetState extends State { _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 { setState(() => _fieldOrder.insert(newIndex, _fieldOrder.removeAt(oldIndex))); } + void _moveColumn(int oldIndex, int newIndex) { + setState(() => _columnOrder.insert(newIndex, _columnOrder.removeAt(oldIndex))); + } + Future _save() async { setState(() { _saving = true; @@ -168,8 +248,11 @@ class _CarViewSheetState extends State { 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 { ), ], ), + 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 { /// 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 onChanged, + required ValueChanged? 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, diff --git a/Phone App/lib/service_parts.dart b/Phone App/lib/service_parts.dart new file mode 100644 index 0000000..2498a80 --- /dev/null +++ b/Phone App/lib/service_parts.dart @@ -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 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 changedParts(ServiceRecord service) => + kServiceParts.where((part) => part.changed(service)).toList(); diff --git a/Phone App/test/car_view_sheet_test.dart b/Phone App/test/car_view_sheet_test.dart new file mode 100644 index 0000000..b623cd6 --- /dev/null +++ b/Phone App/test/car_view_sheet_test.dart @@ -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 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 hiddenColumns = const [], List 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(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(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)); + }); + }); +} diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index d48c116..3f37638 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -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"); diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index aff2c1b..5c8a9e7 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -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