Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
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")),
],
),
),
),
],
);
}
}