Fix fingerprint: adaptive auth screen (login/register by state), auto-prompt biometric on open, optimistic switch, real error messages
This commit is contained in:
@@ -4,7 +4,7 @@ import 'package:login_vault_app/auth_service.dart';
|
||||
import 'package:login_vault_app/dashboard_screen.dart';
|
||||
import 'package:login_vault_app/theme_provider.dart';
|
||||
|
||||
/// First screen: register, or (if registered) re-authenticate.
|
||||
/// First screen: register (if not yet), or re-authenticate (if registered).
|
||||
/// If a valid session exists, skip straight to the Dashboard.
|
||||
class AuthScreen extends StatefulWidget {
|
||||
final AuthService auth;
|
||||
@@ -24,9 +24,39 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initMode();
|
||||
}
|
||||
|
||||
/// Decide login vs register based on whether an account exists.
|
||||
Future<void> _initMode() async {
|
||||
final registered = await widget.auth.isRegistered();
|
||||
if (!mounted) return;
|
||||
setState(() => _isLogin = registered);
|
||||
// If already registered and biometric is enabled, prompt fingerprint
|
||||
// automatically (unless a valid session already skips to dashboard).
|
||||
if (registered && await widget.auth.isBiometricEnabled()) {
|
||||
final timeout = await widget.auth.getTimeoutMinutes();
|
||||
final valid = await widget.auth.hasValidSession(timeout);
|
||||
if (!valid) {
|
||||
_promptBiometricOnOpen();
|
||||
return; // skip silent-auth path; biometric handles entry
|
||||
}
|
||||
}
|
||||
_trySilentAuth();
|
||||
}
|
||||
|
||||
/// Auto-prompt fingerprint on open when enabled; on success go to dashboard
|
||||
/// (email may be empty for google-less accounts, fallback to stored email).
|
||||
Future<void> _promptBiometricOnOpen() async {
|
||||
final (ok, err) = await widget.auth.authenticateWithBiometrics();
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
final email = await widget.auth.getEmail() ?? '';
|
||||
_goToDashboard(email);
|
||||
}
|
||||
// on failure, the auth screen stays; user can tap Masuk manually
|
||||
}
|
||||
|
||||
/// On open: if a valid session is present, go straight to Dashboard.
|
||||
Future<void> _trySilentAuth() async {
|
||||
final timeout = await widget.auth.getTimeoutMinutes();
|
||||
@@ -51,7 +81,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitEmail() async {
|
||||
Future<void> _submit() async {
|
||||
setState(() => _busy = true);
|
||||
final email = _emailCtrl.text.trim();
|
||||
final pass = _passCtrl.text;
|
||||
@@ -113,20 +143,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 12),
|
||||
_busy
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _submitEmail,
|
||||
child: Text(_isLogin ? 'Masuk' : 'Daftar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _isLogin = !_isLogin),
|
||||
child: Text(_isLogin
|
||||
? 'Belum punya akun? Daftar'
|
||||
: 'Sudah punya akun? Masuk'),
|
||||
),
|
||||
],
|
||||
: ElevatedButton(
|
||||
onPressed: _submit,
|
||||
child: Text(_isLogin ? 'Masuk' : 'Daftar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -50,39 +53,41 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt the OS biometric dialog. Returns true on success.
|
||||
/// [errorOut] receives a human-readable reason when it fails.
|
||||
Future<bool> authenticateWithBiometrics({String? errorOut}) async {
|
||||
try {
|
||||
// Prefer strict biometric (fingerprint). If the device only has
|
||||
// weaker biometrics (face) or none enrolled, fall back to allowing
|
||||
// device credentials so the user isn't hard-blocked.
|
||||
bool ok = false;
|
||||
/// Prompt the OS biometric dialog.
|
||||
/// Returns (success, errorMessage). errorMessage is null on success.
|
||||
Future<(bool, String?)> authenticateWithBiometrics() async {
|
||||
String? lastErr;
|
||||
// Prefer strict biometric (fingerprint). If that fails, fall back to
|
||||
// device credentials (PIN/pattern) so the user isn't hard-blocked.
|
||||
for (final opt in [
|
||||
const AuthenticationOptions(
|
||||
biometricOnly: true, stickyAuth: true, sensitiveTransaction: true),
|
||||
const AuthenticationOptions(
|
||||
biometricOnly: false, stickyAuth: true, sensitiveTransaction: true),
|
||||
]) {
|
||||
try {
|
||||
ok = await _localAuth.authenticate(
|
||||
final ok = await _localAuth.authenticate(
|
||||
localizedReason: 'Gunakan biometrik untuk membuka Login Vault',
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: true,
|
||||
stickyAuth: true,
|
||||
),
|
||||
options: opt,
|
||||
);
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
if (ok) return (true, null);
|
||||
} on PlatformException catch (e) {
|
||||
lastErr = '${e.code}: ${e.message}';
|
||||
// user cancel / negative button -> stop retrying
|
||||
if (e.code == 'PasscodeNotSet' ||
|
||||
e.code == 'NotAvailable' ||
|
||||
e.code == 'NotEnrolled' ||
|
||||
e.code == 'LockedOut' ||
|
||||
e.code == 'UserCancel' ||
|
||||
e.code == 'Canceled' ||
|
||||
e.code == 'UserFallback') {
|
||||
return (false, lastErr);
|
||||
}
|
||||
} catch (e) {
|
||||
lastErr = e.toString();
|
||||
}
|
||||
if (!ok) {
|
||||
ok = await _localAuth.authenticate(
|
||||
localizedReason: 'Gunakan biometrik / kredensial perangkat',
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: false,
|
||||
stickyAuth: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
} catch (e) {
|
||||
if (errorOut != null) errorOut = e.toString();
|
||||
return false;
|
||||
}
|
||||
return (false, lastErr);
|
||||
}
|
||||
|
||||
// ---------- Session ----------
|
||||
|
||||
@@ -19,6 +19,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _oldCtrl = TextEditingController();
|
||||
final _newCtrl = TextEditingController();
|
||||
String? _msg;
|
||||
Color _msgColor = Colors.green;
|
||||
bool _biometricOn = false;
|
||||
bool _biometricAvailable = false;
|
||||
int _timeout = 3;
|
||||
@@ -48,35 +49,53 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final oldP = _oldCtrl.text;
|
||||
final newP = _newCtrl.text;
|
||||
if (newP.isEmpty) {
|
||||
setState(() => _msg = 'Password baru wajib diisi');
|
||||
setState(() {
|
||||
_msg = 'Password baru wajib diisi';
|
||||
_msgColor = Colors.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final stored = await widget.auth.getAppPassword();
|
||||
if (stored != null && stored != '__google__' && stored != oldP) {
|
||||
setState(() => _msg = 'Password lama salah');
|
||||
setState(() {
|
||||
_msg = 'Password lama salah';
|
||||
_msgColor = Colors.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
await widget.auth.changePassword(newP);
|
||||
setState(() => _msg = 'Password aplikasi berhasil diubah');
|
||||
setState(() {
|
||||
_msg = 'Password aplikasi berhasil diubah';
|
||||
_msgColor = Colors.green;
|
||||
});
|
||||
_oldCtrl.clear();
|
||||
_newCtrl.clear();
|
||||
}
|
||||
|
||||
Future<void> _toggleBiometric(bool value) async {
|
||||
if (value && !_biometricAvailable) {
|
||||
setState(() => _msg = 'Perangkat tidak mendukung biometrik');
|
||||
setState(() {
|
||||
_msg = 'Perangkat tidak mendukung biometrik';
|
||||
_msgColor = Colors.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// optimistic: move switch immediately so it feels responsive
|
||||
if (mounted) setState(() => _biometricOn = value);
|
||||
if (value) {
|
||||
String? err;
|
||||
final ok = await widget.auth.authenticateWithBiometrics(errorOut: err);
|
||||
final (ok, err) = await widget.auth.authenticateWithBiometrics();
|
||||
if (!ok) {
|
||||
setState(() => _msg = 'Verifikasi biometrik gagal: ${err ?? "dibatalkan"}');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_biometricOn = false;
|
||||
_msg = 'Verifikasi biometrik gagal: ${err ?? "dibatalkan"}';
|
||||
_msgColor = Colors.red;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await widget.auth.setBiometricEnabled(value);
|
||||
if (mounted) setState(() => _biometricOn = value);
|
||||
}
|
||||
|
||||
Future<void> _setTimeout(int? value) async {
|
||||
@@ -121,12 +140,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
title: const Text('Timeout (tanpa aktivitas)'),
|
||||
subtitle: Text('Logout otomatis setelah: ${_timeoutLabel(_timeout)}'),
|
||||
trailing: DropdownButton<int>(
|
||||
value: _timeout,
|
||||
items: _timeoutOptions
|
||||
.map((m) => DropdownMenuItem(value: m, child: Text(_timeoutLabel(m))))
|
||||
trailing: PopupMenuButton<int>(
|
||||
initialValue: _timeoutOptions.contains(_timeout) ? _timeout : _timeoutOptions.first,
|
||||
onSelected: _setTimeout,
|
||||
itemBuilder: (ctx) => _timeoutOptions
|
||||
.map((m) => PopupMenuItem(value: m, child: Text(_timeoutLabel(m))))
|
||||
.toList(),
|
||||
onChanged: _setTimeout,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_timeoutLabel(_timeout)),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
@@ -158,7 +184,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (_msg != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(_msg!, style: const TextStyle(color: Colors.green)),
|
||||
child: Text(_msg!, style: TextStyle(color: _msgColor)),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
||||
Reference in New Issue
Block a user