Files
DriverVault/Phone App/lib/screens/car_detail_screen.dart
T
2026-07-06 08:50:52 +02:00

1163 lines
38 KiB
Dart

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<String, dynamic> _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<CarDetailScreen> createState() => _CarDetailScreenState();
}
class _CarDetailData {
final Car car;
final List<ServiceRecord> services;
final List<Part> parts;
_CarDetailData(this.car, this.services, this.parts);
}
class _CarDetailScreenState extends State<CarDetailScreen> {
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<ServiceRecord>,
results[2] as List<Part>,
);
}
void _reload() => setState(() => _future = _load());
Future<void> _addService(Car car) async {
final added = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _ServiceSheet(carId: car.id, car: car),
);
if (added == true) _reload();
}
Future<void> _editService(Car car, ServiceRecord record) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _ServiceSheet(carId: car.id, car: car, record: record),
);
if (saved == true) _reload();
}
Future<void> _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<void> _editCar(Car car) async {
final updated = await showModalBottomSheet<Car?>(
context: context,
isScrollControlled: true,
builder: (_) => CarFormSheet(car: car),
);
if (updated != null) _reload();
}
Future<void> _addPart(Car car) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _PartSheet(carId: car.id),
);
if (saved == true) _reload();
}
Future<void> _editPart(Car car, Part part) async {
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _PartSheet(carId: car.id, part: part),
);
if (saved == true) _reload();
}
Future<void> _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<bool> _confirm(String message) async {
final res = await showDialog<bool>(
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<void> _shareCar(Car car) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _ShareSheet(car: car),
);
}
Future<void> _editOdometer(Car car) async {
final controller = TextEditingController(text: car.currentKm > 0 ? "${car.currentKm}" : "");
final saved = await showDialog<bool>(
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<void> _deleteCar(Car car, int serviceCount, int partCount) async {
final confirmed = await showDialog<bool>(
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<Widget> 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<String, String> _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 = <Widget>[
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<String>(
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<List<CarShare>> _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<void> _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<void> _setPermission(CarShare s, String value) async {
try {
await apiClient.addCarShare(widget.car.id, s.email, value);
_reload();
} catch (e) {
setState(() => _error = e.toString());
}
}
Future<void> _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<String>(
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<List<CarShare>>(
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<String>(
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<void> _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<void> _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")),
),
),
),
],
),
);
}
}