384 lines
13 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|