Files
DriverVault/Phone App/lib/screens/dashboard_screen.dart
T
tajniak81andClaude Opus 4.8 b6bb6b1df0 Add a language-switch system with per-language files
Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:07:48 +02:00

357 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "package:flutter/material.dart";
import "../i18n.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: Text(t("dashboard.addCar")),
),
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(t("dashboard.eyebrow"),
style: DriverVault.mono(context,
size: 10, weight: FontWeight.w500, color: DriverVault.muted(context))
.copyWith(letterSpacing: 2.2)),
const SizedBox(height: 2),
Text(t("dashboard.title"),
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
],
),
),
_HeaderButton(
icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
tooltip: dark ? t("dashboard.lightMode") : t("dashboard.darkMode"),
onTap: _toggleTheme,
),
const SizedBox(width: 8),
_HeaderButton(
icon: Icons.logout,
tooltip: t("dashboard.logOut"),
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(t("dashboard.empty"))),
]);
}
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, t("dashboard.lastService"), formatDate(row.latest?.date)),
_kv(context, t("dashboard.currentOdometer"), formatKm(row.car.currentKm)),
_kv(context, t("dashboard.nextDue"), formatDate(row.latest?.nextServiceDate)),
_kv(context, t("dashboard.nextDueKm"), formatKm(row.latest?.nextServiceKm)),
const SizedBox(height: 6),
Text(t("dashboard.serviceRecords", n: row.count),
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(t("dashboard.serviceLife"),
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 ? t("dashboard.sharedReadOnly") : t("dashboard.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: Text(t("common.retry"))),
],
),
),
),
],
);
}
}