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 createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); final _server = TextEditingController(); final _formKey = GlobalKey(); 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 _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 _saveServer() async { await apiClient.setServerUrl(_server.text); final current = await apiClient.serverOverride(); if (!mounted) return; setState(() { _server.text = current; _serverSaved = "Saved βœ“"; }); } Future _resetServer() async { await apiClient.setServerUrl(""); if (!mounted) return; setState(() { _server.clear(); _serverSaved = "Reset βœ“"; }); } Future _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 _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( 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 _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 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 Function() onSave; final Future 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)), ), ], ), ], ], ); } }