Files
DriverVault/Phone App/lib/screens/login_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

386 lines
13 KiB
Dart

import "package:flutter/material.dart";
import "../api.dart";
import "../biometric.dart";
import "../config.dart";
import "../i18n.dart";
import "../main.dart";
import "../theme.dart";
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _server = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _loading = false;
bool _showPassword = false;
String? _error;
String? _serverSaved;
// Biometric ("Face recognition" / "Fingerprint") sign-in state.
bool _bioAvailable = false;
bool _bioEnabled = false;
bool _hasFace = false;
bool _hasFingerprint = false;
String? _bioEmail;
bool _autoPrompted = false;
@override
void initState() {
super.initState();
apiClient.serverOverride().then((v) {
if (mounted) _server.text = v;
});
_initBiometrics();
}
Future<void> _initBiometrics() async {
final available = await biometricAuth.isAvailable();
final email = await biometricAuth.enabledEmail();
final face = available && await biometricAuth.hasFace();
final finger = available && await biometricAuth.hasFingerprint();
if (!mounted) return;
setState(() {
_bioAvailable = available;
_bioEnabled = email != null;
_bioEmail = email;
_hasFace = face;
_hasFingerprint = finger;
});
// If biometric login is set up, offer it immediately (once) on screen load.
if (_bioEnabled && _bioAvailable && !_autoPrompted) {
_autoPrompted = true;
_biometricLogin();
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_server.dispose();
super.dispose();
}
Future<void> _saveServer() async {
await apiClient.setServerUrl(_server.text);
final current = await apiClient.serverOverride();
if (!mounted) return;
setState(() {
_server.text = current;
_serverSaved = t("login.savedNote");
});
}
Future<void> _resetServer() async {
await apiClient.setServerUrl("");
if (!mounted) return;
setState(() {
_server.clear();
_serverSaved = t("login.resetNote");
});
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
final email = _email.text.trim();
final password = _password.text;
try {
await authService.login(email, password);
// Pull the full profile so saved appearance prefs (theme/font/date) apply.
try {
appSettings.applyFromProfile(await apiClient.getMe());
} catch (_) {}
// Offer to remember these (known-good) credentials for biometric login.
await _maybeOfferBiometricEnrollment(email, password);
// The app root rebuilds to the dashboard via the auth listener.
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// After a successful password login, if the device supports biometrics and
/// they aren't set up yet, ask whether to enable biometric sign-in.
Future<void> _maybeOfferBiometricEnrollment(String email, String password) async {
if (!_bioAvailable || _bioEnabled || !mounted) return;
final method = _hasFace && !_hasFingerprint
? t("login.methodFace")
: _hasFingerprint && !_hasFace
? t("login.methodFingerprint")
: t("login.methodBiometrics");
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t("login.enrollTitle")),
content: Text(t("login.enrollBody", params: {"method": method})),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("login.enrollNotNow"))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("login.enrollEnable"))),
],
),
);
if (ok == true) {
await biometricAuth.enable(email, password);
}
}
Future<void> _biometricLogin() async {
setState(() {
_loading = true;
_error = null;
});
try {
final creds = await biometricAuth.unlock(
reason: _bioEmail != null
? t("login.bioReasonSignInAs", params: {"email": _bioEmail})
: t("login.bioReasonSignIn"),
);
if (creds == null) {
// User cancelled the prompt, or nothing is stored.
if (mounted) setState(() => _loading = false);
return;
}
await authService.login(creds.$1, creds.$2);
try {
appSettings.applyFromProfile(await apiClient.getMe());
} catch (_) {}
} on BiometricException catch (e) {
if (mounted) setState(() => _error = e.message);
} on ApiException catch (e) {
// Stored credentials no longer work (e.g. password changed) — turn off
// biometric login and fall back to the password form.
if (e.status == 400 || e.status == 401) {
await biometricAuth.disable();
if (mounted) {
setState(() {
_bioEnabled = false;
_error = t("login.savedInvalid");
});
}
} else if (mounted) {
setState(() => _error = e.message);
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// Biometric sign-in buttons, shown only once biometric login is set up on
/// this device. Labels reflect the enrolled biometric kind(s).
Widget _buildBiometricSection() {
if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink();
final buttons = <Widget>[];
Widget bioButton(IconData icon, String label) => Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _loading ? null : _biometricLogin,
icon: Icon(icon),
label: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(label),
),
),
),
);
if (_hasFace) buttons.add(bioButton(Icons.face, t("login.signInWithFace")));
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, t("login.signInWithFingerprint")));
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, t("login.signInWithBiometrics")));
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(t("login.or"), style: const TextStyle(color: Colors.grey, fontSize: 12)),
),
const Expanded(child: Divider()),
],
),
),
...buttons,
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DriverVaultLogo(markSize: 40, fontSize: 30),
const SizedBox(height: 10),
Text(t("login.tagline"),
style: TextStyle(color: DriverVault.muted(context))),
const SizedBox(height: 24),
Form(
key: _formKey,
child: Column(
children: [
if (_error != null)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
obscureText: !_showPassword,
decoration: InputDecoration(
labelText: t("login.password"),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
onPressed: () => setState(() => _showPassword = !_showPassword),
),
),
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_loading ? t("login.submitting") : t("login.submit")),
),
),
),
],
),
),
_buildBiometricSection(),
const SizedBox(height: 8),
_ServerSettings(
controller: _server,
savedNote: _serverSaved,
onSave: _saveServer,
onReset: _resetServer,
),
],
),
),
),
),
);
}
}
/// Collapsible "Server settings" on the login screen: an editable API server URL
/// override (blank = use the compile-time default). Mirrors the web app.
class _ServerSettings extends StatefulWidget {
final TextEditingController controller;
final String? savedNote;
final Future<void> Function() onSave;
final Future<void> Function() onReset;
const _ServerSettings({
required this.controller,
required this.savedNote,
required this.onSave,
required this.onReset,
});
@override
State<_ServerSettings> createState() => _ServerSettingsState();
}
class _ServerSettingsState extends State<_ServerSettings> {
bool _open = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _open = !_open),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t("login.serverSettings"),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)),
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey),
],
),
),
),
if (_open) ...[
TextField(
controller: widget.controller,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: t("login.apiServerUrl"),
hintText: kDefaultApiBase,
border: const OutlineInputBorder(),
isDense: true,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("login.leaveBlank"),
style: const TextStyle(color: Colors.grey, fontSize: 11)),
),
const SizedBox(height: 4),
Row(
children: [
OutlinedButton(onPressed: widget.onSave, child: Text(t("common.save"))),
const SizedBox(width: 8),
TextButton(onPressed: widget.onReset, child: Text(t("login.reset"))),
if (widget.savedNote != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(widget.savedNote!,
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
),
],
),
],
],
);
}
}