250 lines
9.7 KiB
Dart
250 lines
9.7 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../main.dart";
|
|
import "../models.dart";
|
|
import "../format.dart";
|
|
import "../theme.dart";
|
|
|
|
/// 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 ?? ""),
|
|
"currentKm":
|
|
TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""),
|
|
"serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"),
|
|
"serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"),
|
|
};
|
|
}
|
|
|
|
@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 = "Name is required.");
|
|
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),
|
|
};
|
|
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}) {
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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: MediaQuery.of(context).viewInsets.bottom + 16,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(_isEdit ? "Edit car" : "Add a car",
|
|
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", "Name *", caps: TextCapitalization.words),
|
|
Row(children: [
|
|
Expanded(child: _field("make", "Make")),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("model", "Model")),
|
|
]),
|
|
Row(children: [
|
|
Expanded(child: _field("year", "Year", keyboard: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("registration", "Registration")),
|
|
]),
|
|
_field("registrationCountry", "Registration country", caps: TextCapitalization.words),
|
|
_field("vin", "VIN"),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: _fuelType.isEmpty ? null : _fuelType,
|
|
decoration: const InputDecoration(
|
|
labelText: "Fuel type", border: OutlineInputBorder(), isDense: true),
|
|
items: const [
|
|
DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")),
|
|
DropdownMenuItem(value: "diesel", child: Text("Diesel")),
|
|
DropdownMenuItem(value: "hybrid", child: Text("Hybrid")),
|
|
DropdownMenuItem(value: "electric", child: Text("Electric")),
|
|
],
|
|
onChanged: (v) => setState(() => _fuelType = v ?? ""),
|
|
),
|
|
),
|
|
Row(children: [
|
|
Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _dateField("First registration", _firstRegistrationDate,
|
|
(v) => _firstRegistrationDate = v)),
|
|
]),
|
|
_field("oilSpec", "Engine oil spec"),
|
|
Row(children: [
|
|
Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("differentialOilSpec", "Differential oil spec")),
|
|
]),
|
|
Row(children: [
|
|
Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: _field("coolantSpec", "Coolant spec")),
|
|
]),
|
|
_field("currentKm", "Current odometer (km)", keyboard: TextInputType.number),
|
|
Row(children: [
|
|
Expanded(
|
|
child: _field("serviceIntervalDays", "Service interval (days)",
|
|
keyboard: TextInputType.number)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _field("serviceIntervalKm", "Service interval (km)",
|
|
keyboard: TextInputType.number)),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|