From 6a9c4da0c7da24fcd7a0f9fefff87592f1a76caf Mon Sep 17 00:00:00 2001 From: cania Date: Thu, 13 Aug 2026 09:52:03 +0200 Subject: [PATCH] Fix fingerprint: adaptive auth screen (login/register by state), auto-prompt biometric on open, optimistic switch, real error messages --- lib/auth_screen.dart | 51 ++++++++++++++++++++++----------- lib/auth_service.dart | 61 ++++++++++++++++++++++------------------ lib/settings_screen.dart | 54 ++++++++++++++++++++++++++--------- 3 files changed, 108 insertions(+), 58 deletions(-) diff --git a/lib/auth_screen.dart b/lib/auth_screen.dart index 7dc23dc..1b6c6ca 100644 --- a/lib/auth_screen.dart +++ b/lib/auth_screen.dart @@ -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 { @override void initState() { super.initState(); + _initMode(); + } + + /// Decide login vs register based on whether an account exists. + Future _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 _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 _trySilentAuth() async { final timeout = await widget.auth.getTimeoutMinutes(); @@ -51,7 +81,7 @@ class _AuthScreenState extends State { ); } - Future _submitEmail() async { + Future _submit() async { setState(() => _busy = true); final email = _emailCtrl.text.trim(); final pass = _passCtrl.text; @@ -113,20 +143,9 @@ class _AuthScreenState extends State { 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'), ), ], ), diff --git a/lib/auth_service.dart b/lib/auth_service.dart index c293d43..c905072 100644 --- a/lib/auth_service.dart +++ b/lib/auth_service.dart @@ -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 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 ---------- diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index e40b3e6..d41dae2 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -19,6 +19,7 @@ class _SettingsScreenState extends State { 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 { 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 _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 _setTimeout(int? value) async { @@ -121,12 +140,19 @@ class _SettingsScreenState extends State { ListTile( title: const Text('Timeout (tanpa aktivitas)'), subtitle: Text('Logout otomatis setelah: ${_timeoutLabel(_timeout)}'), - trailing: DropdownButton( - value: _timeout, - items: _timeoutOptions - .map((m) => DropdownMenuItem(value: m, child: Text(_timeoutLabel(m)))) + trailing: PopupMenuButton( + 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 { 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(