Two changes, both about showing what is actually known rather than a tidier version of it. The build date asked for a day. A car's build date is often only a year, or a month and a year - the VIN plate is stamped with a month, the papers carry a day, a grey import neither - so a field insisting on all three is answered either with an invented day or with nothing, and both throw away what the owner did know. The field now picks its own precision: a full date, a month and year, or a year, each with the control that suits it. A year is typed rather than picked, because a date picker that makes you walk back to 1998 is worse than four keystrokes. Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601 reduced precision, and printed back at exactly that precision. The three shapes sort and compare as strings in date order, which is why the prefix is stored rather than a date with a precision field beside it. The formatter takes the string apart rather than parsing it: "2015-03" read as a UTC instant and printed in local time hands back February west of Greenwich. Narrowing the precision keeps what is still true, so a day dropped from "2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward half of the control and it is deliberate: there is nothing to widen a year with, and leaving "2015" behind an empty month box would store a date the screen is not showing. The column was free text with no validation at all, which was tolerable while only a date picker could write it and is not now that three shapes are legal. normalizeBuildDate parses rather than pattern-matches, so "2015-13" and "2015-02-31" are refused instead of stored as something no reader can print. The phone needed changing to avoid destroying this. It parsed buildDate with DateTime.tryParse, which returns null for "2015" - so a half-known date would have shown as a dash, and saving the car from the phone would have written "" back over it. It holds both date fields as the string they arrived as now, prints them at their own precision, and hands back anything it cannot set. Its picker still only makes full dates; a precision control there is a separate job. Separately: an empty cell of the service table had three different looks in one row. The dash under Notes was body-coloured, as though it were content; the one under File was 12px, having borrowed the size of the Download button that would otherwise be there; the one under Changed parts was muted at 14px. They are one constant now, muted at the row's own size, which is what Next date and Next km already did for a missing value. The Download link keeps its own styling - it is an action, not a value. Verified in a browser: a stored "2015-03" loads as month precision in a month picker, month to year narrows to "2015", year to day clears, "19x98abc" typed into the year box sanitises to "1998", saving sends buildDate:"1998" and the Information tab then reads "1998" - while a full first-registration date beside it still reads 06-08-2026. All five empty cells across the three columns now compute to the same size, colour and weight, with the filled ones unchanged. go vet and go test ./... pass with a new test over the three valid shapes and six rejects; flutter analyze is clean and 22 tests pass, one new, covering a half-known date in two date formats and the time zone that could shift it; npm run build is clean. Not verified: First registration still demands a full date. The same argument applies to it and the field is now a reusable component, but it was not asked for and is one line away. The web formatter's month-name paths - the DMY and MDY formats, which spell the month out - are covered only by the phone's mirror of the logic, the web app still having no test runner. A car created through the Toyota import bypasses the new validation; it only ever produces full dates, so nothing invalid gets in that way, but it is not guarded. Both apps need redeploying before any of this is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
11 KiB
Dart
292 lines
11 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../models.dart";
|
|
import "../format.dart";
|
|
import "../theme.dart";
|
|
|
|
/// The fuel types a car can carry, in the order the API's models.go lists them.
|
|
/// Keys only: the labels live in enums.fuelType.*, which the car's Information
|
|
/// tab reads as well, so the two cannot disagree.
|
|
const List<String> kFuelTypes = [
|
|
"petrol",
|
|
"petrol_lpg",
|
|
"diesel",
|
|
"diesel_lpg",
|
|
"hybrid",
|
|
"electric",
|
|
"hydrogen",
|
|
];
|
|
|
|
/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal.
|
|
/// Pops with the saved [Car] on success, or null if cancelled.
|
|
class CarFormSheet extends StatefulWidget {
|
|
final Car? car; // null => create
|
|
const CarFormSheet({super.key, this.car});
|
|
|
|
@override
|
|
State<CarFormSheet> createState() => _CarFormSheetState();
|
|
}
|
|
|
|
class _CarFormSheetState extends State<CarFormSheet> {
|
|
late final Map<String, TextEditingController> _c;
|
|
String _fuelType = "";
|
|
// Held as the ISO string they arrived as, not as a DateTime. A build date may
|
|
// be a year or a month rather than a day — the web app can enter one, this
|
|
// sheet's date picker cannot — and parsing it into a DateTime would hand back
|
|
// null, which on save would round somebody's "2015" down to nothing. Picking a
|
|
// date replaces the value; the clear button empties it; anything else is
|
|
// handed back exactly as it came.
|
|
String _buildDate = "";
|
|
String _firstRegistrationDate = "";
|
|
bool _saving = false;
|
|
String? _error;
|
|
|
|
bool get _isEdit => widget.car != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final car = widget.car;
|
|
_fuelType = car?.fuelType ?? "";
|
|
_buildDate = car?.buildDate ?? "";
|
|
_firstRegistrationDate = car?.firstRegistrationDate ?? "";
|
|
_c = {
|
|
"name": TextEditingController(text: car?.name ?? ""),
|
|
"make": TextEditingController(text: car?.make ?? ""),
|
|
"model": TextEditingController(text: car?.model ?? ""),
|
|
"year": TextEditingController(text: (car != null && car.year > 0) ? "${car.year}" : ""),
|
|
"registration": TextEditingController(text: car?.registration ?? ""),
|
|
"registrationCountry": TextEditingController(text: car?.registrationCountry ?? ""),
|
|
"vin": TextEditingController(text: car?.vin ?? ""),
|
|
"oilSpec": TextEditingController(text: car?.oilSpec ?? ""),
|
|
"transmissionOilSpec": TextEditingController(text: car?.transmissionOilSpec ?? ""),
|
|
"differentialOilSpec": TextEditingController(text: car?.differentialOilSpec ?? ""),
|
|
"brakeFluidSpec": TextEditingController(text: car?.brakeFluidSpec ?? ""),
|
|
"coolantSpec": TextEditingController(text: car?.coolantSpec ?? ""),
|
|
// 0 is a reading, so it prints; only a car that does not exist yet is blank.
|
|
"currentKm": TextEditingController(text: car == null ? "" : "${car.currentKm}"),
|
|
"serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"),
|
|
"serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"),
|
|
"technicalCheckIntervalDays": TextEditingController(
|
|
text: "${(car?.technicalCheckIntervalDays ?? 0) > 0 ? car!.technicalCheckIntervalDays : 365}"),
|
|
};
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in _c.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
int _int(String key, int fallback) => int.tryParse(_c[key]!.text.trim()) ?? fallback;
|
|
|
|
Future<void> _save() async {
|
|
final name = _c["name"]!.text.trim();
|
|
if (name.isEmpty) {
|
|
setState(() => _error = t("forms.validation.name"));
|
|
return;
|
|
}
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
final payload = {
|
|
"name": name,
|
|
"make": _c["make"]!.text.trim(),
|
|
"model": _c["model"]!.text.trim(),
|
|
"year": _int("year", 0),
|
|
"registration": _c["registration"]!.text.trim(),
|
|
"registrationCountry": _c["registrationCountry"]!.text.trim(),
|
|
"vin": _c["vin"]!.text.trim(),
|
|
"fuelType": _fuelType,
|
|
"buildDate": _buildDate,
|
|
"firstRegistrationDate": _firstRegistrationDate,
|
|
"oilSpec": _c["oilSpec"]!.text.trim(),
|
|
"transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(),
|
|
"differentialOilSpec": _c["differentialOilSpec"]!.text.trim(),
|
|
"brakeFluidSpec": _c["brakeFluidSpec"]!.text.trim(),
|
|
"coolantSpec": _c["coolantSpec"]!.text.trim(),
|
|
"currentKm": _int("currentKm", 0),
|
|
"serviceIntervalDays": _int("serviceIntervalDays", 365),
|
|
"serviceIntervalKm": _int("serviceIntervalKm", 15000),
|
|
"technicalCheckIntervalDays": _int("technicalCheckIntervalDays", 365),
|
|
};
|
|
try {
|
|
final saved = _isEdit
|
|
? await apiClient.updateCar(widget.car!.id, payload)
|
|
: await apiClient.createCar(payload);
|
|
if (mounted) Navigator.pop(context, saved);
|
|
} catch (e) {
|
|
setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
static String _isoOrEmpty(DateTime? d) => d == null
|
|
? ""
|
|
: "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
|
|
|
|
Widget _field(String key, String label,
|
|
{TextInputType? keyboard,
|
|
TextCapitalization caps = TextCapitalization.none,
|
|
String? helper}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: TextField(
|
|
controller: _c[key],
|
|
keyboardType: keyboard,
|
|
textCapitalization: caps,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
helperText: helper,
|
|
helperMaxLines: 3,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A tappable read-only field that opens a date picker. Shows a clear button
|
|
/// when a date is set, otherwise a calendar icon.
|
|
///
|
|
/// The value is an ISO string rather than a DateTime so that a half-known date
|
|
/// entered on the web — "2015", "2015-03" — survives a save here: it is shown
|
|
/// at the precision it has and handed back untouched unless the owner picks a
|
|
/// new date, which this picker can only ever make a full one.
|
|
Widget _dateField(String label, String value, ValueChanged<String> onChanged) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: InkWell(
|
|
onTap: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.tryParse(value) ?? DateTime.now(),
|
|
firstDate: DateTime(1950),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (picked != null) setState(() => onChanged(_isoOrEmpty(picked)));
|
|
},
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
suffixIcon: value.isEmpty
|
|
? const Icon(Icons.calendar_today, size: 18)
|
|
: IconButton(
|
|
icon: const Icon(Icons.clear, size: 18),
|
|
onPressed: () => setState(() => onChanged("")),
|
|
),
|
|
),
|
|
child: Text(formatPartialDate(value)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
top: 16,
|
|
bottom: DriverVault.sheetBottomInset(context),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(t(_isEdit ? "forms.car.editTitle" : "forms.car.addTitle"),
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 12),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
|
),
|
|
_field("name", t("forms.car.name"), caps: TextCapitalization.words),
|
|
Row(children: [
|
|
Expanded(child: _field("make", t("forms.car.make"))),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("model", t("forms.car.model"))),
|
|
]),
|
|
Row(children: [
|
|
Expanded(child: _field("year", t("forms.car.year"), keyboard: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("registration", t("forms.car.registration"))),
|
|
]),
|
|
_field("registrationCountry", t("forms.car.registrationCountry"),
|
|
caps: TextCapitalization.words),
|
|
_field("vin", t("forms.car.vin")),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: _fuelType.isEmpty ? null : _fuelType,
|
|
decoration: InputDecoration(
|
|
labelText: t("forms.car.fuelType"),
|
|
border: const OutlineInputBorder(),
|
|
isDense: true),
|
|
items: [
|
|
for (final key in kFuelTypes)
|
|
DropdownMenuItem(value: key, child: Text(t("enums.fuelType.$key"))),
|
|
],
|
|
onChanged: (v) => setState(() => _fuelType = v ?? ""),
|
|
),
|
|
),
|
|
Row(children: [
|
|
Expanded(
|
|
child: _dateField(t("forms.car.buildDate"), _buildDate, (v) => _buildDate = v)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _dateField(t("forms.car.firstRegistration"), _firstRegistrationDate,
|
|
(v) => _firstRegistrationDate = v)),
|
|
]),
|
|
_field("oilSpec", t("forms.car.oilSpec")),
|
|
Row(children: [
|
|
Expanded(child: _field("transmissionOilSpec", t("forms.car.transmissionOilSpec"))),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("differentialOilSpec", t("forms.car.differentialOilSpec"))),
|
|
]),
|
|
Row(children: [
|
|
Expanded(child: _field("brakeFluidSpec", t("forms.car.brakeFluidSpec"))),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("coolantSpec", t("forms.car.coolantSpec"))),
|
|
]),
|
|
_field("currentKm", t("forms.car.currentKm"), keyboard: TextInputType.number),
|
|
Row(children: [
|
|
Expanded(
|
|
child: _field("serviceIntervalDays", t("forms.car.serviceIntervalDays"),
|
|
keyboard: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _field("serviceIntervalKm", t("forms.car.serviceIntervalKm"),
|
|
keyboard: TextInputType.number)),
|
|
]),
|
|
_field("technicalCheckIntervalDays", t("forms.car.technicalCheckIntervalDays"),
|
|
keyboard: TextInputType.number,
|
|
helper: t("forms.car.technicalCheckHint")),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: Text(_saving
|
|
? t("common.saving")
|
|
: t(_isEdit ? "common.saveChanges" : "forms.car.submit")),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|