356 lines
12 KiB
Dart
356 lines
12 KiB
Dart
import "package:flutter/material.dart";
|
||
|
||
import "../main.dart";
|
||
import "../models.dart";
|
||
import "../format.dart";
|
||
import "../theme.dart";
|
||
import "car_detail_screen.dart";
|
||
import "car_form_sheet.dart";
|
||
|
||
class _CarRow {
|
||
final Car car;
|
||
final ServiceRecord? latest;
|
||
final int count;
|
||
_CarRow(this.car, this.latest, this.count);
|
||
}
|
||
|
||
class DashboardScreen extends StatefulWidget {
|
||
const DashboardScreen({super.key});
|
||
@override
|
||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||
}
|
||
|
||
class _DashboardScreenState extends State<DashboardScreen> {
|
||
late Future<List<_CarRow>> _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_future = _load();
|
||
}
|
||
|
||
Future<List<_CarRow>> _load() async {
|
||
final cars = await apiClient.listCars();
|
||
final rows = <_CarRow>[];
|
||
for (final c in cars) {
|
||
final services = await apiClient.listCarServices(c.id);
|
||
rows.add(_CarRow(c, services.isNotEmpty ? services.first : null, services.length));
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
final f = _load();
|
||
setState(() => _future = f);
|
||
await f;
|
||
}
|
||
|
||
Future<void> _addCar() async {
|
||
final created = await showModalBottomSheet<Car?>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (_) => const CarFormSheet(),
|
||
);
|
||
if (created != null && mounted) {
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: created.id)),
|
||
);
|
||
await _refresh();
|
||
}
|
||
}
|
||
|
||
void _toggleTheme() {
|
||
final next = Theme.of(context).brightness == Brightness.dark ? "light" : "dark";
|
||
appSettings.patch(theme: next); // optimistic + persisted locally
|
||
apiClient.updateMe({"theme": next}).then((_) {}).catchError((_) {});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dark = DriverVault.isDark(context);
|
||
return Scaffold(
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
onPressed: _addCar,
|
||
icon: const Icon(Icons.add),
|
||
label: const Text("Add car"),
|
||
),
|
||
body: SafeArea(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Home header — eyebrow + title on the left, quick actions right.
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 14, 16, 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text("YOUR CARS",
|
||
style: DriverVault.mono(context,
|
||
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
|
||
.copyWith(letterSpacing: 2.2)),
|
||
const SizedBox(height: 2),
|
||
const Text("Garage",
|
||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
|
||
],
|
||
),
|
||
),
|
||
_HeaderButton(
|
||
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||
tooltip: dark ? "Light mode" : "Dark mode",
|
||
onTap: _toggleTheme,
|
||
),
|
||
const SizedBox(width: 8),
|
||
_HeaderButton(
|
||
icon: Icons.logout,
|
||
tooltip: "Log out",
|
||
onTap: () => authService.logout(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: RefreshIndicator(
|
||
onRefresh: _refresh,
|
||
child: FutureBuilder<List<_CarRow>>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return _ErrorView(message: "${snap.error}", onRetry: _refresh);
|
||
}
|
||
final rows = snap.data ?? [];
|
||
if (rows.isEmpty) {
|
||
return ListView(children: const [
|
||
SizedBox(height: 80),
|
||
Center(child: Text("No cars yet.")),
|
||
]);
|
||
}
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
||
itemCount: rows.length,
|
||
itemBuilder: (context, i) => _CarCard(row: rows[i], onChanged: _refresh),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A bordered 40×40 square icon button used in the home header (mockup style).
|
||
class _HeaderButton extends StatelessWidget {
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final VoidCallback onTap;
|
||
const _HeaderButton({required this.icon, required this.tooltip, required this.onTap});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Tooltip(
|
||
message: tooltip,
|
||
child: Material(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||
border: Border.all(color: DriverVault.isDark(context) ? DriverVault.darkBorderStrong : DriverVault.ink200),
|
||
),
|
||
child: Icon(icon, size: 19, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CarCard extends StatelessWidget {
|
||
final _CarRow row;
|
||
final Future<void> Function() onChanged;
|
||
const _CarCard({required this.row, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final status = serviceStatus(row.latest, row.car);
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100),
|
||
),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(16),
|
||
onTap: () async {
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => CarDetailScreen(carId: row.car.id)),
|
||
);
|
||
await onChanged();
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(row.car.name,
|
||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: -0.3)),
|
||
if (row.car.subtitle.isNotEmpty)
|
||
Text(row.car.subtitle,
|
||
style: TextStyle(color: DriverVault.muted(context), fontSize: 12)),
|
||
],
|
||
),
|
||
),
|
||
_Badge(status: status),
|
||
],
|
||
),
|
||
if (!row.car.isOwner) ...[
|
||
const SizedBox(height: 8),
|
||
_SharedChip(readOnly: row.car.isReadOnly),
|
||
],
|
||
..._serviceLife(context, status),
|
||
const SizedBox(height: 12),
|
||
_kv(context, "Last service", formatDate(row.latest?.date)),
|
||
_kv(context, "Current odometer", formatKm(row.car.currentKm)),
|
||
_kv(context, "Next due", formatDate(row.latest?.nextServiceDate)),
|
||
_kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)),
|
||
const SizedBox(height: 6),
|
||
Text("${row.count} service record${row.count == 1 ? '' : 's'}",
|
||
style: TextStyle(color: DriverVault.muted(context), fontSize: 11)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _kv(BuildContext context, String k, String v) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(k, style: TextStyle(color: DriverVault.muted(context), fontSize: 13)),
|
||
Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500,
|
||
color: Theme.of(context).colorScheme.onSurface)),
|
||
],
|
||
),
|
||
);
|
||
|
||
/// Service-life bar: fraction of the km interval used up (echoes the mockup's
|
||
/// battery bar). Returns [] when there isn't enough data to compute it.
|
||
List<Widget> _serviceLife(BuildContext context, Status status) {
|
||
final interval = row.car.serviceIntervalKm;
|
||
final nextKm = row.latest?.nextServiceKm ?? 0;
|
||
final currentKm = row.car.currentKm;
|
||
if (interval <= 0 || nextKm <= 0 || currentKm <= 0) return const [];
|
||
final remaining = nextKm - currentKm;
|
||
final pct = (100 * (1 - remaining / interval)).clamp(0, 100).round();
|
||
final tone = status.fg(DriverVault.isDark(context));
|
||
return [
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text("SERVICE LIFE",
|
||
style: DriverVault.mono(context, size: 9, weight: FontWeight.w500,
|
||
color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)),
|
||
Text("$pct%",
|
||
style: DriverVault.mono(context, size: 11, weight: FontWeight.w500,
|
||
color: Theme.of(context).colorScheme.onSurface)),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(99),
|
||
child: LinearProgressIndicator(
|
||
value: pct / 100,
|
||
minHeight: 7,
|
||
backgroundColor: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink100,
|
||
valueColor: AlwaysStoppedAnimation<Color>(tone),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
}
|
||
|
||
class _SharedChip extends StatelessWidget {
|
||
final bool readOnly;
|
||
const _SharedChip({required this.readOnly});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: DriverVault.brandTint(context),
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(
|
||
readOnly ? "Shared · read-only" : "Shared",
|
||
style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Badge extends StatelessWidget {
|
||
final Status status;
|
||
const _Badge({required this.status});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)),
|
||
child: Text(status.label,
|
||
style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontSize: 12, fontWeight: FontWeight.w600)),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorView extends StatelessWidget {
|
||
final String message;
|
||
final Future<void> Function() onRetry;
|
||
const _ErrorView({required this.message, required this.onRetry});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListView(
|
||
children: [
|
||
const SizedBox(height: 80),
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
children: [
|
||
const Icon(Icons.error_outline, color: DriverVault.danger, size: 40),
|
||
const SizedBox(height: 12),
|
||
Text(message, textAlign: TextAlign.center),
|
||
const SizedBox(height: 12),
|
||
FilledButton(onPressed: onRetry, child: const Text("Retry")),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|