1251 lines
35 KiB
Dart
1251 lines
35 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:flutter_quill/flutter_quill.dart';
|
|
import 'package:notas/data/app_database.dart';
|
|
import 'package:notas/data/api_client.dart';
|
|
import 'package:notas/data/local_vault_service.dart';
|
|
import 'package:notas/data/note_repository.dart';
|
|
import 'package:notas/platform/app_platform.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/settings_screen.dart';
|
|
import 'package:notas/screens/vault_access_screen.dart';
|
|
import 'package:notas/theme/app_palette.dart';
|
|
import 'package:notas/theme/app_theme.dart';
|
|
import 'package:notas/widgets/sync_status.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
|
|
enum _AppSection { home, settings }
|
|
|
|
enum _AppPhase { loading, access, biometricChoice, biometricGate, notes }
|
|
|
|
class PerformSyncIntent extends Intent {
|
|
const PerformSyncIntent();
|
|
}
|
|
|
|
class NotesApp extends StatefulWidget {
|
|
const NotesApp({super.key});
|
|
|
|
@override
|
|
State<NotesApp> createState() => _NotesAppState();
|
|
}
|
|
|
|
class _NotesAppState extends State<NotesApp>
|
|
with WindowListener, WidgetsBindingObserver {
|
|
static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
|
|
static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
|
|
static const Duration _syncInterval = Duration(minutes: 5);
|
|
static const Duration _windowSizeSaveDelay = Duration(milliseconds: 350);
|
|
static const String _themeSeedColorKey = 'theme_seed_color_v1';
|
|
static const String _themeModeKey = 'theme_mode_v1';
|
|
|
|
final LocalVaultService _vaultService = LocalVaultService.instance;
|
|
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
|
GlobalKey<ScaffoldMessengerState>();
|
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
AppDatabase? _database;
|
|
NoteRepository? _repository;
|
|
String? _pendingEncryptionKey;
|
|
bool _isBootstrapping = true;
|
|
bool _isUnlocking = false;
|
|
bool _biometricGateEnabled = false;
|
|
int _biometricGateSession = 0;
|
|
Timer? _biometricLockTimer;
|
|
Timer? _windowSizeSaveTimer;
|
|
Timer? _syncTimer;
|
|
bool _isHandlingWindowClose = false;
|
|
_AppPhase _phase = _AppPhase.loading;
|
|
_AppSection _currentSection = _AppSection.home;
|
|
SyncStatus _syncStatus = SyncStatus.idle;
|
|
double? _syncProgress;
|
|
String? _syncDetailMessage;
|
|
String? _syncErrorMessage;
|
|
int _syncOperationId = 0;
|
|
int _homeRefreshToken = 0;
|
|
Color _themeSeedColor = Colors.amber;
|
|
ThemeMode _themeMode = ThemeMode.system;
|
|
|
|
// Cached ThemeData for light and dark variants.
|
|
ThemeData? _lightTheme;
|
|
ThemeData? _darkTheme;
|
|
|
|
bool _isSyncBannerVisible() {
|
|
switch (_syncStatus) {
|
|
case SyncStatus.preparing:
|
|
case SyncStatus.encrypting:
|
|
case SyncStatus.uploading:
|
|
case SyncStatus.waitingResponse:
|
|
case SyncStatus.decrypting:
|
|
case SyncStatus.syncing:
|
|
case SyncStatus.synced:
|
|
case SyncStatus.error:
|
|
return true;
|
|
case SyncStatus.idle:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Widget _buildSyncBanner(BuildContext context) {
|
|
if (!_isSyncBannerVisible()) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final AppPalette palette = _activePalette();
|
|
final String message =
|
|
_syncErrorMessage ?? _syncDetailMessage ?? 'Sincronizando...';
|
|
final double? progress = _syncProgress;
|
|
final IconData icon;
|
|
final Color accentColor;
|
|
|
|
switch (_syncStatus) {
|
|
case SyncStatus.preparing:
|
|
case SyncStatus.encrypting:
|
|
case SyncStatus.uploading:
|
|
case SyncStatus.waitingResponse:
|
|
case SyncStatus.decrypting:
|
|
case SyncStatus.syncing:
|
|
icon = Icons.cloud_sync_outlined;
|
|
accentColor = palette.textSecondary;
|
|
break;
|
|
case SyncStatus.synced:
|
|
icon = Icons.check_circle;
|
|
accentColor = palette.success;
|
|
break;
|
|
case SyncStatus.error:
|
|
icon = Icons.error;
|
|
accentColor = palette.destructiveAccent;
|
|
break;
|
|
case SyncStatus.idle:
|
|
icon = Icons.cloud_sync_outlined;
|
|
accentColor = palette.textSecondary;
|
|
break;
|
|
}
|
|
|
|
return Material(
|
|
color: palette.surfaceElevated,
|
|
elevation: 12,
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(color: accentColor.withValues(alpha: 0.45)),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: accentColor, size: 18),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: palette.textPrimary,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_syncStatus == SyncStatus.preparing ||
|
|
_syncStatus == SyncStatus.encrypting ||
|
|
_syncStatus == SyncStatus.uploading ||
|
|
_syncStatus == SyncStatus.waitingResponse ||
|
|
_syncStatus == SyncStatus.decrypting ||
|
|
_syncStatus == SyncStatus.syncing) ...[
|
|
const SizedBox(height: 8),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: LinearProgressIndicator(
|
|
minHeight: 4,
|
|
value: progress,
|
|
backgroundColor: palette.borderMuted,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Brightness _effectiveBrightness() {
|
|
switch (_themeMode) {
|
|
case ThemeMode.dark:
|
|
return Brightness.dark;
|
|
case ThemeMode.light:
|
|
return Brightness.light;
|
|
case ThemeMode.system:
|
|
return WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
|
}
|
|
}
|
|
|
|
AppPalette _activePalette() {
|
|
return AppPalette.fromBrightness(
|
|
_effectiveBrightness(),
|
|
seedColor: _themeSeedColor,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
if (isDesktop) {
|
|
windowManager.addListener(this);
|
|
windowManager.setPreventClose(true);
|
|
}
|
|
_loadThemeSeedColor();
|
|
_loadThemeMode();
|
|
_bootstrapVault();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
if (isDesktop) {
|
|
windowManager.removeListener(this);
|
|
windowManager.setPreventClose(false);
|
|
}
|
|
_biometricLockTimer?.cancel();
|
|
_windowSizeSaveTimer?.cancel();
|
|
_syncTimer?.cancel();
|
|
_database?.close();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadThemeSeedColor() async {
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
final int? storedColorValue = prefs.getInt(_themeSeedColorKey);
|
|
if (storedColorValue == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_themeSeedColor = Color(storedColorValue);
|
|
_updateThemeData();
|
|
});
|
|
}
|
|
|
|
Future<void> _setThemeSeedColor(Color color) async {
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.setInt(_themeSeedColorKey, color.toARGB32());
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_themeSeedColor = color;
|
|
_updateThemeData();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadThemeMode() async {
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
final int? stored = prefs.getInt(_themeModeKey);
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (stored == null) {
|
|
_themeMode = ThemeMode.system;
|
|
} else if (stored == 1) {
|
|
_themeMode = ThemeMode.light;
|
|
} else if (stored == 2) {
|
|
_themeMode = ThemeMode.dark;
|
|
} else {
|
|
_themeMode = ThemeMode.system;
|
|
}
|
|
_updateThemeData();
|
|
});
|
|
}
|
|
|
|
Future<void> _setThemeMode(ThemeMode mode) async {
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
final int stored = mode == ThemeMode.system
|
|
? 0
|
|
: (mode == ThemeMode.light ? 1 : 2);
|
|
await prefs.setInt(_themeModeKey, stored);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_themeMode = mode;
|
|
});
|
|
}
|
|
|
|
void _updateThemeData() {
|
|
_lightTheme = AppTheme.theme(
|
|
seedColor: _themeSeedColor,
|
|
brightness: Brightness.light,
|
|
);
|
|
_darkTheme = AppTheme.theme(
|
|
seedColor: _themeSeedColor,
|
|
brightness: Brightness.dark,
|
|
);
|
|
// Updated light/dark themes regenerated
|
|
}
|
|
|
|
@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 {
|
|
try {
|
|
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) {
|
|
setState(() {
|
|
_isBootstrapping = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _openVault(String encryptionKey) async {
|
|
await _database?.close();
|
|
|
|
try {
|
|
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
|
if (!mounted) {
|
|
await database.close();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_database = database;
|
|
_repository = NoteRepository(
|
|
database: database,
|
|
authApi: AuthApi.instance,
|
|
masterKey: encryptionKey,
|
|
);
|
|
_phase = _AppPhase.notes;
|
|
});
|
|
|
|
// Start periodic sync
|
|
_startPeriodicSync();
|
|
// Run an initial sync immediately and let the repository use the
|
|
// stored lastSyncAt when it exists.
|
|
unawaited(_performSync());
|
|
} 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> _beginRemoteVaultFlow({
|
|
required String username,
|
|
required String password,
|
|
required bool isRegister,
|
|
}) async {
|
|
if (_isUnlocking) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isUnlocking = true;
|
|
});
|
|
|
|
try {
|
|
if (isRegister) {
|
|
final String encryptionKey = _vaultService.generateEncryptionKey();
|
|
final String encryptedMasterKey = await AuthApi.instance
|
|
.encryptWithPassword(encryptionKey, password);
|
|
|
|
final Map<String, dynamic> response = await AuthApi.instance.register(
|
|
username,
|
|
password,
|
|
encryptedMasterKey: encryptedMasterKey,
|
|
);
|
|
|
|
if (response['error'] == true) {
|
|
throw StateError('No se pudo registrar el usuario.');
|
|
}
|
|
|
|
await _vaultService.storeEncryptionKey(encryptionKey);
|
|
_pendingEncryptionKey = encryptionKey;
|
|
} else {
|
|
final Map<String, dynamic> response = await AuthApi.instance.login(
|
|
username,
|
|
password,
|
|
);
|
|
|
|
if (response['error'] == true) {
|
|
throw StateError('No se pudo iniciar sesión.');
|
|
}
|
|
|
|
final String? encryptedMasterKey =
|
|
(response['encrypted_master_key'] as String?) ??
|
|
(response['encryptedMasterKey'] as String?);
|
|
|
|
if (encryptedMasterKey == null || encryptedMasterKey.isEmpty) {
|
|
throw StateError('La API no devolvió la clave de encriptación.');
|
|
}
|
|
|
|
final String encryptionKey = await AuthApi.instance.decryptWithPassword(
|
|
encryptedMasterKey,
|
|
password,
|
|
);
|
|
|
|
await _vaultService.storeEncryptionKey(encryptionKey);
|
|
_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 completar la autenticación: $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 - pass currentContext directly in builder
|
|
final NavigatorState? navigator = _navigatorKey.currentState;
|
|
|
|
if (navigator == null) break;
|
|
if (!mounted) return;
|
|
|
|
final bool? retry = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext dialogContext) {
|
|
final AppPalette palette = _activePalette();
|
|
return AlertDialog(
|
|
backgroundColor: palette.cardBackground,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: palette.border),
|
|
),
|
|
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() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_currentSection = _AppSection.settings;
|
|
});
|
|
}
|
|
|
|
void _openHome() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_currentSection = _AppSection.home;
|
|
});
|
|
}
|
|
|
|
Future<void> _resetLocalVaultData() async {
|
|
final AppDatabase? database = _database;
|
|
|
|
setState(() {
|
|
_repository = null;
|
|
_database = null;
|
|
_isBootstrapping = true;
|
|
_phase = _AppPhase.loading;
|
|
});
|
|
|
|
await database?.close();
|
|
|
|
await _vaultService.clearEncryptionKey();
|
|
await AuthApi.instance.clearTokens();
|
|
|
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
await prefs.clear();
|
|
|
|
final Directory supportDir = await getApplicationSupportDirectory();
|
|
final String dbPath = p.join(supportDir.path, 'notes.sqlite');
|
|
final List<String> filesToDelete = <String>[
|
|
dbPath,
|
|
'$dbPath-wal',
|
|
'$dbPath-shm',
|
|
'$dbPath-journal',
|
|
];
|
|
|
|
for (final String filePath in filesToDelete) {
|
|
final File file = File(filePath);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isBootstrapping = false;
|
|
_isUnlocking = false;
|
|
_biometricGateEnabled = false;
|
|
_pendingEncryptionKey = null;
|
|
_phase = _AppPhase.access;
|
|
});
|
|
}
|
|
|
|
Future<void> _lockVault() async {
|
|
final AppDatabase? database = _database;
|
|
|
|
if (database == null && _repository == null) {
|
|
return;
|
|
}
|
|
|
|
await database?.close();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_database = null;
|
|
_repository = null;
|
|
_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) {
|
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'$actionLabel todavía no está conectado con la API. Usa "Entrar sin cuenta" para empezar en local.',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _saveWindowSize() async {
|
|
if (await windowManager.isFullScreen()) {
|
|
return;
|
|
}
|
|
|
|
if (await windowManager.isMaximized()) {
|
|
return;
|
|
}
|
|
|
|
final Size currentSize = await windowManager.getSize();
|
|
await WindowStateStore.instance.saveWindowSize(currentSize);
|
|
}
|
|
|
|
void _scheduleWindowSizeSave() {
|
|
if (!isDesktop) {
|
|
return;
|
|
}
|
|
|
|
_windowSizeSaveTimer?.cancel();
|
|
_windowSizeSaveTimer = Timer(_windowSizeSaveDelay, () {
|
|
_windowSizeSaveTimer = null;
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
unawaited(_saveWindowSize());
|
|
});
|
|
}
|
|
|
|
void _startPeriodicSync() {
|
|
_syncTimer?.cancel();
|
|
_syncTimer = Timer.periodic(_syncInterval, (_) {
|
|
_performSync();
|
|
});
|
|
}
|
|
|
|
Future<void> _performSync({bool forceFull = false}) async {
|
|
if (_repository == null) {
|
|
return;
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (_syncStatus == SyncStatus.preparing ||
|
|
_syncStatus == SyncStatus.encrypting ||
|
|
_syncStatus == SyncStatus.uploading ||
|
|
_syncStatus == SyncStatus.waitingResponse ||
|
|
_syncStatus == SyncStatus.decrypting ||
|
|
_syncStatus == SyncStatus.syncing) {
|
|
return;
|
|
}
|
|
|
|
final int syncOperationId = ++_syncOperationId;
|
|
|
|
setState(() {
|
|
_syncStatus = SyncStatus.preparing;
|
|
_syncProgress = null;
|
|
_syncDetailMessage = 'Preparando sincronización...';
|
|
_syncErrorMessage = null;
|
|
});
|
|
|
|
void updateSyncState(
|
|
SyncStatus status, {
|
|
double? progress,
|
|
String? message,
|
|
}) {
|
|
if (!mounted || syncOperationId != _syncOperationId) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_syncStatus = status;
|
|
_syncProgress = progress;
|
|
_syncDetailMessage = message;
|
|
if (status != SyncStatus.error) {
|
|
_syncErrorMessage = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
final Map<String, dynamic> result = await _repository!.performSync(
|
|
forceFull: forceFull,
|
|
onProgress: updateSyncState,
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result['error'] == true) {
|
|
setState(() {
|
|
_syncStatus = SyncStatus.error;
|
|
_syncErrorMessage = result['message'] as String?;
|
|
_syncProgress = null;
|
|
_syncDetailMessage = null;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_syncStatus = SyncStatus.synced;
|
|
_syncErrorMessage = null;
|
|
_syncProgress = null;
|
|
_syncDetailMessage = 'Sincronización completada';
|
|
_homeRefreshToken += 1;
|
|
});
|
|
|
|
// Keep the completion state visible briefly so it can be read.
|
|
Future<void>.delayed(const Duration(seconds: 1), () {
|
|
if (mounted && syncOperationId == _syncOperationId) {
|
|
setState(() {
|
|
_syncStatus = SyncStatus.idle;
|
|
_syncProgress = null;
|
|
_syncDetailMessage = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} catch (e, st) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_syncStatus = SyncStatus.error;
|
|
_syncErrorMessage = '$e\n\nStackTrace: $st';
|
|
_syncProgress = null;
|
|
_syncDetailMessage = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildLoadingScreen() {
|
|
return const Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Preparando el vault local...'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppShell({required Widget home}) {
|
|
return home;
|
|
}
|
|
|
|
Widget _buildMainShell(NoteRepository repository) {
|
|
final AppPalette palette = _activePalette();
|
|
final Widget activeScreen = _currentSection == _AppSection.home
|
|
? HomeScreen(
|
|
key: const ValueKey<String>('home-screen'),
|
|
repository: repository,
|
|
onOpenSettings: _openSettings,
|
|
onRequestSync: _performSync,
|
|
onVaultInvalid: _resetLocalVaultData,
|
|
syncStatus: _syncStatus,
|
|
syncProgress: _syncProgress,
|
|
syncDetailMessage: _syncDetailMessage,
|
|
syncErrorMessage: _syncErrorMessage,
|
|
refreshToken: _homeRefreshToken,
|
|
)
|
|
: SettingsScreen(
|
|
key: const ValueKey<String>('settings-screen'),
|
|
onDeleteAllData: _resetLocalVaultData,
|
|
onBackToHome: _openHome,
|
|
onForceSync: () => _performSync(forceFull: true),
|
|
currentSeedColor: _themeSeedColor,
|
|
onThemeColorSelected: _setThemeSeedColor,
|
|
currentThemeMode: _themeMode,
|
|
onThemeModeSelected: _setThemeMode,
|
|
);
|
|
|
|
return Shortcuts(
|
|
shortcuts: <LogicalKeySet, Intent>{
|
|
LogicalKeySet(LogicalKeyboardKey.f5): const PerformSyncIntent(),
|
|
},
|
|
child: Actions(
|
|
actions: <Type, Action<Intent>>{
|
|
PerformSyncIntent: CallbackAction<PerformSyncIntent>(
|
|
onInvoke: (PerformSyncIntent intent) {
|
|
_performSync();
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
child: Focus(
|
|
autofocus: true,
|
|
child: Scaffold(
|
|
body: Container(
|
|
decoration: BoxDecoration(gradient: palette.backdropGradient),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: _screenTransitionDuration,
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder:
|
|
(Widget child, Animation<double> animation) {
|
|
final Animation<Offset> offsetAnimation =
|
|
Tween<Offset>(
|
|
begin: const Offset(0.08, 0.0),
|
|
end: Offset.zero,
|
|
).animate(animation);
|
|
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: offsetAnimation,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: activeScreen,
|
|
),
|
|
),
|
|
AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder:
|
|
(Widget child, Animation<double> animation) {
|
|
final Animation<Offset> slideAnimation =
|
|
Tween<Offset>(
|
|
begin: const Offset(0.0, 0.35),
|
|
end: Offset.zero,
|
|
).animate(animation);
|
|
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: slideAnimation,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: _buildSyncBanner(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void onWindowResize() {
|
|
_scheduleWindowSizeSave();
|
|
}
|
|
|
|
@override
|
|
void onWindowResized() {
|
|
_scheduleWindowSizeSave();
|
|
}
|
|
|
|
@override
|
|
void onWindowFocus() {
|
|
_cancelBiometricLockTimer();
|
|
}
|
|
|
|
@override
|
|
void onWindowBlur() {
|
|
_scheduleBiometricLockTimer();
|
|
}
|
|
|
|
@override
|
|
void onWindowClose() {
|
|
if (_isHandlingWindowClose) {
|
|
return;
|
|
}
|
|
|
|
_windowSizeSaveTimer?.cancel();
|
|
_windowSizeSaveTimer = null;
|
|
unawaited(_saveWindowSize());
|
|
|
|
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) {
|
|
Widget homeWidget;
|
|
|
|
if (_isBootstrapping) {
|
|
homeWidget = _buildLoadingScreen();
|
|
} else {
|
|
final NoteRepository? repository = _repository;
|
|
|
|
if (repository != null) {
|
|
homeWidget = _buildMainShell(repository);
|
|
} else {
|
|
switch (_phase) {
|
|
case _AppPhase.loading:
|
|
homeWidget = _buildLoadingScreen();
|
|
break;
|
|
case _AppPhase.access:
|
|
homeWidget = _buildAppShell(
|
|
home: VaultAccessScreen(
|
|
isBusy: _isUnlocking,
|
|
onCreateAccountPressed: (String email, String password) async {
|
|
await _beginRemoteVaultFlow(
|
|
username: email,
|
|
password: password,
|
|
isRegister: true,
|
|
);
|
|
},
|
|
onSignInPressed: (String email, String password) async {
|
|
await _beginRemoteVaultFlow(
|
|
username: email,
|
|
password: password,
|
|
isRegister: false,
|
|
);
|
|
},
|
|
onContinueWithoutAccount: _enterWithoutAccount,
|
|
),
|
|
);
|
|
break;
|
|
case _AppPhase.biometricChoice:
|
|
homeWidget = _buildAppShell(
|
|
home: BiometricChoiceScreen(
|
|
isBusy: _isUnlocking,
|
|
onEnableBiometrics: () =>
|
|
_completeBiometricChoice(enableBiometrics: true),
|
|
onSkipBiometrics: () =>
|
|
_completeBiometricChoice(enableBiometrics: false),
|
|
),
|
|
);
|
|
break;
|
|
case _AppPhase.biometricGate:
|
|
homeWidget = _buildAppShell(
|
|
home: BiometricGateScreen(
|
|
key: ValueKey<int>(_biometricGateSession),
|
|
isBusy: _isUnlocking,
|
|
onUnlockRequested: _unlockBiometricGate,
|
|
),
|
|
);
|
|
break;
|
|
case _AppPhase.notes:
|
|
homeWidget = _buildLoadingScreen();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return MaterialApp(
|
|
navigatorKey: _navigatorKey,
|
|
title: 'Mis Notas',
|
|
debugShowCheckedModeBanner: false,
|
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
|
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
FlutterQuillLocalizations.delegate,
|
|
],
|
|
theme:
|
|
_lightTheme ??
|
|
AppTheme.theme(
|
|
seedColor: _themeSeedColor,
|
|
brightness: Brightness.light,
|
|
),
|
|
darkTheme:
|
|
_darkTheme ??
|
|
AppTheme.theme(
|
|
seedColor: _themeSeedColor,
|
|
brightness: Brightness.dark,
|
|
),
|
|
themeMode: _themeMode,
|
|
home: homeWidget,
|
|
);
|
|
}
|
|
}
|