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.
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Permisos necesarios para autenticación biométrica en Android -->
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||||
<application
|
<application
|
||||||
android:label="notas"
|
android:label="notas"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.example.notas
|
package com.example.notas
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2048M -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
+389
-20
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -6,6 +7,8 @@ import 'package:notas/data/local_vault_service.dart';
|
|||||||
import 'package:notas/data/note_repository.dart';
|
import 'package:notas/data/note_repository.dart';
|
||||||
import 'package:notas/platform/app_platform.dart';
|
import 'package:notas/platform/app_platform.dart';
|
||||||
import 'package:notas/platform/window_state.dart';
|
import 'package:notas/platform/window_state.dart';
|
||||||
|
import 'package:notas/screens/biometric_choice_screen.dart';
|
||||||
|
import 'package:notas/screens/biometric_gate_screen.dart';
|
||||||
import 'package:notas/screens/home_screen.dart';
|
import 'package:notas/screens/home_screen.dart';
|
||||||
import 'package:notas/screens/settings_screen.dart';
|
import 'package:notas/screens/settings_screen.dart';
|
||||||
import 'package:notas/screens/vault_access_screen.dart';
|
import 'package:notas/screens/vault_access_screen.dart';
|
||||||
@@ -20,6 +23,14 @@ enum _AppSection {
|
|||||||
settings,
|
settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _AppPhase {
|
||||||
|
loading,
|
||||||
|
access,
|
||||||
|
biometricChoice,
|
||||||
|
biometricGate,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
|
||||||
class NotesApp extends StatefulWidget {
|
class NotesApp extends StatefulWidget {
|
||||||
const NotesApp({super.key});
|
const NotesApp({super.key});
|
||||||
|
|
||||||
@@ -27,43 +38,125 @@ class NotesApp extends StatefulWidget {
|
|||||||
State<NotesApp> createState() => _NotesAppState();
|
State<NotesApp> createState() => _NotesAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NotesAppState extends State<NotesApp> with WindowListener {
|
class _NotesAppState extends State<NotesApp>
|
||||||
|
with WindowListener, WidgetsBindingObserver {
|
||||||
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
||||||
|
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
||||||
|
|
||||||
final LocalVaultService _vaultService = LocalVaultService.instance;
|
final LocalVaultService _vaultService = LocalVaultService.instance;
|
||||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
||||||
GlobalKey<ScaffoldMessengerState>();
|
GlobalKey<ScaffoldMessengerState>();
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
AppDatabase? _database;
|
AppDatabase? _database;
|
||||||
NoteRepository? _repository;
|
NoteRepository? _repository;
|
||||||
|
String? _pendingEncryptionKey;
|
||||||
bool _isBootstrapping = true;
|
bool _isBootstrapping = true;
|
||||||
bool _isUnlocking = false;
|
bool _isUnlocking = false;
|
||||||
|
bool _biometricGateEnabled = false;
|
||||||
|
int _biometricGateSession = 0;
|
||||||
|
Timer? _biometricLockTimer;
|
||||||
|
bool _isHandlingWindowClose = false;
|
||||||
|
_AppPhase _phase = _AppPhase.loading;
|
||||||
_AppSection _currentSection = _AppSection.home;
|
_AppSection _currentSection = _AppSection.home;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
windowManager.addListener(this);
|
windowManager.addListener(this);
|
||||||
|
windowManager.setPreventClose(true);
|
||||||
}
|
}
|
||||||
_bootstrapVault();
|
_bootstrapVault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
|
windowManager.setPreventClose(false);
|
||||||
}
|
}
|
||||||
|
_biometricLockTimer?.cancel();
|
||||||
_database?.close();
|
_database?.close();
|
||||||
super.dispose();
|
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<void> _bootstrapVault() async {
|
Future<void> _bootstrapVault() async {
|
||||||
try {
|
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) {
|
if (encryptionKey != null) {
|
||||||
await _openVault(encryptionKey);
|
await _openVault(encryptionKey);
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_phase = _AppPhase.access;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -77,6 +170,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
Future<void> _openVault(String encryptionKey) async {
|
Future<void> _openVault(String encryptionKey) async {
|
||||||
await _database?.close();
|
await _database?.close();
|
||||||
|
|
||||||
|
try {
|
||||||
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await database.close();
|
await database.close();
|
||||||
@@ -86,7 +180,180 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_database = database;
|
_database = database;
|
||||||
_repository = NoteRepository(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<void> _beginInitialVaultFlow({String? actionLabel}) async {
|
||||||
|
if (_isUnlocking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_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<void> _enterWithoutAccount() {
|
||||||
|
return _beginInitialVaultFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<bool>(
|
||||||
|
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<void> _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() {
|
void _openSettings() {
|
||||||
@@ -116,6 +383,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
_repository = null;
|
_repository = null;
|
||||||
_database = null;
|
_database = null;
|
||||||
_isBootstrapping = true;
|
_isBootstrapping = true;
|
||||||
|
_phase = _AppPhase.loading;
|
||||||
});
|
});
|
||||||
|
|
||||||
await database?.close();
|
await database?.close();
|
||||||
@@ -145,33 +413,64 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isBootstrapping = false;
|
_isBootstrapping = false;
|
||||||
_isUnlocking = false;
|
_isUnlocking = false;
|
||||||
|
_biometricGateEnabled = false;
|
||||||
|
_pendingEncryptionKey = null;
|
||||||
|
_phase = _AppPhase.access;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _enterWithoutAccount() async {
|
Future<void> _lockVault() async {
|
||||||
if (_isUnlocking) {
|
final AppDatabase? database = _database;
|
||||||
|
|
||||||
|
if (database == null && _repository == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await database?.close();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isUnlocking = true;
|
_database = null;
|
||||||
});
|
_repository = null;
|
||||||
|
|
||||||
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;
|
_isBootstrapping = false;
|
||||||
|
_biometricGateSession += 1;
|
||||||
|
_phase = _AppPhase.biometricGate;
|
||||||
|
_currentSection = _AppSection.home;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> _allowWindowClose() async {
|
||||||
|
if (!isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await windowManager.setPreventClose(false);
|
||||||
|
await windowManager.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAccountPlaceholder(String actionLabel) {
|
void _showAccountPlaceholder(String actionLabel) {
|
||||||
@@ -199,6 +498,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
|
|
||||||
Widget _buildLoadingScreen() {
|
Widget _buildLoadingScreen() {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
title: 'Mis Notas',
|
title: 'Mis Notas',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.theme,
|
theme: AppTheme.theme,
|
||||||
@@ -228,6 +528,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
|
|
||||||
Widget _buildAppShell({required Widget home}) {
|
Widget _buildAppShell({required Widget home}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
title: 'Mis Notas',
|
title: 'Mis Notas',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||||
@@ -242,6 +543,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
key: const ValueKey<String>('home-screen'),
|
key: const ValueKey<String>('home-screen'),
|
||||||
repository: repository,
|
repository: repository,
|
||||||
onOpenSettings: _openSettings,
|
onOpenSettings: _openSettings,
|
||||||
|
onVaultInvalid: _resetLocalVaultData,
|
||||||
)
|
)
|
||||||
: SettingsScreen(
|
: SettingsScreen(
|
||||||
key: const ValueKey<String>('settings-screen'),
|
key: const ValueKey<String>('settings-screen'),
|
||||||
@@ -250,6 +552,7 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
title: 'Mis Notas',
|
title: 'Mis Notas',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||||
@@ -308,6 +611,44 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
_saveWindowSize();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isBootstrapping) {
|
if (_isBootstrapping) {
|
||||||
@@ -320,17 +661,45 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
|||||||
return _buildMainShell(repository);
|
return _buildMainShell(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (_phase) {
|
||||||
|
case _AppPhase.loading:
|
||||||
|
return _buildLoadingScreen();
|
||||||
|
case _AppPhase.access:
|
||||||
return _buildAppShell(
|
return _buildAppShell(
|
||||||
home: VaultAccessScreen(
|
home: VaultAccessScreen(
|
||||||
isBusy: _isUnlocking,
|
isBusy: _isUnlocking,
|
||||||
onCreateAccountPressed: (String email, String password) async {
|
onCreateAccountPressed: (String email, String password) async {
|
||||||
_showAccountPlaceholder('Crear cuenta');
|
await _beginInitialVaultFlow(actionLabel: 'Crear cuenta');
|
||||||
},
|
},
|
||||||
onSignInPressed: (String email, String password) async {
|
onSignInPressed: (String email, String password) async {
|
||||||
_showAccountPlaceholder('Iniciar sesión');
|
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión');
|
||||||
},
|
},
|
||||||
onContinueWithoutAccount: _enterWithoutAccount,
|
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<int>(_biometricGateSession),
|
||||||
|
isBusy: _isUnlocking,
|
||||||
|
onUnlockRequested: _unlockBiometricGate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case _AppPhase.notes:
|
||||||
|
if (repository == null) {
|
||||||
|
return _buildLoadingScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildMainShell(repository);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
class LocalVaultService {
|
class LocalVaultService {
|
||||||
LocalVaultService._();
|
LocalVaultService._();
|
||||||
@@ -9,26 +11,110 @@ class LocalVaultService {
|
|||||||
|
|
||||||
static const String _encryptionKeyStorageKey =
|
static const String _encryptionKeyStorageKey =
|
||||||
'notes_local_encryption_key_v1';
|
'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 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<String?> readEncryptionKey() async {
|
Future<String?> readEncryptionKey() async {
|
||||||
final String? cachedKey = _cachedEncryptionKey;
|
|
||||||
if (cachedKey != null) {
|
|
||||||
return cachedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String? storedKey = await _secureStorage.read(
|
final String? storedKey = await _secureStorage.read(
|
||||||
key: _encryptionKeyStorageKey,
|
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;
|
return storedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> createEncryptionKey() async {
|
Future<String?> readStoredEncryptionKeyRaw() {
|
||||||
|
return _secureStorage.read(key: _encryptionKeyStorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> hasEncryptionKey() async {
|
||||||
|
return (await readStoredEncryptionKeyRaw()) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isVaultAccessCompleted() async {
|
||||||
|
return (await _secureStorage.read(key: _vaultAccessCompletedKey)) == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVaultAccessCompleted(bool value) async {
|
||||||
|
if (value) {
|
||||||
|
await _secureStorage.write(key: _vaultAccessCompletedKey, value: '1');
|
||||||
|
} else {
|
||||||
|
await _secureStorage.delete(key: _vaultAccessCompletedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isBiometricChoicePending() async {
|
||||||
|
return (await _secureStorage.read(key: _biometricChoicePendingKey)) == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBiometricChoicePending(bool value) async {
|
||||||
|
if (value) {
|
||||||
|
await _secureStorage.write(key: _biometricChoicePendingKey, value: '1');
|
||||||
|
} else {
|
||||||
|
await _secureStorage.delete(key: _biometricChoicePendingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isBiometricGateEnabled() async {
|
||||||
|
return (await _secureStorage.read(key: _biometricGateEnabledKey)) == '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBiometricGateEnabled(bool value) async {
|
||||||
|
if (value) {
|
||||||
|
await _secureStorage.write(key: _biometricGateEnabledKey, value: '1');
|
||||||
|
} else {
|
||||||
|
await _secureStorage.delete(key: _biometricGateEnabledKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
|
||||||
final String encryptionKey = _generateEncryptionKey();
|
final String encryptionKey = _generateEncryptionKey();
|
||||||
|
|
||||||
await _secureStorage.write(
|
await _secureStorage.write(
|
||||||
@@ -36,13 +122,86 @@ class LocalVaultService {
|
|||||||
value: encryptionKey,
|
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;
|
return encryptionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearEncryptionKey() async {
|
Future<void> clearEncryptionKey() async {
|
||||||
await _secureStorage.delete(key: _encryptionKeyStorageKey);
|
await _secureStorage.delete(key: _encryptionKeyStorageKey);
|
||||||
_cachedEncryptionKey = null;
|
await _secureStorage.delete(key: _vaultAccessCompletedKey);
|
||||||
|
await _secureStorage.delete(key: _biometricChoicePendingKey);
|
||||||
|
await _secureStorage.delete(key: _biometricGateEnabledKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<BiometricType> 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<bool> 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() {
|
String _generateEncryptionKey() {
|
||||||
|
|||||||
@@ -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<void> Function() onEnableBiometrics;
|
||||||
|
final Future<void> 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> Function() onUnlockRequested;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BiometricGateScreen> createState() => _BiometricGateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BiometricGateScreenState extends State<BiometricGateScreen> {
|
||||||
|
bool _autoRequested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_requestUnlockOnce();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,12 @@ class HomeScreen extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.repository,
|
required this.repository,
|
||||||
required this.onOpenSettings,
|
required this.onOpenSettings,
|
||||||
|
this.onVaultInvalid,
|
||||||
});
|
});
|
||||||
|
|
||||||
final NoteRepository repository;
|
final NoteRepository repository;
|
||||||
final VoidCallback onOpenSettings;
|
final VoidCallback onOpenSettings;
|
||||||
|
final Future<void> Function()? onVaultInvalid;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
@@ -38,16 +40,21 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadNotes() async {
|
Future<void> _loadNotes() async {
|
||||||
|
try {
|
||||||
final List<Note> storedNotes = await widget.repository.loadNotes();
|
final List<Note> storedNotes = await widget.repository.loadNotes();
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_notes = storedNotes;
|
_notes = storedNotes;
|
||||||
_isLoading = false;
|
_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!();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openNoteComposer() async {
|
Future<void> _openNoteComposer() async {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import local_auth_darwin
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
|
|||||||
+52
-4
@@ -246,6 +246,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -356,10 +364,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.20.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -388,10 +396,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.11.0"
|
version: "4.12.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -424,6 +432,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-1
@@ -40,8 +40,9 @@ dependencies:
|
|||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
flutter_secure_storage: ^10.2.0
|
flutter_secure_storage: ^10.2.0
|
||||||
|
local_auth: ^3.0.1
|
||||||
sqlite3: ^3.3.1
|
sqlite3: ^3.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
#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>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||||
WindowManagerPluginRegisterWithRegistrar(
|
WindowManagerPluginRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -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
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user