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