Files
DriverVault/Phone App/lib/screens/car_view_sheet.dart
T
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.

It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.

There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.

A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.

The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.

Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.

Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.

Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:08 +02:00

498 lines
19 KiB
Dart

import "package:flutter/material.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../service_parts.dart";
import "../theme.dart";
/// Every tab a car's page can show, in the order they appear when the car has no
/// arrangement of its own. Information sits second because the connected
/// service, when there is one, is what you came to look at.
///
/// Mirrors ALL_TAB_KEYS in the web CarDetail and arrangeableCarTabs in the API's
/// cars.go — the server rejects any key outside that set.
const List<String> kCarTabKeys = [
"provider",
"info",
"services",
"technical",
"maintenance",
"fuel",
"charging",
"documents",
"parts",
"reminders",
];
/// The tabs that can be switched off. "info" is deliberately absent: it is the
/// car itself, and a page with no tabs left would be a dead end. It can still be
/// moved, which is why the two lists are separate.
const List<String> kHideableCarTabKeys = [
"provider",
"services",
"technical",
"maintenance",
"fuel",
"charging",
"documents",
"parts",
"reminders",
];
/// The Information rows, in their default order. Keys mirror hideableCarFields
/// in the API's cars.go.
const List<String> kCarInfoFieldKeys = [
"oilSpec",
"transmissionOil",
"differentialOil",
"brakeFluid",
"coolant",
"odometer",
"serviceInterval",
"nextDue",
"registrationPlate",
"registrationCountry",
"vin",
"fuelType",
"buildDate",
"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:
/// anything unrecognised is dropped, and any key it leaves out follows the ones
/// it names. That is what puts a tab or row added in a later release at the end
/// of somebody's page rather than in the middle of it.
List<String> arrangeKeys(List<String> catalogue, List<String> order) {
final arranged = <String>[];
for (final key in order) {
if (catalogue.contains(key) && !arranged.contains(key)) arranged.add(key);
}
arranged.addAll(catalogue.where((k) => !arranged.contains(k)));
return arranged;
}
/// What this car's page shows: which tabs and Information rows are on, and the
/// order of each. Mirrors the web's view picker, except that the web rearranges
/// by dragging the tab bar and the rows themselves — a gesture the tab bar owns
/// on a touch screen — so both arrangements are made here instead, with a handle.
///
/// Edited as a draft and saved in one write rather than on every checkbox:
/// switching several off one at a time would make the page rearrange under the
/// finger between taps. Pops the updated [Car] when it saved.
class CarViewSheet extends StatefulWidget {
final Car car;
/// Whether this car could show a connected-service tab at all. Offering to
/// hide a tab nobody can see would just be confusing.
final bool showProvider;
/// The provider's display name ("MyToyota"), used for that tab's label.
final String providerLabel;
const CarViewSheet({
super.key,
required this.car,
required this.showProvider,
this.providerLabel = "",
});
@override
State<CarViewSheet> createState() => _CarViewSheetState();
}
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;
late Set<String> _visibleServiceParts;
bool _saving = false;
String? _error;
@override
void initState() {
super.initState();
final car = widget.car;
_tabOrder = arrangeKeys(kCarTabKeys, car.tabOrder);
_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();
_visibleServiceParts = visibleParts(car).map((p) => p.key).toSet();
}
/// Whether [key] is one of the rows on offer below: Information never is
/// (it cannot be switched off), and the connected service only when this car
/// could show one.
bool _isRow(String key) =>
key != "info" && (key != "provider" || widget.showProvider);
/// The tab rows on offer, in the car's arrangement.
List<String> get _tabRows => _tabOrder.where(_isRow).toList();
/// The connected-service row is named after the service once the car is
/// linked to one ("MyToyota"); before that there is no name to use, so it
/// falls back to the generic label rather than to car.tabs.provider, which is
/// deliberately not a key — the web app labels that tab the same way.
String _tabLabel(String key) {
if (key != "provider") return t("car.tabs.$key");
return widget.providerLabel.isEmpty ? t("car.tabs.connected") : widget.providerLabel;
}
/// Applies a drag over the rows to the full arrangement, which holds keys the
/// rows do not offer. The movable slots are refilled from the new row
/// sequence and everything else keeps the position it already had, so
/// rearranging the rows can never move Information — or a connected-service
/// tab this car cannot show — out from under the user.
void _moveTab(int oldIndex, int newIndex) {
final rows = _tabRows;
rows.insert(newIndex, rows.removeAt(oldIndex));
setState(() {
var next = 0;
_tabOrder = [
for (final key in _tabOrder) _isRow(key) ? rows[next++] : key,
];
});
}
void _moveField(int oldIndex, int newIndex) {
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;
_error = null;
});
try {
final car = await apiClient.updateCarView(
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,
hiddenServiceParts: kServiceParts
.where((p) => !_visibleServiceParts.contains(p.key))
.map((p) => p.key)
.toList(),
);
if (mounted) Navigator.pop(context, car);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final inset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(bottom: inset),
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
maxChildSize: 0.95,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Text(t("car.viewPicker.title"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Text(t("car.viewPicker.subtitle"),
style: TextStyle(fontSize: 12, color: DriverVault.muted(context))),
const SizedBox(height: 16),
_heading(context, t("car.viewPicker.tabsHeading")),
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: _moveTab,
children: [
for (var i = 0; i < _tabRows.length; i++)
_row(
context,
index: i,
key: _tabRows[i],
label: _tabLabel(_tabRows[i]),
on: _visibleTabs.contains(_tabRows[i]),
onChanged: (v) => setState(() => v
? _visibleTabs.add(_tabRows[i])
: _visibleTabs.remove(_tabRows[i])),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
"${t("car.viewPicker.alwaysOn", params: {"tab": t("car.tabs.info")})}"
" ${t("car.viewPicker.reorderHint")}",
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
),
),
const SizedBox(height: 20),
_heading(context, t("car.viewPicker.fieldsHeading")),
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: _moveField,
children: [
for (var i = 0; i < _fieldOrder.length; i++)
_row(
context,
index: i,
key: _fieldOrder[i],
label: t("car.info.${_fieldOrder[i]}"),
on: _visibleFields.contains(_fieldOrder[i]),
onChanged: (v) => setState(() => v
? _visibleFields.add(_fieldOrder[i])
: _visibleFields.remove(_fieldOrder[i])),
),
],
),
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)),
),
),
const SizedBox(height: 20),
_heading(context, t("car.viewPicker.servicePartsHeading")),
// No drag handle here, and no stored order: the parts are a
// checkbox list inside one column, so their position carries
// nothing a tab's or a column's does.
for (final part in kServiceParts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Checkbox(
value: _visibleServiceParts.contains(part.key),
onChanged: (v) => setState(() => (v ?? false)
? _visibleServiceParts.add(part.key)
: _visibleServiceParts.remove(part.key)),
),
Expanded(
child: Text(t(part.label), style: const TextStyle(fontSize: 14)),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
t("car.viewPicker.servicePartsHint"),
style: TextStyle(fontSize: 11, color: DriverVault.muted(context)),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context),
child: Text(t("common.cancel")),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? t("common.saving") : t("common.save")),
),
],
),
),
),
],
),
),
);
}
Widget _heading(BuildContext context, String text) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: DriverVault.muted(context))),
);
/// 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,
}) =>
Padding(
key: ValueKey(key),
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Checkbox(
value: on,
onChanged: onChanged == null ? null : (v) => onChanged(v ?? false),
),
Expanded(child: Text(label, style: const TextStyle(fontSize: 14))),
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(Icons.drag_handle, color: DriverVault.muted(context)),
),
),
],
),
);
}