Initial commit
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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")),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user