feat: Implement note encryption and synchronization features

- Added NoteEncryption class for encrypting and decrypting note content using AES-GCM.
- Updated NoteRepository to handle synchronization of notes and categories with the server, including encryption of note data before sending.
- Introduced SyncRequest and SyncResponse models for managing synchronization data.
- Enhanced LocalVaultService to store and retrieve the encryption key.
- Modified HomeScreen and SettingsScreen to trigger synchronization after note operations and manage API endpoint settings.
- Added SyncStatusIndicator to provide visual feedback on synchronization status in the app title bar.
- Created Category model to manage note categories with encryption support.
- Updated note model to include UUID, server version, deletion status, and category ID.
- Added necessary UI elements for displaying and managing the encryption key in SettingsScreen.
- Updated dependencies in pubspec.yaml for cryptography and HTTP handling.
This commit is contained in:
2026-05-18 16:11:19 +02:00
parent 516b3b9aa3
commit efe602a5da
18 changed files with 2531 additions and 71 deletions
+196 -6
View File
@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
@@ -14,6 +16,7 @@ import 'package:notas/screens/settings_screen.dart';
import 'package:notas/screens/vault_access_screen.dart';
import 'package:notas/theme/app_theme.dart';
import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/widgets/sync_status_indicator.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart';
@@ -31,6 +34,10 @@ enum _AppPhase {
notes,
}
class PerformSyncIntent extends Intent {
const PerformSyncIntent();
}
class NotesApp extends StatefulWidget {
const NotesApp({super.key});
@@ -42,6 +49,7 @@ 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);
final LocalVaultService _vaultService = LocalVaultService.instance;
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
@@ -56,9 +64,12 @@ class _NotesAppState extends State<NotesApp>
bool _biometricGateEnabled = false;
int _biometricGateSession = 0;
Timer? _biometricLockTimer;
Timer? _syncTimer;
bool _isHandlingWindowClose = false;
_AppPhase _phase = _AppPhase.loading;
_AppSection _currentSection = _AppSection.home;
SyncStatus _syncStatus = SyncStatus.idle;
String? _syncErrorMessage;
@override
void initState() {
@@ -79,6 +90,7 @@ class _NotesAppState extends State<NotesApp>
windowManager.setPreventClose(false);
}
_biometricLockTimer?.cancel();
_syncTimer?.cancel();
_database?.close();
super.dispose();
}
@@ -179,9 +191,18 @@ class _NotesAppState extends State<NotesApp>
setState(() {
_database = database;
_repository = NoteRepository(database: database);
_repository = NoteRepository(
database: database,
authApi: AuthApi.instance,
masterKey: encryptionKey,
);
_phase = _AppPhase.notes;
});
// Start periodic sync
_startPeriodicSync();
// Run an initial full sync immediately to pull server changes
unawaited(_performSync(forceFull: true));
} 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
@@ -238,6 +259,86 @@ class _NotesAppState extends State<NotesApp>
}
}
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();
}
@@ -496,6 +597,66 @@ class _NotesAppState extends State<NotesApp>
await WindowStateStore.instance.saveWindowSize(currentSize);
}
void _startPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(_syncInterval, (_) {
_performSync();
});
}
Future<void> _performSync({bool forceFull = false}) async {
if (_repository == null) {
return;
}
if (!mounted) {
return;
}
setState(() {
_syncStatus = SyncStatus.syncing;
_syncErrorMessage = null;
});
try {
final Map<String, dynamic> result = await _repository!.performSync(forceFull: forceFull);
if (!mounted) {
return;
}
if (result['error'] == true) {
setState(() {
_syncStatus = SyncStatus.error;
_syncErrorMessage = result['message'] as String?;
});
} else {
setState(() {
_syncStatus = SyncStatus.synced;
_syncErrorMessage = null;
});
// Reset to idle after 3 seconds
Future<void>.delayed(const Duration(seconds: 3), () {
if (mounted) {
setState(() {
_syncStatus = SyncStatus.idle;
});
}
});
}
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_syncStatus = SyncStatus.error;
_syncErrorMessage = e.toString();
});
}
}
Widget _buildLoadingScreen() {
return MaterialApp(
navigatorKey: _navigatorKey,
@@ -557,8 +718,23 @@ class _NotesAppState extends State<NotesApp>
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey,
theme: AppTheme.theme,
home: Scaffold(
body: Container(
home: 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: const BoxDecoration(
gradient: LinearGradient(
colors: [
@@ -573,7 +749,10 @@ class _NotesAppState extends State<NotesApp>
child: SafeArea(
child: Column(
children: [
const AppTitleBar(),
AppTitleBar(
syncStatus: _syncStatus,
syncErrorMessage: _syncErrorMessage,
),
Expanded(
child: AnimatedSwitcher(
duration: _screenTransitionDuration,
@@ -598,6 +777,9 @@ class _NotesAppState extends State<NotesApp>
),
),
),
),
),
),
);
}
@@ -669,10 +851,18 @@ class _NotesAppState extends State<NotesApp>
home: VaultAccessScreen(
isBusy: _isUnlocking,
onCreateAccountPressed: (String email, String password) async {
await _beginInitialVaultFlow(actionLabel: 'Crear cuenta');
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: true,
);
},
onSignInPressed: (String email, String password) async {
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión');
await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: false,
);
},
onContinueWithoutAccount: _enterWithoutAccount,
),