Files
DriverVault/Phone App/lib/screens/login_screen.dart
T
2026-07-06 08:50:52 +02:00

384 lines
13 KiB
Dart

import "package:flutter/material.dart";
import "../api.dart";
import "../biometric.dart";
import "../config.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 = "Saved ✓";
});
}
Future<void> _resetServer() async {
await apiClient.setServerUrl("");
if (!mounted) return;
setState(() {
_server.clear();
_serverSaved = "Reset ✓";
});
}
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
? "face recognition"
: _hasFingerprint && !_hasFace
? "your fingerprint"
: "biometrics";
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Enable biometric sign-in?"),
content: Text(
"Next time you can sign in with $method instead of typing your password."),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")),
],
),
);
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 ? "Sign in as $_bioEmail" : "Sign in to DriverVault",
);
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 = "Saved sign-in is no longer valid. Please sign in with your password.";
});
}
} 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, "Sign in with face recognition"));
if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint"));
if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics"));
return Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)),
),
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("Your car, on track.",
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: const InputDecoration(labelText: "Email", border: OutlineInputBorder()),
validator: (v) => (v == null || v.isEmpty) ? "Required" : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
obscureText: !_showPassword,
decoration: InputDecoration(
labelText: "Password",
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
tooltip: _showPassword ? "Hide password" : "Show password",
onPressed: () => setState(() => _showPassword = !_showPassword),
),
),
validator: (v) => (v == null || v.isEmpty) ? "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 ? "Signing in…" : "Sign in"),
),
),
),
],
),
),
_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: [
const Text("Server settings",
style: 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: const InputDecoration(
labelText: "API server URL",
hintText: kDefaultApiBase,
border: OutlineInputBorder(),
isDense: true,
),
),
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text("Leave blank to use the default.",
style: TextStyle(color: Colors.grey, fontSize: 11)),
),
const SizedBox(height: 4),
Row(
children: [
OutlinedButton(onPressed: widget.onSave, child: const Text("Save")),
const SizedBox(width: 8),
TextButton(onPressed: widget.onReset, child: const Text("Reset")),
if (widget.savedNote != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(widget.savedNote!,
style: const TextStyle(color: DriverVault.success, fontSize: 12)),
),
],
),
],
],
);
}
}