1163 lines
38 KiB
Dart
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")),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|