Add fingerprint (local_auth) session + 3-min inactivity timeout (default), activity resets timer, settings: biometric toggle + timeout picker
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Internet permission required for Google Sign-In -->
|
<!-- Internet permission required for Google Sign-In -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- Biometric / fingerprint authentication -->
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||||
<queries>
|
<queries>
|
||||||
<!-- Used by Google Sign-In to query browser packages -->
|
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
@@ -31,8 +33,6 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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-launch screen: register with email+password, or with Google.
|
/// First screen: register, or (if registered) re-authenticate.
|
||||||
|
/// If a valid session exists, skip straight to the Dashboard.
|
||||||
class AuthScreen extends StatefulWidget {
|
class AuthScreen extends StatefulWidget {
|
||||||
final AuthService auth;
|
final AuthService auth;
|
||||||
const AuthScreen({super.key, required this.auth});
|
const AuthScreen({super.key, required this.auth});
|
||||||
@@ -17,12 +18,29 @@ class AuthScreen extends StatefulWidget {
|
|||||||
class _AuthScreenState extends State<AuthScreen> {
|
class _AuthScreenState extends State<AuthScreen> {
|
||||||
final _emailCtrl = TextEditingController();
|
final _emailCtrl = TextEditingController();
|
||||||
final _passCtrl = TextEditingController();
|
final _passCtrl = TextEditingController();
|
||||||
bool _isLogin = true; // toggle register/login
|
bool _isLogin = true;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_trySilentAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On open: if a valid session is present, go straight to Dashboard.
|
||||||
|
Future<void> _trySilentAuth() async {
|
||||||
|
final timeout = await widget.auth.getTimeoutMinutes();
|
||||||
|
final valid = await widget.auth.hasValidSession(timeout);
|
||||||
|
if (valid && mounted) {
|
||||||
|
final email = await widget.auth.getEmail() ?? '';
|
||||||
|
_goToDashboard(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _goToDashboard(String email) {
|
void _goToDashboard(String email) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
widget.auth.openSession(); // refresh session timestamp
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => DashboardScreen(
|
builder: (_) => DashboardScreen(
|
||||||
|
|||||||
@@ -1,35 +1,31 @@
|
|||||||
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:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:login_vault_app/constants.dart';
|
import 'package:login_vault_app/constants.dart';
|
||||||
|
|
||||||
/// Handles app-level authentication and persistence of the local identity.
|
/// Handles app-level authentication, session, and local identity.
|
||||||
///
|
|
||||||
/// On first launch the user registers (email+password OR Google). The chosen
|
|
||||||
/// email is stored locally as the key ([AppConstants.secureKeyEmail]) and the
|
|
||||||
/// app password (used to lock the dashboard) is stored in secure storage.
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||||
|
final LocalAuthentication _localAuth = LocalAuthentication();
|
||||||
|
|
||||||
|
// ---------- Identity (email + app password) ----------
|
||||||
|
|
||||||
/// Returns the stored email, or null if not registered yet.
|
|
||||||
Future<String?> getEmail() async =>
|
Future<String?> getEmail() async =>
|
||||||
await _storage.read(key: AppConstants.secureKeyEmail);
|
await _storage.read(key: AppConstants.secureKeyEmail);
|
||||||
|
|
||||||
Future<String?> getAppPassword() async =>
|
Future<String?> getAppPassword() async =>
|
||||||
await _storage.read(key: AppConstants.secureKeyAppPassword);
|
await _storage.read(key: AppConstants.secureKeyAppPassword);
|
||||||
|
|
||||||
/// True if a user identity already exists locally.
|
|
||||||
Future<bool> isRegistered() async => (await getEmail()) != null;
|
Future<bool> isRegistered() async => (await getEmail()) != null;
|
||||||
|
|
||||||
/// Register with email + app password.
|
|
||||||
Future<void> registerWithEmail(String email, String appPassword) async {
|
Future<void> registerWithEmail(String email, String appPassword) async {
|
||||||
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
|
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
|
||||||
await _storage.write(
|
await _storage.write(
|
||||||
key: AppConstants.secureKeyAppPassword, value: appPassword);
|
key: AppConstants.secureKeyAppPassword, value: appPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register with Google (email supplied by Google sign-in).
|
|
||||||
Future<void> registerWithGoogle(String email) async {
|
Future<void> registerWithGoogle(String email) async {
|
||||||
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
|
await _storage.write(key: AppConstants.secureKeyEmail, value: email);
|
||||||
// App password defaults to a marker; user can change it later in settings.
|
|
||||||
final existing = await getAppPassword();
|
final existing = await getAppPassword();
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
await _storage.write(
|
await _storage.write(
|
||||||
@@ -41,27 +37,103 @@ class AuthService {
|
|||||||
Future<bool> verifyPassword(String password) async {
|
Future<bool> verifyPassword(String password) async {
|
||||||
final stored = await getAppPassword();
|
final stored = await getAppPassword();
|
||||||
if (stored == null) return false;
|
if (stored == null) return false;
|
||||||
if (stored == '__google__') {
|
if (stored == '__google__') return true; // google users: any pass ok
|
||||||
// Google users have no password by default; let any entry pass OR force set.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return stored == password;
|
return stored == password;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the app password (used in settings).
|
|
||||||
Future<void> changePassword(String newPassword) async {
|
Future<void> changePassword(String newPassword) async {
|
||||||
await _storage.write(
|
await _storage.write(
|
||||||
key: AppConstants.secureKeyAppPassword, value: newPassword);
|
key: AppConstants.secureKeyAppPassword, value: newPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
// ---------- Biometric (fingerprint) ----------
|
||||||
// Keep email (identity) but we treat logout as returning to auth screen
|
|
||||||
// with the email still known. To fully reset, call [reset].
|
/// Can the device authenticate with biometrics (fingerprint etc.)?
|
||||||
|
Future<bool> canUseBiometrics() async {
|
||||||
|
try {
|
||||||
|
final supported = await _localAuth.isDeviceSupported();
|
||||||
|
if (!supported) return false;
|
||||||
|
final available = await _localAuth.getAvailableBiometrics();
|
||||||
|
return available.isNotEmpty;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wipe local identity (for completeness / debugging).
|
/// Prompt the OS biometric dialog. Returns true on success.
|
||||||
|
Future<bool> authenticateWithBiometrics() async {
|
||||||
|
try {
|
||||||
|
return await _localAuth.authenticate(
|
||||||
|
localizedReason: 'Gunakan sidik jari untuk membuka Login Vault',
|
||||||
|
options: const AuthenticationOptions(
|
||||||
|
biometricOnly: true,
|
||||||
|
stickyAuth: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Session ----------
|
||||||
|
|
||||||
|
/// Persist that a session was just opened (with timestamp).
|
||||||
|
Future<void> openSession() async {
|
||||||
|
await _storage.write(
|
||||||
|
key: AppConstants.secureKeySession, value: DateTime.now().toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> closeSession() async {
|
||||||
|
await _storage.delete(key: AppConstants.secureKeySession);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a valid, non-expired session exists.
|
||||||
|
/// [timeoutMinutes] = 0 means "stay logged in until manual logout".
|
||||||
|
Future<bool> hasValidSession(int timeoutMinutes) async {
|
||||||
|
if (timeoutMinutes <= 0) {
|
||||||
|
// never auto-expire; just check a session was opened
|
||||||
|
final v = await _storage.read(key: AppConstants.secureKeySession);
|
||||||
|
return v != null;
|
||||||
|
}
|
||||||
|
final v = await _storage.read(key: AppConstants.secureKeySession);
|
||||||
|
if (v == null) return false;
|
||||||
|
final opened = DateTime.tryParse(v);
|
||||||
|
if (opened == null) return false;
|
||||||
|
final diff = DateTime.now().difference(opened).inMinutes;
|
||||||
|
return diff < timeoutMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Timeout setting ----------
|
||||||
|
|
||||||
|
/// Get configured session timeout (minutes). Default 3.
|
||||||
|
Future<int> getTimeoutMinutes() async {
|
||||||
|
final sp = await _prefs();
|
||||||
|
return sp.getInt(AppConstants.prefTimeout) ?? 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setTimeoutMinutes(int minutes) async {
|
||||||
|
final sp = await _prefs();
|
||||||
|
await sp.setInt(AppConstants.prefTimeout, minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Biometric toggle setting ----------
|
||||||
|
|
||||||
|
Future<bool> isBiometricEnabled() async {
|
||||||
|
final sp = await _prefs();
|
||||||
|
return sp.getBool(AppConstants.prefBiometric) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBiometricEnabled(bool on) async {
|
||||||
|
final sp = await _prefs();
|
||||||
|
await sp.setBool(AppConstants.prefBiometric, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SharedPreferences> _prefs() async =>
|
||||||
|
await SharedPreferences.getInstance();
|
||||||
|
|
||||||
Future<void> reset() async {
|
Future<void> reset() async {
|
||||||
await _storage.delete(key: AppConstants.secureKeyEmail);
|
await _storage.delete(key: AppConstants.secureKeyEmail);
|
||||||
await _storage.delete(key: AppConstants.secureKeyAppPassword);
|
await _storage.delete(key: AppConstants.secureKeyAppPassword);
|
||||||
|
await _storage.delete(key: AppConstants.secureKeySession);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ class AppConstants {
|
|||||||
/// SQLite database file name.
|
/// SQLite database file name.
|
||||||
static const String dbName = 'login_vault.db';
|
static const String dbName = 'login_vault.db';
|
||||||
|
|
||||||
/// Application package-ish identifiers for secure storage keys.
|
/// Secure storage keys.
|
||||||
static const String secureKeyEmail = 'vault_email';
|
static const String secureKeyEmail = 'vault_email';
|
||||||
static const String secureKeyAppPassword = 'vault_app_password';
|
static const String secureKeyAppPassword = 'vault_app_password';
|
||||||
|
static const String secureKeySession = 'vault_session_opened_at';
|
||||||
|
|
||||||
|
/// SharedPreferences keys (settings).
|
||||||
|
static const String prefTimeout = 'session_timeout_minutes';
|
||||||
|
static const String prefBiometric = 'biometric_enabled';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,25 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchCtrl.addListener(_onSearchChanged);
|
_searchCtrl.addListener(() {
|
||||||
|
_touch(); // user activity
|
||||||
|
_onSearchChanged();
|
||||||
|
});
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchCtrl.removeListener(_onSearchChanged);
|
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the session timestamp — called on every user interaction so the
|
||||||
|
/// 3-minute inactivity timeout only fires when the user is truly idle.
|
||||||
|
void _touch() {
|
||||||
|
widget.auth.openSession();
|
||||||
|
}
|
||||||
|
|
||||||
void _onSearchChanged() => _refresh();
|
void _onSearchChanged() => _refresh();
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
@@ -66,6 +74,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editEntry(VaultEntry e) async {
|
Future<void> _editEntry(VaultEntry e) async {
|
||||||
|
_touch();
|
||||||
final result = await showEntryDialog(
|
final result = await showEntryDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Edit konten',
|
title: 'Edit konten',
|
||||||
@@ -80,6 +89,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addEntry() async {
|
Future<void> _addEntry() async {
|
||||||
|
_touch();
|
||||||
final result = await showEntryDialog(
|
final result = await showEntryDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Tambah entri baru',
|
title: 'Tambah entri baru',
|
||||||
@@ -110,13 +120,16 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: 'Pengaturan',
|
tooltip: 'Pengaturan',
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () {
|
||||||
|
_touch();
|
||||||
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SettingsScreen(
|
builder: (_) => SettingsScreen(
|
||||||
auth: widget.auth, theme: widget.theme),
|
auth: widget.auth, theme: widget.theme),
|
||||||
),
|
),
|
||||||
).then((_) => _refresh()),
|
).then((_) => _refresh());
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -139,6 +152,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
_touch();
|
||||||
_searchCtrl.clear();
|
_searchCtrl.clear();
|
||||||
_refresh();
|
_refresh();
|
||||||
},
|
},
|
||||||
@@ -167,20 +181,20 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
child: const Icon(Icons.delete, color: Colors.white),
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
),
|
),
|
||||||
confirmDismiss: (_) async {
|
confirmDismiss: (_) async {
|
||||||
// Use our own dialog for nicer copy.
|
_touch();
|
||||||
final c = await showConfirmDialog(
|
final c = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Hapus entri?',
|
title: 'Hapus entri?',
|
||||||
body:
|
body: 'Yakin hapus "${e.id}"?',
|
||||||
'Yakin hapus "${e.id}"?',
|
|
||||||
);
|
);
|
||||||
if (c == true) await _deleteEntry(e);
|
if (c == true) await _deleteEntry(e);
|
||||||
return false; // we handle deletion ourselves
|
return false;
|
||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 6),
|
horizontal: 12, vertical: 6),
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
|
onExpansionChanged: (_) => _touch(),
|
||||||
title: Text(e.id,
|
title: Text(e.id,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold)),
|
fontWeight: FontWeight.bold)),
|
||||||
|
|||||||
@@ -11,26 +11,39 @@ void main() async {
|
|||||||
final theme = ThemeProvider();
|
final theme = ThemeProvider();
|
||||||
final registered = await auth.isRegistered();
|
final registered = await auth.isRegistered();
|
||||||
final email = await auth.getEmail() ?? '';
|
final email = await auth.getEmail() ?? '';
|
||||||
|
final timeout = await auth.getTimeoutMinutes();
|
||||||
|
final hasSession = await auth.hasValidSession(timeout);
|
||||||
|
final startAtDashboard = registered && email.isNotEmpty && hasSession;
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider<AuthService>.value(value: auth),
|
Provider<AuthService>.value(value: auth),
|
||||||
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
|
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
|
||||||
],
|
],
|
||||||
child: MyApp(registered: registered, email: email),
|
child: MyApp(
|
||||||
|
startAtDashboard: startAtDashboard,
|
||||||
|
auth: auth,
|
||||||
|
theme: theme,
|
||||||
|
email: email,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
final bool registered;
|
final bool startAtDashboard;
|
||||||
|
final AuthService auth;
|
||||||
|
final ThemeProvider theme;
|
||||||
final String email;
|
final String email;
|
||||||
const MyApp({super.key, required this.registered, required this.email});
|
const MyApp(
|
||||||
|
{super.key,
|
||||||
|
required this.startAtDashboard,
|
||||||
|
required this.auth,
|
||||||
|
required this.theme,
|
||||||
|
required this.email});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Provider.of<ThemeProvider>(context);
|
|
||||||
final auth = Provider.of<AuthService>(context, listen: false);
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Login Vault',
|
title: 'Login Vault',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
@@ -44,8 +57,7 @@ class MyApp extends StatelessWidget {
|
|||||||
colorSchemeSeed: Colors.indigo,
|
colorSchemeSeed: Colors.indigo,
|
||||||
),
|
),
|
||||||
themeMode: theme.mode,
|
themeMode: theme.mode,
|
||||||
// decide home: if registered AND email known -> Dashboard, else Auth.
|
home: startAtDashboard
|
||||||
home: (registered && email.isNotEmpty)
|
|
||||||
? DashboardScreen(auth: auth, theme: theme, email: email)
|
? DashboardScreen(auth: auth, theme: theme, email: email)
|
||||||
: AuthScreen(auth: auth),
|
: AuthScreen(auth: auth),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:login_vault_app/auth_service.dart';
|
import 'package:login_vault_app/auth_service.dart';
|
||||||
import 'package:login_vault_app/theme_provider.dart';
|
import 'package:login_vault_app/theme_provider.dart';
|
||||||
|
|
||||||
/// Settings: change app password, switch theme (dark/system), and Exit.
|
/// Settings: change app password, switch theme (dark/system), session timeout,
|
||||||
|
/// fingerprint toggle, and Exit.
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
final AuthService auth;
|
final AuthService auth;
|
||||||
final ThemeProvider theme;
|
final ThemeProvider theme;
|
||||||
@@ -18,6 +19,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final _oldCtrl = TextEditingController();
|
final _oldCtrl = TextEditingController();
|
||||||
final _newCtrl = TextEditingController();
|
final _newCtrl = TextEditingController();
|
||||||
String? _msg;
|
String? _msg;
|
||||||
|
bool _biometricOn = false;
|
||||||
|
bool _biometricAvailable = false;
|
||||||
|
int _timeout = 3;
|
||||||
|
|
||||||
|
final List<int> _timeoutOptions = [3, 5, 10, 15, 30, 60, 0];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final on = await widget.auth.isBiometricEnabled();
|
||||||
|
final avail = await widget.auth.canUseBiometrics();
|
||||||
|
final t = await widget.auth.getTimeoutMinutes();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_biometricOn = on && avail;
|
||||||
|
_biometricAvailable = avail;
|
||||||
|
_timeout = t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _changePassword() async {
|
Future<void> _changePassword() async {
|
||||||
final oldP = _oldCtrl.text;
|
final oldP = _oldCtrl.text;
|
||||||
@@ -37,6 +62,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_newCtrl.clear();
|
_newCtrl.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleBiometric(bool value) async {
|
||||||
|
if (value && !_biometricAvailable) {
|
||||||
|
setState(() => _msg = 'Perangkat tidak mendukung sidik jari');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
final ok = await widget.auth.authenticateWithBiometrics();
|
||||||
|
if (!ok) {
|
||||||
|
setState(() => _msg = 'Verifikasi sidik jari gagal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await widget.auth.setBiometricEnabled(value);
|
||||||
|
if (mounted) setState(() => _biometricOn = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setTimeout(int? value) async {
|
||||||
|
if (value == null) return;
|
||||||
|
await widget.auth.setTimeoutMinutes(value);
|
||||||
|
if (mounted) setState(() => _timeout = value);
|
||||||
|
widget.auth.openSession(); // activity
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timeoutLabel(int m) => m == 0 ? 'Selamanya' : '$m menit';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -44,8 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const Text('Tema',
|
const Text('Tema', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Gelap (Dark)'),
|
title: const Text('Gelap (Dark)'),
|
||||||
leading: const Icon(Icons.dark_mode),
|
leading: const Icon(Icons.dark_mode),
|
||||||
@@ -67,6 +116,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onTap: () => widget.theme.setMode(ThemeMode.system),
|
onTap: () => widget.theme.setMode(ThemeMode.system),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const Text('Session', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
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))))
|
||||||
|
.toList(),
|
||||||
|
onChanged: _setTimeout,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Login dengan sidik jari'),
|
||||||
|
subtitle: Text(_biometricAvailable
|
||||||
|
? 'Gunakan fingerprint untuk membuka app'
|
||||||
|
: 'Perangkat tidak mendukung biometrik'),
|
||||||
|
value: _biometricOn,
|
||||||
|
onChanged: _toggleBiometric,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
const Text('Ubah Password Aplikasi',
|
const Text('Ubah Password Aplikasi',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -87,15 +157,14 @@ 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!,
|
child: Text(_msg!, style: const TextStyle(color: Colors.green)),
|
||||||
style: const TextStyle(color: Colors.green)),
|
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.exit_to_app),
|
leading: const Icon(Icons.exit_to_app),
|
||||||
title: const Text('Keluar (Exit)'),
|
title: const Text('Keluar (Exit)'),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
// On Android, pop the whole stack then ask the system to close.
|
await widget.auth.closeSession();
|
||||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
Navigator.of(context).popUntil((r) => r.isFirst);
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
SystemNavigator.pop();
|
SystemNavigator.pop();
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import Foundation
|
|||||||
|
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
|
import local_auth_darwin
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
56
pubspec.lock
56
pubspec.lock
@@ -102,6 +102,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -232,6 +240,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.3"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -296,6 +312,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
local_auth:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: local_auth
|
||||||
|
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
local_auth_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_android
|
||||||
|
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.56"
|
||||||
|
local_auth_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_darwin
|
||||||
|
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.1"
|
||||||
|
local_auth_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_platform_interface
|
||||||
|
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
local_auth_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_windows
|
||||||
|
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.11"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencies:
|
|||||||
google_sign_in: ^6.2.1
|
google_sign_in: ^6.2.1
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
|
local_auth: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
local_auth_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user