import "package:flutter/material.dart"; import "../main.dart"; import "../models.dart"; import "../format.dart"; import "../theme.dart"; import "car_form_sheet.dart"; /// Serializes a car to the full update payload. The API's updateCar rewrites /// every column from the body, so partial payloads would blank omitted fields — /// always send them all, overriding only what changed. Map _carPayload(Car car, {int? currentKm}) => { "name": car.name, "make": car.make, "model": car.model, "year": car.year, "registration": car.registration, "registrationCountry": car.registrationCountry, "vin": car.vin, "oilSpec": car.oilSpec, "transmissionOilSpec": car.transmissionOilSpec, "differentialOilSpec": car.differentialOilSpec, "brakeFluidSpec": car.brakeFluidSpec, "coolantSpec": car.coolantSpec, "fuelType": car.fuelType, "buildDate": car.buildDate, "firstRegistrationDate": car.firstRegistrationDate, "serviceIntervalDays": car.serviceIntervalDays, "serviceIntervalKm": car.serviceIntervalKm, "currentKm": currentKm ?? car.currentKm, }; class CarDetailScreen extends StatefulWidget { final String carId; const CarDetailScreen({super.key, required this.carId}); @override State createState() => _CarDetailScreenState(); } class _CarDetailData { final Car car; final List services; final List parts; _CarDetailData(this.car, this.services, this.parts); } class _CarDetailScreenState extends State { late Future<_CarDetailData> _future; @override void initState() { super.initState(); _future = _load(); } Future<_CarDetailData> _load() async { final results = await Future.wait([ apiClient.getCar(widget.carId), apiClient.listCarServices(widget.carId), apiClient.listCarParts(widget.carId), ]); return _CarDetailData( results[0] as Car, results[1] as List, results[2] as List, ); } void _reload() => setState(() => _future = _load()); Future _addService(Car car) async { final added = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ServiceSheet(carId: car.id, car: car), ); if (added == true) _reload(); } Future _editService(Car car, ServiceRecord record) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ServiceSheet(carId: car.id, car: car, record: record), ); if (saved == true) _reload(); } Future _deleteService(ServiceRecord record) async { final ok = await _confirm("Delete this service record?"); if (!ok) return; try { await apiClient.deleteService(record.id); _reload(); } catch (e) { _snack("Delete failed: $e"); } } Future _editCar(Car car) async { final updated = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => CarFormSheet(car: car), ); if (updated != null) _reload(); } Future _addPart(Car car) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _PartSheet(carId: car.id), ); if (saved == true) _reload(); } Future _editPart(Car car, Part part) async { final saved = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _PartSheet(carId: car.id, part: part), ); if (saved == true) _reload(); } Future _deletePart(Part part) async { final ok = await _confirm("Delete this part?"); if (!ok) return; try { await apiClient.deletePart(part.id); _reload(); } catch (e) { _snack("Delete failed: $e"); } } Future _confirm(String message) async { final res = await showDialog( context: context, builder: (ctx) => AlertDialog( content: Text(message), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), FilledButton( style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), onPressed: () => Navigator.pop(ctx, true), child: const Text("Delete"), ), ], ), ); return res == true; } void _snack(String msg) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } Future _shareCar(Car car) async { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ShareSheet(car: car), ); } Future _editOdometer(Car car) async { final controller = TextEditingController(text: car.currentKm > 0 ? "${car.currentKm}" : ""); final saved = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text("Current odometer"), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration(suffixText: "km", border: OutlineInputBorder()), autofocus: true, ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), FilledButton( onPressed: () async { await apiClient.updateCar( car.id, _carPayload(car, currentKm: int.tryParse(controller.text.trim()) ?? 0), ); if (ctx.mounted) Navigator.pop(ctx, true); }, child: const Text("Save"), ), ], ), ); if (saved == true) _reload(); } Future _deleteCar(Car car, int serviceCount, int partCount) async { final confirmed = await showDialog( context: context, builder: (_) => _DeleteCarDialog( car: car, serviceCount: serviceCount, partCount: partCount, ), ); if (confirmed != true) return; try { await apiClient.deleteCar(car.id); if (mounted) Navigator.pop(context); // back to dashboard (it refreshes) } catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text("Delete failed: $e"))); } } } @override Widget build(BuildContext context) { return Scaffold( body: FutureBuilder<_CarDetailData>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } if (snap.hasError) { return Scaffold( appBar: AppBar(), body: Center(child: Text("${snap.error}")), ); } final data = snap.data!; final car = data.car; final latest = data.services.isNotEmpty ? data.services.first : null; final status = serviceStatus(latest, car); return DefaultTabController( length: 3, child: Scaffold( appBar: AppBar( title: Text(car.name), actions: [ if (car.isOwner) IconButton( tooltip: "Share car", icon: const Icon(Icons.person_add_alt), onPressed: () => _shareCar(car), ), if (car.canWrite) IconButton( tooltip: "Edit car", icon: const Icon(Icons.edit_outlined), onPressed: () => _editCar(car), ), if (car.canWrite) IconButton( tooltip: "Update odometer", icon: const Icon(Icons.speed), onPressed: () => _editOdometer(car), ), if (car.isOwner) IconButton( tooltip: "Delete car", icon: const Icon(Icons.delete_outline), onPressed: () => _deleteCar(car, data.services.length, data.parts.length), ), ], bottom: const TabBar( isScrollable: true, tabs: [ Tab(text: "Information"), Tab(text: "Service history"), Tab(text: "Parts catalog"), ], ), ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Column( children: [ _StatusHeader(car: car, status: status), if (car.isReadOnly) ...[ const SizedBox(height: 12), const _ReadOnlyNotice(), ], ], ), ), Expanded( child: TabBarView( children: [ // Information tab _InfoTab(car: car, latest: latest), // Service history tab _TabList( empty: data.services.isEmpty ? "No service records yet." : null, onAdd: car.canWrite ? () => _addService(car) : null, addLabel: "Add service", children: data.services .map((s) => _ServiceTile( record: s, onEdit: car.canWrite ? () => _editService(car, s) : null, onDelete: car.canWrite ? () => _deleteService(s) : null, )) .toList(), ), // Parts catalog tab _TabList( empty: data.parts.isEmpty ? "No parts yet." : null, onAdd: car.canWrite ? () => _addPart(car) : null, addLabel: "Add part", children: data.parts .map((p) => _PartTile( part: p, onEdit: car.canWrite ? () => _editPart(car, p) : null, onDelete: car.canWrite ? () => _deletePart(p) : null, )) .toList(), ), ], ), ), ], ), ), ); }, ), ); } } /// A scrollable tab body: an optional "+ Add" button, an empty-state message, or /// the list of tiles. class _TabList extends StatelessWidget { final String? empty; final VoidCallback? onAdd; final String addLabel; final List children; const _TabList({ required this.empty, required this.onAdd, required this.addLabel, required this.children, }); @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), children: [ if (onAdd != null) Align( alignment: Alignment.centerRight, child: FilledButton.icon( onPressed: onAdd, icon: const Icon(Icons.add, size: 18), label: Text(addLabel), ), ), const SizedBox(height: 8), if (empty != null) _Empty(empty!) else ...children, ], ); } } /// Slim header shown above the tabs: car subtitle (make/model) plus the /// service-status badge. The car's spec details now live in the Information tab. class _StatusHeader extends StatelessWidget { final Car car; final Status status; const _StatusHeader({required this.car, required this.status}); @override Widget build(BuildContext context) { return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Row( children: [ Expanded( child: Text(car.subtitle.isEmpty ? car.name : car.subtitle, style: TextStyle(color: DriverVault.muted(context))), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)), child: Text(status.label, style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontWeight: FontWeight.w600)), ), ], ), ), ); } } /// Information tab: the car's spec details (oil specs, odometer, interval, next /// due, VIN) in a scrollable card. class _InfoTab extends StatelessWidget { final Car car; final ServiceRecord? latest; const _InfoTab({required this.car, required this.latest}); @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), children: [ Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), ), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _kv(context, "Engine oil spec", _orDash(car.oilSpec)), _kv(context, "Transmission oil spec", _orDash(car.transmissionOilSpec)), _kv(context, "Differential oil spec", _orDash(car.differentialOilSpec)), _kv(context, "Brake fluid spec", _orDash(car.brakeFluidSpec)), _kv(context, "Coolant spec", _orDash(car.coolantSpec)), _kv(context, "Current odometer", formatKm(car.currentKm)), _kv(context, "Service interval", "${car.serviceIntervalDays} days · ${formatKm(car.serviceIntervalKm)}"), _kv(context, "Next due", "${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}"), _kv(context, "Registration plate", _orDash(car.registration)), _kv(context, "Registration country", _orDash(car.registrationCountry)), _kv(context, "VIN", _orDash(car.vin)), _kv(context, "Fuel type", _fuelLabel(car.fuelType)), _kv(context, "Build date", _dateOrDash(car.buildDate)), _kv(context, "First registration", _dateOrDash(car.firstRegistrationDate)), ], ), ), ), ], ); } static String _orDash(String v) => v.isEmpty ? "—" : v; static const Map _fuelLabels = { "petrol": "Petrol (gasoline)", "diesel": "Diesel", "hybrid": "Hybrid", "electric": "Electric", }; static String _fuelLabel(String v) => _fuelLabels[v] ?? "—"; static String _dateOrDash(String iso) { final d = DateTime.tryParse(iso); return d == null ? "—" : formatDate(d); } Widget _kv(BuildContext context, String k, String v) => Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 150, child: Text(k, style: TextStyle(color: DriverVault.muted(context)))), Expanded(child: Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurface))), ], ), ); } class _ServiceTile extends StatelessWidget { final ServiceRecord record; final VoidCallback? onEdit; final VoidCallback? onDelete; const _ServiceTile({required this.record, this.onEdit, this.onDelete}); @override Widget build(BuildContext context) { final chips = [ if (record.changedOil) _chip(context, "Oil & filter"), if (record.changedEngineAirFilter) _chip(context, "Engine air"), if (record.changedCabinAirFilter) _chip(context, "Cabin air"), ]; return Card( elevation: 0, margin: const EdgeInsets.only(bottom: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(formatDate(record.date), style: const TextStyle(fontWeight: FontWeight.w600)), Row(children: [ Text(formatKm(record.km)), if (onEdit != null || onDelete != null) _RowMenu(onEdit: onEdit, onDelete: onDelete), ]), ], ), const SizedBox(height: 4), Text("Next: ${formatDate(record.nextServiceDate)} · ${formatKm(record.nextServiceKm)}", style: const TextStyle(color: Colors.grey, fontSize: 12)), if (chips.isNotEmpty) ...[ const SizedBox(height: 8), Wrap(spacing: 6, runSpacing: 6, children: chips), ], if (record.notes.isNotEmpty) ...[ const SizedBox(height: 6), Text(record.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), ], ], ), ), ); } Widget _chip(BuildContext context, String label) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft, borderRadius: BorderRadius.circular(6), ), child: Text(label, style: const TextStyle(color: DriverVault.success, fontSize: 11, fontWeight: FontWeight.w500)), ); } class _PartTile extends StatelessWidget { final Part part; final VoidCallback? onEdit; final VoidCallback? onDelete; const _PartTile({required this.part, this.onEdit, this.onDelete}); @override Widget build(BuildContext context) { return Card( elevation: 0, margin: const EdgeInsets.only(bottom: 6), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), ), child: ListTile( dense: true, title: Text(part.name, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text(part.partNumber.isEmpty ? "—" : part.partNumber, style: DriverVault.mono(context, size: 12, weight: FontWeight.w400, color: DriverVault.muted(context))), trailing: (onEdit != null || onDelete != null) ? _RowMenu(onEdit: onEdit, onDelete: onDelete) : null, ), ); } } /// Overflow menu with Edit/Delete for a service/part row (write-gated). class _RowMenu extends StatelessWidget { final VoidCallback? onEdit; final VoidCallback? onDelete; const _RowMenu({this.onEdit, this.onDelete}); @override Widget build(BuildContext context) { return PopupMenuButton( padding: EdgeInsets.zero, icon: const Icon(Icons.more_vert, size: 18), onSelected: (v) { if (v == "edit") onEdit?.call(); if (v == "delete") onDelete?.call(); }, itemBuilder: (_) => [ if (onEdit != null) const PopupMenuItem(value: "edit", child: Text("Edit")), if (onDelete != null) const PopupMenuItem( value: "delete", child: Text("Delete", style: TextStyle(color: DriverVault.danger))), ], ); } } class _Empty extends StatelessWidget { final String text; const _Empty(this.text); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center(child: Text(text, style: const TextStyle(color: Colors.grey))), ); } /// Shown on a car that was shared with the current user read-only. class _ReadOnlyNotice extends StatelessWidget { const _ReadOnlyNotice(); @override Widget build(BuildContext context) { final onTint = DriverVault.brandOnTint(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: DriverVault.brandTint(context), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Icon(Icons.visibility_outlined, size: 18, color: onTint), const SizedBox(width: 8), Expanded( child: Text( "Shared with you (read-only). You can't make changes.", style: TextStyle(color: onTint, fontSize: 13), ), ), ], ), ); } } /// Owner-only bottom sheet to manage who a car is shared with: list current /// grants, add one by email at a chosen permission, or remove one. class _ShareSheet extends StatefulWidget { final Car car; const _ShareSheet({required this.car}); @override State<_ShareSheet> createState() => _ShareSheetState(); } class _ShareSheetState extends State<_ShareSheet> { late Future> _future; final _email = TextEditingController(); String _permission = "read"; bool _submitting = false; String? _error; @override void initState() { super.initState(); _future = apiClient.listCarShares(widget.car.id); } @override void dispose() { _email.dispose(); super.dispose(); } void _reload() => setState(() => _future = apiClient.listCarShares(widget.car.id)); Future _add() async { final email = _email.text.trim(); if (email.isEmpty) return; setState(() { _submitting = true; _error = null; }); try { await apiClient.addCarShare(widget.car.id, email, _permission); _email.clear(); _permission = "read"; _reload(); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _submitting = false); } } Future _setPermission(CarShare s, String value) async { try { await apiClient.addCarShare(widget.car.id, s.email, value); _reload(); } catch (e) { setState(() => _error = e.toString()); } } Future _remove(CarShare s) async { try { await apiClient.removeCarShare(widget.car.id, s.userId); _reload(); } catch (e) { setState(() => _error = e.toString()); } } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("Share ${widget.car.name}", style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 4), const Text( "Read-only lets them view; read & write also lets them edit the car and its records.", style: TextStyle(color: Colors.grey, fontSize: 12), ), const SizedBox(height: 12), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), ), Row( children: [ Expanded( child: TextField( controller: _email, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration( labelText: "User email", border: OutlineInputBorder(), isDense: true, ), ), ), const SizedBox(width: 8), DropdownButton( value: _permission, onChanged: (v) => setState(() => _permission = v ?? "read"), items: const [ DropdownMenuItem(value: "read", child: Text("Read")), DropdownMenuItem(value: "write", child: Text("Write")), ], ), ], ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton( onPressed: _submitting ? null : _add, child: Text(_submitting ? "Sharing…" : "Share"), ), ), const SizedBox(height: 16), const Text("People with access", style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), FutureBuilder>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(12), child: Center(child: CircularProgressIndicator()), ); } final shares = snap.data ?? []; if (shares.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Text("Not shared with anyone yet.", style: TextStyle(color: Colors.grey)), ); } return Column( children: shares .map((s) => ListTile( contentPadding: EdgeInsets.zero, dense: true, title: Text(s.label), subtitle: s.name.isNotEmpty ? Text(s.email) : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ DropdownButton( value: s.permission, underline: const SizedBox.shrink(), onChanged: (v) => v == null ? null : _setPermission(s, v), items: const [ DropdownMenuItem(value: "read", child: Text("Read")), DropdownMenuItem(value: "write", child: Text("Write")), ], ), IconButton( tooltip: "Remove", icon: const Icon(Icons.close, size: 18), onPressed: () => _remove(s), ), ], ), )) .toList(), ); }, ), ], ), ); } } /// Type-to-confirm dialog for deleting a car. The delete button is enabled only /// once the user types the car's exact name — guarding this cascade delete /// (which also removes all of the car's service records and parts). class _DeleteCarDialog extends StatefulWidget { final Car car; final int serviceCount; final int partCount; const _DeleteCarDialog({ required this.car, required this.serviceCount, required this.partCount, }); @override State<_DeleteCarDialog> createState() => _DeleteCarDialogState(); } class _DeleteCarDialogState extends State<_DeleteCarDialog> { final _confirm = TextEditingController(); bool _deleting = false; @override void dispose() { _confirm.dispose(); super.dispose(); } bool get _canDelete => _confirm.text.trim() == widget.car.name; String _plural(int n, String noun) => "$n $noun${n == 1 ? '' : 's'}"; @override Widget build(BuildContext context) { return AlertDialog( title: const Text("Delete this car?", style: TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w700)), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "This permanently deletes ${widget.car.name} and all of its " "${_plural(widget.serviceCount, 'service record')} and " "${_plural(widget.partCount, 'part')}. This cannot be undone.", ), const SizedBox(height: 16), Text("Type “${widget.car.name}” to confirm", style: const TextStyle(fontSize: 13, color: Colors.grey)), const SizedBox(height: 6), TextField( controller: _confirm, autofocus: true, decoration: InputDecoration(hintText: widget.car.name, border: const OutlineInputBorder()), onChanged: (_) => setState(() {}), ), ], ), actions: [ TextButton( onPressed: _deleting ? null : () => Navigator.pop(context, false), child: const Text("Cancel"), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: const Color(0xFFDC2626)), onPressed: (!_canDelete || _deleting) ? null : () { setState(() => _deleting = true); Navigator.pop(context, true); }, child: Text(_deleting ? "Deleting…" : "Delete permanently"), ), ], ); } } /// Bottom sheet for adding or editing a service record. class _ServiceSheet extends StatefulWidget { final String carId; final Car car; final ServiceRecord? record; // null => create const _ServiceSheet({required this.carId, required this.car, this.record}); @override State<_ServiceSheet> createState() => _ServiceSheetState(); } class _ServiceSheetState extends State<_ServiceSheet> { late DateTime _date; late final TextEditingController _km; late final TextEditingController _notes; late bool _oil, _engine, _cabin; bool _saving = false; String? _error; bool get _isEdit => widget.record != null; @override void initState() { super.initState(); final r = widget.record; _date = r?.date ?? DateTime.now(); _km = TextEditingController(text: (r != null && r.km > 0) ? "${r.km}" : ""); _notes = TextEditingController(text: r?.notes ?? ""); _oil = r?.changedOil ?? true; _engine = r?.changedEngineAirFilter ?? false; _cabin = r?.changedCabinAirFilter ?? false; } @override void dispose() { _km.dispose(); _notes.dispose(); super.dispose(); } Future _save() async { setState(() { _saving = true; _error = null; }); final payload = { "car": widget.carId, "date": _date.toUtc().toIso8601String(), "km": int.tryParse(_km.text.trim()) ?? 0, "changedOil": _oil, "changedEngineAirFilter": _engine, "changedCabinAirFilter": _cabin, "notes": _notes.text.trim(), }; try { if (_isEdit) { await apiClient.updateService(widget.record!.id, payload); } else { await apiClient.createService(payload); } if (mounted) Navigator.pop(context, true); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_isEdit ? "Edit service record" : "Add service record", 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)), ), Row( children: [ Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.calendar_today, size: 16), label: Text(formatDate(_date)), onPressed: () async { final picked = await showDatePicker( context: context, initialDate: _date, firstDate: DateTime(2000), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (picked != null) setState(() => _date = picked); }, ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: _km, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: "Odometer (km)", border: OutlineInputBorder()), ), ), ], ), const SizedBox(height: 8), CheckboxListTile( value: _oil, onChanged: (v) => setState(() => _oil = v ?? false), title: const Text("Oil & oil filter"), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: _engine, onChanged: (v) => setState(() => _engine = v ?? false), title: const Text("Engine air filter"), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: _cabin, onChanged: (v) => setState(() => _cabin = v ?? false), title: const Text("Cabin air filter"), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), TextField( controller: _notes, decoration: const InputDecoration(labelText: "Notes", border: OutlineInputBorder()), ), const SizedBox(height: 8), Text( "Next service (+${widget.car.serviceIntervalDays}d / +${formatKm(widget.car.serviceIntervalKm)}) is computed automatically.", style: const TextStyle(color: Colors.grey, fontSize: 12), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add service")), ), ), ), ], ), ); } } /// Bottom sheet for adding or editing a part. class _PartSheet extends StatefulWidget { final String carId; final Part? part; // null => create const _PartSheet({required this.carId, this.part}); @override State<_PartSheet> createState() => _PartSheetState(); } class _PartSheetState extends State<_PartSheet> { late final TextEditingController _name; late final TextEditingController _partNumber; bool _saving = false; String? _error; bool get _isEdit => widget.part != null; @override void initState() { super.initState(); _name = TextEditingController(text: widget.part?.name ?? ""); _partNumber = TextEditingController(text: widget.part?.partNumber ?? ""); } @override void dispose() { _name.dispose(); _partNumber.dispose(); super.dispose(); } Future _save() async { final name = _name.text.trim(); if (name.isEmpty) { setState(() => _error = "Part name is required."); return; } setState(() { _saving = true; _error = null; }); final payload = { "car": widget.carId, "name": name, "partNumber": _partNumber.text.trim(), }; try { if (_isEdit) { await apiClient.updatePart(widget.part!.id, payload); } else { await apiClient.createPart(payload); } if (mounted) Navigator.pop(context, true); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_isEdit ? "Edit part" : "Add part", 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)), ), TextField( controller: _name, textCapitalization: TextCapitalization.words, decoration: const InputDecoration(labelText: "Part name *", border: OutlineInputBorder()), ), const SizedBox(height: 8), TextField( controller: _partNumber, decoration: const InputDecoration(labelText: "Part number", border: OutlineInputBorder()), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add part")), ), ), ), ], ), ); } }