Fix fingerprint: adaptive auth screen (login/register by state), auto-prompt biometric on open, optimistic switch, real error messages

This commit is contained in:
cania
2026-08-13 09:52:03 +02:00
parent 0fd595d8a6
commit 6a9c4da0c7
3 changed files with 108 additions and 58 deletions

View File

@@ -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'),
),
],
),

View File

@@ -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 ----------

View File

@@ -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(