Initial commit
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user