From 2160478fa78c6e634e86d114757e097f23f13288 Mon Sep 17 00:00:00 2001 From: Marcos Date: Sun, 17 May 2026 13:48:09 +0200 Subject: [PATCH] feat: add biometric authentication support and related UI screens - Updated AndroidManifest.xml to include permissions for biometric authentication. - Changed MainActivity to extend FlutterFragmentActivity for better compatibility. - Modified gradle.properties to optimize memory settings. - Enhanced app.dart to manage new app phases for biometric authentication. - Implemented LocalVaultService methods for handling biometric key protection. - Created BiometricChoiceScreen and BiometricGateScreen for user interaction. - Updated HomeScreen to handle vault invalidation scenarios. - Registered local_auth plugin for biometric functionality on macOS and Windows. - Updated pubspec.yaml and pubspec.lock to include local_auth dependency. --- android/app/src/main/AndroidManifest.xml | 3 + .../kotlin/com/example/notas/MainActivity.kt | 4 +- android/gradle.properties | 4 +- lib/app.dart | 441 ++++++++++++++++-- lib/data/local_vault_service.dart | 179 ++++++- lib/screens/biometric_choice_screen.dart | 120 +++++ lib/screens/biometric_gate_screen.dart | 134 ++++++ lib/screens/home_screen.dart | 23 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++- pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 13 files changed, 910 insertions(+), 63 deletions(-) create mode 100644 lib/screens/biometric_choice_screen.dart create mode 100644 lib/screens/biometric_gate_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b1ea60d..d3ea1a9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + createState() => _NotesAppState(); } -class _NotesAppState extends State with WindowListener { +class _NotesAppState extends State + with WindowListener, WidgetsBindingObserver { static const Duration _screenTransitionDuration = Duration(milliseconds: 280); + static const Duration _biometricInactivityTimeout = Duration(minutes: 5); final LocalVaultService _vaultService = LocalVaultService.instance; final GlobalKey _scaffoldMessengerKey = GlobalKey(); + final GlobalKey _navigatorKey = GlobalKey(); AppDatabase? _database; NoteRepository? _repository; + String? _pendingEncryptionKey; bool _isBootstrapping = true; bool _isUnlocking = false; + bool _biometricGateEnabled = false; + int _biometricGateSession = 0; + Timer? _biometricLockTimer; + bool _isHandlingWindowClose = false; + _AppPhase _phase = _AppPhase.loading; _AppSection _currentSection = _AppSection.home; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); if (isDesktop) { windowManager.addListener(this); + windowManager.setPreventClose(true); } _bootstrapVault(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); if (isDesktop) { windowManager.removeListener(this); + windowManager.setPreventClose(false); } + _biometricLockTimer?.cancel(); _database?.close(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (_isUnlocking) { + return; + } + + switch (state) { + case AppLifecycleState.resumed: + _cancelBiometricLockTimer(); + return; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + _scheduleBiometricLockTimer(); + return; + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + return; + } + } + Future _bootstrapVault() async { try { - final String? encryptionKey = await _vaultService.readEncryptionKey(); + final bool hasEncryptionKey = await _vaultService.hasEncryptionKey(); + _biometricGateEnabled = hasEncryptionKey && await _vaultService.isBiometricGateEnabled(); + if (!hasEncryptionKey) { + _pendingEncryptionKey = null; + if (mounted) { + setState(() { + _phase = _AppPhase.access; + }); + } + return; + } + + final bool accessCompleted = await _vaultService.isVaultAccessCompleted(); + final bool biometricChoicePending = await _vaultService.isBiometricChoicePending(); + + if (!accessCompleted) { + _pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw(); + if (mounted) { + setState(() { + _phase = _AppPhase.access; + }); + } + return; + } + + if (biometricChoicePending) { + _pendingEncryptionKey = await _vaultService.readStoredEncryptionKeyRaw(); + if (mounted) { + setState(() { + _phase = _AppPhase.biometricChoice; + }); + } + return; + } + + if (_biometricGateEnabled) { + if (mounted) { + setState(() { + _phase = _AppPhase.biometricGate; + }); + } + return; + } + + final String? encryptionKey = await _vaultService.readStoredEncryptionKeyRaw(); if (encryptionKey != null) { await _openVault(encryptionKey); + } else if (mounted) { + setState(() { + _phase = _AppPhase.access; + }); } } finally { if (mounted) { @@ -77,16 +170,190 @@ class _NotesAppState extends State with WindowListener { Future _openVault(String encryptionKey) async { await _database?.close(); - final AppDatabase database = AppDatabase(encryptionKey: encryptionKey); - if (!mounted) { - await database.close(); + try { + final AppDatabase database = AppDatabase(encryptionKey: encryptionKey); + if (!mounted) { + await database.close(); + return; + } + + setState(() { + _database = database; + _repository = NoteRepository(database: database); + _phase = _AppPhase.notes; + }); + } catch (e) { + // If the database file is not a valid SQLite DB (e.g., wrong key or corruption), + // reset the local vault so the app doesn't crash. The reset will delete DB files + // and clear the stored encryption key. + await _resetLocalVaultData(); + + if (mounted) { + _scaffoldMessengerKey.currentState?.showSnackBar( + const SnackBar(content: Text('El vault local estaba corrupto y ha sido reiniciado.')), + ); + } + } + } + + Future _beginInitialVaultFlow({String? actionLabel}) async { + if (_isUnlocking) { return; } setState(() { - _database = database; - _repository = NoteRepository(database: database); + _isUnlocking = true; }); + + try { + if (actionLabel != null) { + _showAccountPlaceholder(actionLabel); + } + + final String? existingKey = await _vaultService.readStoredEncryptionKeyRaw(); + final String encryptionKey = existingKey ?? await _vaultService.createEncryptionKey(); + + _pendingEncryptionKey = encryptionKey; + await _vaultService.setVaultAccessCompleted(true); + await _vaultService.setBiometricChoicePending(true); + await _vaultService.setBiometricGateEnabled(false); + + if (mounted) { + setState(() { + _phase = _AppPhase.biometricChoice; + _biometricGateEnabled = false; + }); + } + } catch (error) { + _scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar(content: Text('No se pudo preparar el vault local: $error')), + ); + } finally { + if (mounted) { + setState(() { + _isUnlocking = false; + _isBootstrapping = false; + }); + } + } + } + + Future _enterWithoutAccount() { + return _beginInitialVaultFlow(); + } + + Future _completeBiometricChoice({required bool enableBiometrics}) async { + if (_isUnlocking) { + return; + } + + setState(() { + _isUnlocking = true; + }); + + try { + final String? pendingKey = _pendingEncryptionKey ?? await _vaultService.readStoredEncryptionKeyRaw(); + + if (pendingKey == null) { + throw StateError('No se encontró la llave local.'); + } + + if (enableBiometrics) { + final bool available = await _vaultService.isBiometricAvailable(); + if (available) { + bool activated = await _vaultService.enableBiometricProtection(); + while (!activated) { + // Ask the user to retry or skip + final BuildContext? dialogCtx = _navigatorKey.currentContext; + if (dialogCtx == null) { + break; + } + + final NavigatorState navigator = Navigator.of(dialogCtx); + + final bool? retry = await showDialog( + context: dialogCtx, + builder: (BuildContext context) => AlertDialog( + title: const Text('No se pudo activar la biometría'), + content: const Text('No se pudo activar la biometría. ¿Quieres intentarlo de nuevo o entrar sin huella?'), + actions: [ + TextButton(onPressed: () => navigator.pop(false), child: const Text('Entrar sin huella')), + FilledButton(onPressed: () => navigator.pop(true), child: const Text('Reintentar')), + ], + ), + ); + + if (retry != true) { + // User chose to skip biometric activation + break; + } + + activated = await _vaultService.enableBiometricProtection(); + } + + if (activated) { + await _vaultService.setBiometricChoicePending(false); + await _vaultService.setVaultAccessCompleted(true); + _biometricGateEnabled = true; + _pendingEncryptionKey = pendingKey; + await _openVault(pendingKey); + return; + } + } + + _scaffoldMessengerKey.currentState?.showSnackBar( + const SnackBar(content: Text('La biometría no está disponible en este dispositivo.')), + ); + return; + } + + await _vaultService.setBiometricGateEnabled(false); + await _vaultService.setBiometricChoicePending(false); + await _vaultService.setVaultAccessCompleted(true); + _biometricGateEnabled = false; + _pendingEncryptionKey = pendingKey; + await _openVault(pendingKey); + } catch (error) { + _scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar(content: Text('No se pudo finalizar la configuración del vault: $error')), + ); + } finally { + if (mounted) { + setState(() { + _isUnlocking = false; + _isBootstrapping = false; + }); + } + } + } + + Future _unlockBiometricGate() async { + if (_isUnlocking) { + return; + } + + setState(() { + _isUnlocking = true; + }); + + try { + final String? encryptionKey = await _vaultService.readEncryptionKey(); + + if (encryptionKey != null) { + await _openVault(encryptionKey); + } + } catch (error) { + _scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar(content: Text('No se pudo desbloquear el vault: $error')), + ); + } finally { + if (mounted) { + setState(() { + _isUnlocking = false; + _isBootstrapping = false; + }); + } + } } void _openSettings() { @@ -116,6 +383,7 @@ class _NotesAppState extends State with WindowListener { _repository = null; _database = null; _isBootstrapping = true; + _phase = _AppPhase.loading; }); await database?.close(); @@ -145,33 +413,64 @@ class _NotesAppState extends State with WindowListener { setState(() { _isBootstrapping = false; _isUnlocking = false; + _biometricGateEnabled = false; + _pendingEncryptionKey = null; + _phase = _AppPhase.access; }); } - Future _enterWithoutAccount() async { - if (_isUnlocking) { + Future _lockVault() async { + final AppDatabase? database = _database; + + if (database == null && _repository == null) { + return; + } + + await database?.close(); + + if (!mounted) { return; } setState(() { - _isUnlocking = true; + _database = null; + _repository = null; + _isBootstrapping = false; + _biometricGateSession += 1; + _phase = _AppPhase.biometricGate; + _currentSection = _AppSection.home; }); + } - try { - final String encryptionKey = await _vaultService.createEncryptionKey(); - await _openVault(encryptionKey); - } catch (error) { - _scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar(content: Text('No se pudo crear el vault local: $error')), - ); - } finally { - if (mounted) { - setState(() { - _isUnlocking = false; - _isBootstrapping = false; - }); - } + bool get _needsBiometricLock => _biometricGateEnabled && _repository != null; + + void _cancelBiometricLockTimer() { + _biometricLockTimer?.cancel(); + _biometricLockTimer = null; + } + + void _scheduleBiometricLockTimer() { + if (!_needsBiometricLock) { + return; } + + _biometricLockTimer?.cancel(); + _biometricLockTimer = Timer(_biometricInactivityTimeout, () { + if (!mounted || !_needsBiometricLock) { + return; + } + + unawaited(_lockVault()); + }); + } + + Future _allowWindowClose() async { + if (!isDesktop) { + return; + } + + await windowManager.setPreventClose(false); + await windowManager.close(); } void _showAccountPlaceholder(String actionLabel) { @@ -199,6 +498,7 @@ class _NotesAppState extends State with WindowListener { Widget _buildLoadingScreen() { return MaterialApp( + navigatorKey: _navigatorKey, title: 'Mis Notas', debugShowCheckedModeBanner: false, theme: AppTheme.theme, @@ -228,6 +528,7 @@ class _NotesAppState extends State with WindowListener { Widget _buildAppShell({required Widget home}) { return MaterialApp( + navigatorKey: _navigatorKey, title: 'Mis Notas', debugShowCheckedModeBanner: false, scaffoldMessengerKey: _scaffoldMessengerKey, @@ -242,6 +543,7 @@ class _NotesAppState extends State with WindowListener { key: const ValueKey('home-screen'), repository: repository, onOpenSettings: _openSettings, + onVaultInvalid: _resetLocalVaultData, ) : SettingsScreen( key: const ValueKey('settings-screen'), @@ -250,6 +552,7 @@ class _NotesAppState extends State with WindowListener { ); return MaterialApp( + navigatorKey: _navigatorKey, title: 'Mis Notas', debugShowCheckedModeBanner: false, scaffoldMessengerKey: _scaffoldMessengerKey, @@ -308,6 +611,44 @@ class _NotesAppState extends State with WindowListener { _saveWindowSize(); } + @override + void onWindowFocus() { + _cancelBiometricLockTimer(); + } + + @override + void onWindowBlur() { + _scheduleBiometricLockTimer(); + } + + @override + void onWindowClose() { + if (_isHandlingWindowClose) { + return; + } + + if (!_needsBiometricLock) { + unawaited(_allowWindowClose()); + return; + } + + _isHandlingWindowClose = true; + _cancelBiometricLockTimer(); + + unawaited(() async { + try { + final String? encryptionKey = await _vaultService.readEncryptionKey(); + if (encryptionKey == null) { + return; + } + + await _allowWindowClose(); + } finally { + _isHandlingWindowClose = false; + } + }()); + } + @override Widget build(BuildContext context) { if (_isBootstrapping) { @@ -320,17 +661,45 @@ class _NotesAppState extends State with WindowListener { return _buildMainShell(repository); } - return _buildAppShell( - home: VaultAccessScreen( - isBusy: _isUnlocking, - onCreateAccountPressed: (String email, String password) async { - _showAccountPlaceholder('Crear cuenta'); - }, - onSignInPressed: (String email, String password) async { - _showAccountPlaceholder('Iniciar sesión'); - }, - onContinueWithoutAccount: _enterWithoutAccount, - ), - ); + switch (_phase) { + case _AppPhase.loading: + return _buildLoadingScreen(); + case _AppPhase.access: + return _buildAppShell( + home: VaultAccessScreen( + isBusy: _isUnlocking, + onCreateAccountPressed: (String email, String password) async { + await _beginInitialVaultFlow(actionLabel: 'Crear cuenta'); + }, + onSignInPressed: (String email, String password) async { + await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión'); + }, + onContinueWithoutAccount: _enterWithoutAccount, + ), + ); + case _AppPhase.biometricChoice: + return _buildAppShell( + home: BiometricChoiceScreen( + isBusy: _isUnlocking, + onEnableBiometrics: () => _completeBiometricChoice(enableBiometrics: true), + onSkipBiometrics: () => _completeBiometricChoice(enableBiometrics: false), + ), + ); + case _AppPhase.biometricGate: + return _buildAppShell( + home: BiometricGateScreen( + key: ValueKey(_biometricGateSession), + isBusy: _isUnlocking, + onUnlockRequested: _unlockBiometricGate, + ), + ); + case _AppPhase.notes: + if (repository == null) { + return _buildLoadingScreen(); + } + + return _buildMainShell(repository); + } + } } \ No newline at end of file diff --git a/lib/data/local_vault_service.dart b/lib/data/local_vault_service.dart index e48e94e..977b464 100644 --- a/lib/data/local_vault_service.dart +++ b/lib/data/local_vault_service.dart @@ -1,6 +1,8 @@ import 'dart:math'; +import 'dart:io' show Platform; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; class LocalVaultService { LocalVaultService._(); @@ -9,26 +11,110 @@ class LocalVaultService { static const String _encryptionKeyStorageKey = 'notes_local_encryption_key_v1'; + static const String _vaultAccessCompletedKey = + 'notes_vault_access_completed_v1'; + static const String _biometricChoicePendingKey = + 'notes_vault_biometric_choice_pending_v1'; + static const String _biometricGateEnabledKey = + 'notes_vault_biometric_gate_enabled_v1'; final FlutterSecureStorage _secureStorage = FlutterSecureStorage(); + final LocalAuthentication _localAuth = LocalAuthentication(); + String? _lastBiometricError; - String? _cachedEncryptionKey; + /// Último error conocido al consultar/activar biometría. Útil para diagnóstico. + String? getLastBiometricError() => _lastBiometricError; Future readEncryptionKey() async { - final String? cachedKey = _cachedEncryptionKey; - if (cachedKey != null) { - return cachedKey; - } - final String? storedKey = await _secureStorage.read( key: _encryptionKeyStorageKey, ); - _cachedEncryptionKey = storedKey; + if (storedKey == null) return null; + + // If biometric protection was enabled when the key was created, require + // authentication before returning the key. We only enable biometric + // protection on mobile (Android/iOS). + if (await isBiometricGateEnabled()) { + // Only attempt authentication on Android/iOS. + if (!Platform.isAndroid && !Platform.isIOS) { + return null; + } + + try { + final bool supported = await _localAuth.isDeviceSupported(); + final bool canCheck = await _localAuth.canCheckBiometrics; + + if (supported || canCheck) { + final bool didAuthenticate = await _localAuth.authenticate( + localizedReason: + 'Autentícate para acceder a la llave de encriptación', + biometricOnly: false, + sensitiveTransaction: true, + persistAcrossBackgrounding: false, + ); + + if (!didAuthenticate) return null; + } else { + return null; + } + } catch (e, st) { + _lastBiometricError = e.toString(); + // Also print stack for debugging when running locally. + // ignore: avoid_print + print('LocalVaultService.readEncryptionKey biometric error: $e\n$st'); + return null; + } + } + return storedKey; } - Future createEncryptionKey() async { + Future readStoredEncryptionKeyRaw() { + return _secureStorage.read(key: _encryptionKeyStorageKey); + } + + Future hasEncryptionKey() async { + return (await readStoredEncryptionKeyRaw()) != null; + } + + Future isVaultAccessCompleted() async { + return (await _secureStorage.read(key: _vaultAccessCompletedKey)) == '1'; + } + + Future setVaultAccessCompleted(bool value) async { + if (value) { + await _secureStorage.write(key: _vaultAccessCompletedKey, value: '1'); + } else { + await _secureStorage.delete(key: _vaultAccessCompletedKey); + } + } + + Future isBiometricChoicePending() async { + return (await _secureStorage.read(key: _biometricChoicePendingKey)) == '1'; + } + + Future setBiometricChoicePending(bool value) async { + if (value) { + await _secureStorage.write(key: _biometricChoicePendingKey, value: '1'); + } else { + await _secureStorage.delete(key: _biometricChoicePendingKey); + } + } + + Future isBiometricGateEnabled() async { + return (await _secureStorage.read(key: _biometricGateEnabledKey)) == '1'; + } + + Future setBiometricGateEnabled(bool value) async { + if (value) { + await _secureStorage.write(key: _biometricGateEnabledKey, value: '1'); + } else { + await _secureStorage.delete(key: _biometricGateEnabledKey); + } + } + + Future createEncryptionKey({bool protectWithBiometrics = false}) async { final String encryptionKey = _generateEncryptionKey(); await _secureStorage.write( @@ -36,13 +122,86 @@ class LocalVaultService { value: encryptionKey, ); - _cachedEncryptionKey = encryptionKey; + // If requested, try to enable biometric protection. Only enable on mobile + // platforms and only if the authentication succeeds. + if (protectWithBiometrics && (Platform.isAndroid || Platform.isIOS)) { + try { + final bool supported = await _localAuth.isDeviceSupported(); + final bool canCheck = await _localAuth.canCheckBiometrics; + + if (supported || canCheck) { + final bool didAuthenticate = await _localAuth.authenticate( + localizedReason: 'Configura biometría para proteger la llave', + biometricOnly: false, + sensitiveTransaction: true, + persistAcrossBackgrounding: false, + ); + + if (didAuthenticate) { + await setBiometricGateEnabled(true); + } + } + } catch (e) { + _lastBiometricError = e.toString(); + // ignore: avoid_print + print('LocalVaultService.createEncryptionKey biometric error: $e'); + // Ignore errors and leave biometric protection disabled. + } + } + return encryptionKey; } Future clearEncryptionKey() async { await _secureStorage.delete(key: _encryptionKeyStorageKey); - _cachedEncryptionKey = null; + await _secureStorage.delete(key: _vaultAccessCompletedKey); + await _secureStorage.delete(key: _biometricChoicePendingKey); + await _secureStorage.delete(key: _biometricGateEnabledKey); + } + + Future isBiometricAvailable() async { + if (!Platform.isAndroid && !Platform.isIOS) return false; + + try { + final bool supported = await _localAuth.isDeviceSupported(); + final bool canCheck = await _localAuth.canCheckBiometrics; + if (!supported && !canCheck) return false; + + final List types = await _localAuth.getAvailableBiometrics(); + _lastBiometricError = null; + return types.isNotEmpty; + } catch (e) { + _lastBiometricError = e.toString(); + // ignore: avoid_print + print('LocalVaultService.isBiometricAvailable error: $e'); + return false; + } + } + + Future enableBiometricProtection() async { + if (!await isBiometricAvailable()) return false; + + try { + // Prefer biometric-only authentication for activation to ensure the + // user sets up biometric unlocking (no device credential fallback). + final bool didAuthenticate = await _localAuth.authenticate( + localizedReason: 'Autentícate para habilitar biometría', + biometricOnly: true, + sensitiveTransaction: true, + persistAcrossBackgrounding: false, + ); + + if (!didAuthenticate) return false; + + await setBiometricGateEnabled(true); + _lastBiometricError = null; + return true; + } catch (e) { + _lastBiometricError = e.toString(); + // ignore: avoid_print + print('LocalVaultService.enableBiometricProtection error: $e'); + return false; + } } String _generateEncryptionKey() { diff --git a/lib/screens/biometric_choice_screen.dart b/lib/screens/biometric_choice_screen.dart new file mode 100644 index 0000000..3d68760 --- /dev/null +++ b/lib/screens/biometric_choice_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:notas/widgets/app_title_bar.dart'; + +class BiometricChoiceScreen extends StatelessWidget { + const BiometricChoiceScreen({ + super.key, + required this.isBusy, + required this.onEnableBiometrics, + required this.onSkipBiometrics, + }); + + final bool isBusy; + final Future Function() onEnableBiometrics; + final Future Function() onSkipBiometrics; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF191A1D), + Color(0xFF222326), + Color(0xFF101114), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + child: Column( + children: [ + const AppTitleBar(), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF1D1E20), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 30, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.fingerprint, + color: Colors.amber, + size: 44, + ), + const SizedBox(height: 16), + const Text( + 'Proteger con huella', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Text( + '¿Quieres que la app te pida huella o cara antes de entrar a tus notas?', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + height: 1.4, + ), + ), + const SizedBox(height: 22), + FilledButton( + onPressed: isBusy ? null : onEnableBiometrics, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: isBusy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sí, activar huella'), + ), + const SizedBox(height: 10), + OutlinedButton( + onPressed: isBusy ? null : onSkipBiometrics, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: Colors.white24), + foregroundColor: Colors.white, + ), + child: const Text('No, entrar sin huella'), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/biometric_gate_screen.dart b/lib/screens/biometric_gate_screen.dart new file mode 100644 index 0000000..8822008 --- /dev/null +++ b/lib/screens/biometric_gate_screen.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:notas/widgets/app_title_bar.dart'; + +class BiometricGateScreen extends StatefulWidget { + const BiometricGateScreen({ + super.key, + required this.isBusy, + required this.onUnlockRequested, + }); + + final bool isBusy; + final Future Function() onUnlockRequested; + + @override + State createState() => _BiometricGateScreenState(); +} + +class _BiometricGateScreenState extends State { + bool _autoRequested = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _requestUnlockOnce(); + } + }); + } + + Future _requestUnlockOnce() async { + if (_autoRequested || widget.isBusy) { + return; + } + + _autoRequested = true; + await widget.onUnlockRequested(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF191A1D), + Color(0xFF222326), + Color(0xFF101114), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + child: Column( + children: [ + const AppTitleBar(), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF1D1E20), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 30, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.fingerprint, + color: Colors.amber, + size: 44, + ), + const SizedBox(height: 16), + const Text( + 'Desbloqueo biométrico', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Text( + 'Pon tu huella o cara para entrar a tus notas.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + height: 1.4, + ), + ), + const SizedBox(height: 22), + FilledButton( + onPressed: widget.isBusy ? null : widget.onUnlockRequested, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: widget.isBusy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Desbloquear'), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 83654bd..5000e08 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -15,10 +15,12 @@ class HomeScreen extends StatefulWidget { super.key, required this.repository, required this.onOpenSettings, + this.onVaultInvalid, }); final NoteRepository repository; final VoidCallback onOpenSettings; + final Future Function()? onVaultInvalid; @override State createState() => _HomeScreenState(); @@ -38,16 +40,21 @@ class _HomeScreenState extends State { } Future _loadNotes() async { - final List storedNotes = await widget.repository.loadNotes(); + try { + final List storedNotes = await widget.repository.loadNotes(); - if (!mounted) { - return; + if (!mounted) return; + + setState(() { + _notes = storedNotes; + _isLoading = false; + }); + } catch (e) { + // If loading notes fails (e.g., DB corrupt), notify the app to reset the vault. + if (widget.onVaultInvalid != null) { + await widget.onVaultInvalid!(); + } } - - setState(() { - _notes = storedNotes; - _isLoading = false; - }); } Future _openNoteComposer() async { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f658d77..68b382f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,14 @@ import FlutterMacOS import Foundation import flutter_secure_storage_darwin +import local_auth_darwin import screen_retriever_macos import shared_preferences_foundation import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index e48d488..90608fe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,6 +246,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -356,10 +364,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" io: dependency: transitive description: @@ -388,10 +396,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -424,6 +432,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: ae6f382f638108c6becd134318d7c3f0a93875383a54010f61d7c97ac05d5137 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: b201c006fa769c23386f89aa6837ec0eb8179fcfb212eadcf87b422b3f9a6a78 + url: "https://pub.dev" + source: hosted + version: "2.0.8" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: a8c3d4e17454111f7fd31ff72a31222359f6059f7fe956c2dcfe0f88f49826d4 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + 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: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16 + url: "https://pub.dev" + source: hosted + version: "2.0.1" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b3c0e0f..7667403 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,8 +40,9 @@ dependencies: path_provider: ^2.1.4 shared_preferences: ^2.3.2 window_manager: ^0.5.1 - intl: ^0.19.0 + intl: ^0.20.2 flutter_secure_storage: ^10.2.0 + local_auth: ^3.0.1 sqlite3: ^3.3.1 dev_dependencies: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e3176cf..d25cfff 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8827481..b2fc4ee 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + local_auth_windows screen_retriever_windows window_manager )