Both found by driving the installed app on a phone rather than by reading the code, which is worth noting: the second one is invisible in a simulator with gesture navigation turned off. The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric range (A/C off) 103.9 km". The long number is a reading converted out of miles: headlineMetrics multiplied by 1.609344 and printed whatever came out, so a range estimate claimed to know the distance to the metre, and the two readings disagreed about their own precision on the same card. Distances now keep one decimal and percentages none, applied by the reading's kind rather than by whether it was converted - a provider reporting 99.744 km natively gets the same treatment. Anything else is left alone, because without knowing what it measures there is no safe place to cut. The odometer already rounded to a whole number on its own path; this only changes the headline readings. The Add-user sheet's "Create user" button sat underneath the system navigation bar. Every one of these sheets padded its bottom with viewInsets.bottom, which is the keyboard - correct while typing and wrong the rest of the time, because with the keyboard down that inset is zero and the navigation bar is still there. They take the larger of the keyboard and the navigation bar now, since a raised keyboard covers the bar and the two must not be added. One helper on DriverVault rather than the same expression in six files, which is how the six drifted into being identical and identically wrong. Verified: go build, go vet and go test ./... pass, with a new test covering the conversion (62 mi reads 99.8 km), a native over-precise reading, a percentage, and the odometer's whole number surviving. flutter analyze clean, 21 tests pass, and the rebuilt release APK was installed on the phone - the Create user button now sits clear of the navigation bar, where the screenshot that prompted this showed it clipped. Not verified: the rounding is not visible on the phone yet. It talks to a deployed API Server that has not been rebuilt from this commit, so that tab will keep reading 99.744 until the server is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
11 KiB
Dart
282 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 = "";
|
|
DateTime? _buildDate;
|
|
DateTime? _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.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null;
|
|
_firstRegistrationDate =
|
|
(car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null;
|
|
_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": _isoOrEmpty(_buildDate),
|
|
"firstRegistrationDate": _isoOrEmpty(_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.
|
|
Widget _dateField(String label, DateTime? value, ValueChanged<DateTime?> onChanged) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: InkWell(
|
|
onTap: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: value ?? DateTime.now(),
|
|
firstDate: DateTime(1950),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (picked != null) setState(() => onChanged(picked));
|
|
},
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
suffixIcon: value == null
|
|
? const Icon(Icons.calendar_today, size: 18)
|
|
: IconButton(
|
|
icon: const Icon(Icons.clear, size: 18),
|
|
onPressed: () => setState(() => onChanged(null)),
|
|
),
|
|
),
|
|
child: Text(value == null ? "—" : formatDate(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")),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|