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>
112 lines
3.7 KiB
Dart
112 lines
3.7 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "../biometric.dart";
|
|
import "../i18n.dart";
|
|
import "../main.dart";
|
|
import "../theme.dart";
|
|
|
|
/// Shown when the app is authenticated but locked (biometric login is enabled
|
|
/// and the app was just launched or resumed). The session token is still valid;
|
|
/// a successful biometric check simply reveals the app. "Use password" drops the
|
|
/// session and returns to the login screen.
|
|
class LockScreen extends StatefulWidget {
|
|
const LockScreen({super.key});
|
|
@override
|
|
State<LockScreen> createState() => _LockScreenState();
|
|
}
|
|
|
|
class _LockScreenState extends State<LockScreen> {
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Prompt once as soon as the lock screen appears.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
|
|
}
|
|
|
|
Future<void> _unlock() async {
|
|
if (_busy) return;
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final creds = await biometricAuth.unlock(reason: t("lock.reason"));
|
|
if (creds == null) {
|
|
// Cancelled — leave locked; the buttons let them retry or use password.
|
|
if (mounted) setState(() => _busy = false);
|
|
return;
|
|
}
|
|
// Token from a previous session is still held by ApiClient; just reveal.
|
|
authService.unlock();
|
|
} on BiometricException catch (e) {
|
|
if (mounted) setState(() => _error = e.message);
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final u = authService.user;
|
|
final name = u == null ? null : (u.name.isNotEmpty ? u.name : u.email);
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: DriverVault.brand700,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Icon(Icons.lock_outline, color: Colors.white, size: 32),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(t("lock.title"),
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)),
|
|
if (name != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(name, style: TextStyle(color: DriverVault.muted(context))),
|
|
),
|
|
const SizedBox(height: 24),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text(_error!,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: DriverVault.danger)),
|
|
),
|
|
SizedBox(
|
|
width: 260,
|
|
child: FilledButton.icon(
|
|
onPressed: _busy ? null : _unlock,
|
|
icon: const Icon(Icons.fingerprint),
|
|
label: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: Text(_busy ? t("lock.unlocking") : t("lock.unlock")),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextButton(
|
|
onPressed: _busy ? null : () => authService.logout(),
|
|
child: Text(t("lock.usePassword")),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|