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/dashboard_screen.dart';
|
||||||
import 'package:login_vault_app/theme_provider.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.
|
/// If a valid session exists, skip straight to the Dashboard.
|
||||||
class AuthScreen extends StatefulWidget {
|
class AuthScreen extends StatefulWidget {
|
||||||
final AuthService auth;
|
final AuthService auth;
|
||||||
@@ -24,9 +24,39 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_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.
|
/// On open: if a valid session is present, go straight to Dashboard.
|
||||||
Future<void> _trySilentAuth() async {
|
Future<void> _trySilentAuth() async {
|
||||||
final timeout = await widget.auth.getTimeoutMinutes();
|
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);
|
setState(() => _busy = true);
|
||||||
final email = _emailCtrl.text.trim();
|
final email = _emailCtrl.text.trim();
|
||||||
final pass = _passCtrl.text;
|
final pass = _passCtrl.text;
|
||||||
@@ -113,20 +143,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_busy
|
_busy
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: Column(
|
: ElevatedButton(
|
||||||
children: [
|
onPressed: _submit,
|
||||||
ElevatedButton(
|
child: Text(_isLogin ? 'Masuk' : 'Daftar'),
|
||||||
onPressed: _submitEmail,
|
|
||||||
child: Text(_isLogin ? 'Masuk' : 'Daftar'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
setState(() => _isLogin = !_isLogin),
|
|
||||||
child: Text(_isLogin
|
|
||||||
? 'Belum punya akun? Daftar'
|
|
||||||
: 'Sudah punya akun? Masuk'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -50,39 +53,41 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt the OS biometric dialog. Returns true on success.
|
/// Prompt the OS biometric dialog.
|
||||||
/// [errorOut] receives a human-readable reason when it fails.
|
/// Returns (success, errorMessage). errorMessage is null on success.
|
||||||
Future<bool> authenticateWithBiometrics({String? errorOut}) async {
|
Future<(bool, String?)> authenticateWithBiometrics() async {
|
||||||
try {
|
String? lastErr;
|
||||||
// Prefer strict biometric (fingerprint). If the device only has
|
// Prefer strict biometric (fingerprint). If that fails, fall back to
|
||||||
// weaker biometrics (face) or none enrolled, fall back to allowing
|
// device credentials (PIN/pattern) so the user isn't hard-blocked.
|
||||||
// device credentials so the user isn't hard-blocked.
|
for (final opt in [
|
||||||
bool ok = false;
|
const AuthenticationOptions(
|
||||||
|
biometricOnly: true, stickyAuth: true, sensitiveTransaction: true),
|
||||||
|
const AuthenticationOptions(
|
||||||
|
biometricOnly: false, stickyAuth: true, sensitiveTransaction: true),
|
||||||
|
]) {
|
||||||
try {
|
try {
|
||||||
ok = await _localAuth.authenticate(
|
final ok = await _localAuth.authenticate(
|
||||||
localizedReason: 'Gunakan biometrik untuk membuka Login Vault',
|
localizedReason: 'Gunakan biometrik untuk membuka Login Vault',
|
||||||
options: const AuthenticationOptions(
|
options: opt,
|
||||||
biometricOnly: true,
|
|
||||||
stickyAuth: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
if (ok) return (true, null);
|
||||||
ok = false;
|
} 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 ----------
|
// ---------- Session ----------
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final _oldCtrl = TextEditingController();
|
final _oldCtrl = TextEditingController();
|
||||||
final _newCtrl = TextEditingController();
|
final _newCtrl = TextEditingController();
|
||||||
String? _msg;
|
String? _msg;
|
||||||
|
Color _msgColor = Colors.green;
|
||||||
bool _biometricOn = false;
|
bool _biometricOn = false;
|
||||||
bool _biometricAvailable = false;
|
bool _biometricAvailable = false;
|
||||||
int _timeout = 3;
|
int _timeout = 3;
|
||||||
@@ -48,35 +49,53 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final oldP = _oldCtrl.text;
|
final oldP = _oldCtrl.text;
|
||||||
final newP = _newCtrl.text;
|
final newP = _newCtrl.text;
|
||||||
if (newP.isEmpty) {
|
if (newP.isEmpty) {
|
||||||
setState(() => _msg = 'Password baru wajib diisi');
|
setState(() {
|
||||||
|
_msg = 'Password baru wajib diisi';
|
||||||
|
_msgColor = Colors.red;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final stored = await widget.auth.getAppPassword();
|
final stored = await widget.auth.getAppPassword();
|
||||||
if (stored != null && stored != '__google__' && stored != oldP) {
|
if (stored != null && stored != '__google__' && stored != oldP) {
|
||||||
setState(() => _msg = 'Password lama salah');
|
setState(() {
|
||||||
|
_msg = 'Password lama salah';
|
||||||
|
_msgColor = Colors.red;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await widget.auth.changePassword(newP);
|
await widget.auth.changePassword(newP);
|
||||||
setState(() => _msg = 'Password aplikasi berhasil diubah');
|
setState(() {
|
||||||
|
_msg = 'Password aplikasi berhasil diubah';
|
||||||
|
_msgColor = Colors.green;
|
||||||
|
});
|
||||||
_oldCtrl.clear();
|
_oldCtrl.clear();
|
||||||
_newCtrl.clear();
|
_newCtrl.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleBiometric(bool value) async {
|
Future<void> _toggleBiometric(bool value) async {
|
||||||
if (value && !_biometricAvailable) {
|
if (value && !_biometricAvailable) {
|
||||||
setState(() => _msg = 'Perangkat tidak mendukung biometrik');
|
setState(() {
|
||||||
|
_msg = 'Perangkat tidak mendukung biometrik';
|
||||||
|
_msgColor = Colors.red;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// optimistic: move switch immediately so it feels responsive
|
||||||
|
if (mounted) setState(() => _biometricOn = value);
|
||||||
if (value) {
|
if (value) {
|
||||||
String? err;
|
final (ok, err) = await widget.auth.authenticateWithBiometrics();
|
||||||
final ok = await widget.auth.authenticateWithBiometrics(errorOut: err);
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
setState(() => _msg = 'Verifikasi biometrik gagal: ${err ?? "dibatalkan"}');
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_biometricOn = false;
|
||||||
|
_msg = 'Verifikasi biometrik gagal: ${err ?? "dibatalkan"}';
|
||||||
|
_msgColor = Colors.red;
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await widget.auth.setBiometricEnabled(value);
|
await widget.auth.setBiometricEnabled(value);
|
||||||
if (mounted) setState(() => _biometricOn = value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setTimeout(int? value) async {
|
Future<void> _setTimeout(int? value) async {
|
||||||
@@ -121,12 +140,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Timeout (tanpa aktivitas)'),
|
title: const Text('Timeout (tanpa aktivitas)'),
|
||||||
subtitle: Text('Logout otomatis setelah: ${_timeoutLabel(_timeout)}'),
|
subtitle: Text('Logout otomatis setelah: ${_timeoutLabel(_timeout)}'),
|
||||||
trailing: DropdownButton<int>(
|
trailing: PopupMenuButton<int>(
|
||||||
value: _timeout,
|
initialValue: _timeoutOptions.contains(_timeout) ? _timeout : _timeoutOptions.first,
|
||||||
items: _timeoutOptions
|
onSelected: _setTimeout,
|
||||||
.map((m) => DropdownMenuItem(value: m, child: Text(_timeoutLabel(m))))
|
itemBuilder: (ctx) => _timeoutOptions
|
||||||
|
.map((m) => PopupMenuItem(value: m, child: Text(_timeoutLabel(m))))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: _setTimeout,
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(_timeoutLabel(_timeout)),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -158,7 +184,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (_msg != null)
|
if (_msg != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(_msg!, style: const TextStyle(color: Colors.green)),
|
child: Text(_msg!, style: TextStyle(color: _msgColor)),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
Reference in New Issue
Block a user