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>
118 lines
4.1 KiB
Dart
118 lines
4.1 KiB
Dart
import "package:flutter/material.dart";
|
|
import "package:intl/date_symbol_data_local.dart";
|
|
|
|
import "api.dart";
|
|
import "app_settings.dart";
|
|
import "auth.dart";
|
|
import "biometric.dart";
|
|
import "i18n.dart";
|
|
import "theme.dart";
|
|
import "screens/lock_screen.dart";
|
|
import "screens/login_screen.dart";
|
|
import "screens/root_shell.dart";
|
|
|
|
// Simple app-wide singletons (small app — no DI framework needed).
|
|
final apiClient = ApiClient();
|
|
final authService = AuthService(apiClient);
|
|
final appSettings = AppSettings();
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
// Month and day names are rendered in the user's chosen language, so every
|
|
// locale's date symbols have to be loaded before the first formatDate call —
|
|
// DateFormat throws on an uninitialized locale rather than falling back.
|
|
await initializeDateFormatting();
|
|
// Load the UI translation files before the first frame so t() is ready.
|
|
await loadTranslations();
|
|
await apiClient.loadServerUrl();
|
|
await appSettings.loadFromStorage();
|
|
await authService.loadFromStorage();
|
|
// Prime the biometric-enabled cache; if a session survived from a previous
|
|
// run and biometric login is on, start locked so the app requires an unlock
|
|
// instead of jumping straight to the dashboard.
|
|
final bioEnabled = await biometricAuth.isEnabled();
|
|
if (authService.isAuthenticated && bioEnabled) {
|
|
authService.lock();
|
|
}
|
|
// If a token survived from a previous run, refresh the profile so the saved
|
|
// appearance prefs (theme/font/date format) apply on boot.
|
|
if (authService.isAuthenticated) {
|
|
apiClient.getMe().then((p) => appSettings.applyFromProfile(p)).catchError((_) {});
|
|
}
|
|
runApp(const CarControlApp());
|
|
}
|
|
|
|
class CarControlApp extends StatefulWidget {
|
|
const CarControlApp({super.key});
|
|
@override
|
|
State<CarControlApp> createState() => _CarControlAppState();
|
|
}
|
|
|
|
class _CarControlAppState extends State<CarControlApp> with WidgetsBindingObserver {
|
|
// Grace period: a quick app-switch (returning within this window) does NOT
|
|
// re-lock; staying in the background longer does.
|
|
static const _lockGrace = Duration(seconds: 30);
|
|
DateTime? _backgroundedAt;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// Re-lock when the app returns from the background, but only if it was away
|
|
// longer than the grace period — so quick app-switches don't re-lock.
|
|
// Handle only 'paused'/'resumed' (not 'inactive', which also fires
|
|
// transiently while the OS biometric prompt is showing).
|
|
if (state == AppLifecycleState.paused) {
|
|
_backgroundedAt = DateTime.now();
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
final since = _backgroundedAt;
|
|
_backgroundedAt = null;
|
|
if (since != null &&
|
|
DateTime.now().difference(since) > _lockGrace &&
|
|
authService.isAuthenticated &&
|
|
biometricAuth.enabledCached) {
|
|
authService.lock();
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: appSettings,
|
|
builder: (context, _) => MaterialApp(
|
|
title: "DriverVault",
|
|
debugShowCheckedModeBanner: false,
|
|
theme: DriverVault.theme(Brightness.light),
|
|
darkTheme: DriverVault.theme(Brightness.dark),
|
|
themeMode: appSettings.themeMode,
|
|
// Apply the user's font-size preference app-wide.
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(
|
|
textScaler: TextScaler.linear(appSettings.textScale),
|
|
),
|
|
child: child!,
|
|
),
|
|
home: ListenableBuilder(
|
|
listenable: authService,
|
|
builder: (context, _) {
|
|
if (!authService.isAuthenticated) return const LoginScreen();
|
|
if (authService.locked) return const LockScreen();
|
|
return const RootShell();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|