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 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:notas/data/app_database.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/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';
@@ -14,6 +16,7 @@ import 'package:notas/screens/settings_screen.dart';
import 'package:notas/screens/vault_access_screen.dart'; import 'package:notas/screens/vault_access_screen.dart';
import 'package:notas/theme/app_theme.dart'; import 'package:notas/theme/app_theme.dart';
import 'package:notas/widgets/app_title_bar.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/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -31,6 +34,10 @@ enum _AppPhase {
notes, notes,
} }
class PerformSyncIntent extends Intent {
const PerformSyncIntent();
}
class NotesApp extends StatefulWidget { class NotesApp extends StatefulWidget {
const NotesApp({super.key}); const NotesApp({super.key});
@@ -42,6 +49,7 @@ class _NotesAppState extends State<NotesApp>
with WindowListener, WidgetsBindingObserver { with WindowListener, WidgetsBindingObserver {
static const Duration _screenTransitionDuration = Duration(milliseconds: 280); static const Duration _screenTransitionDuration = Duration(milliseconds: 280);
static const Duration _biometricInactivityTimeout = Duration(minutes: 5); static const Duration _biometricInactivityTimeout = Duration(minutes: 5);
static const Duration _syncInterval = Duration(minutes: 5);
final LocalVaultService _vaultService = LocalVaultService.instance; final LocalVaultService _vaultService = LocalVaultService.instance;
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey = final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
@@ -56,9 +64,12 @@ class _NotesAppState extends State<NotesApp>
bool _biometricGateEnabled = false; bool _biometricGateEnabled = false;
int _biometricGateSession = 0; int _biometricGateSession = 0;
Timer? _biometricLockTimer; Timer? _biometricLockTimer;
Timer? _syncTimer;
bool _isHandlingWindowClose = false; bool _isHandlingWindowClose = false;
_AppPhase _phase = _AppPhase.loading; _AppPhase _phase = _AppPhase.loading;
_AppSection _currentSection = _AppSection.home; _AppSection _currentSection = _AppSection.home;
SyncStatus _syncStatus = SyncStatus.idle;
String? _syncErrorMessage;
@override @override
void initState() { void initState() {
@@ -79,6 +90,7 @@ class _NotesAppState extends State<NotesApp>
windowManager.setPreventClose(false); windowManager.setPreventClose(false);
} }
_biometricLockTimer?.cancel(); _biometricLockTimer?.cancel();
_syncTimer?.cancel();
_database?.close(); _database?.close();
super.dispose(); super.dispose();
} }
@@ -179,9 +191,18 @@ class _NotesAppState extends State<NotesApp>
setState(() { setState(() {
_database = database; _database = database;
_repository = NoteRepository(database: database); _repository = NoteRepository(
database: database,
authApi: AuthApi.instance,
masterKey: encryptionKey,
);
_phase = _AppPhase.notes; _phase = _AppPhase.notes;
}); });
// Start periodic sync
_startPeriodicSync();
// Run an initial full sync immediately to pull server changes
unawaited(_performSync(forceFull: true));
} catch (e) { } catch (e) {
// If the database file is not a valid SQLite DB (e.g., wrong key or corruption), // 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 // 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() { Future<void> _enterWithoutAccount() {
return _beginInitialVaultFlow(); return _beginInitialVaultFlow();
} }
@@ -496,6 +597,66 @@ class _NotesAppState extends State<NotesApp>
await WindowStateStore.instance.saveWindowSize(currentSize); 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() { Widget _buildLoadingScreen() {
return MaterialApp( return MaterialApp(
navigatorKey: _navigatorKey, navigatorKey: _navigatorKey,
@@ -557,8 +718,23 @@ class _NotesAppState extends State<NotesApp>
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey, scaffoldMessengerKey: _scaffoldMessengerKey,
theme: AppTheme.theme, theme: AppTheme.theme,
home: Scaffold( home: Shortcuts(
body: Container( 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( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
@@ -573,7 +749,10 @@ class _NotesAppState extends State<NotesApp>
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
const AppTitleBar(), AppTitleBar(
syncStatus: _syncStatus,
syncErrorMessage: _syncErrorMessage,
),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: _screenTransitionDuration, duration: _screenTransitionDuration,
@@ -598,6 +777,9 @@ class _NotesAppState extends State<NotesApp>
), ),
), ),
), ),
),
),
),
); );
} }
@@ -669,10 +851,18 @@ class _NotesAppState extends State<NotesApp>
home: VaultAccessScreen( home: VaultAccessScreen(
isBusy: _isUnlocking, isBusy: _isUnlocking,
onCreateAccountPressed: (String email, String password) async { 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 { onSignInPressed: (String email, String password) async {
await _beginInitialVaultFlow(actionLabel: 'Iniciar sesión'); await _beginRemoteVaultFlow(
username: email,
password: password,
isRegister: false,
);
}, },
onContinueWithoutAccount: _enterWithoutAccount, onContinueWithoutAccount: _enterWithoutAccount,
), ),
+332
View File
@@ -0,0 +1,332 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:crypto/crypto.dart' as crypto;
import 'package:cryptography/cryptography.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:notas/data/sync_models.dart';
class ApiConfig {
ApiConfig._();
static const String _endpointKey = 'api_endpoint_v1';
/// Default endpoint for local development. Can be overridden by user.
static const String defaultEndpoint = 'http://localhost:3000/api';
static Future<String> getEndpoint() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_endpointKey) ?? defaultEndpoint;
}
static Future<void> setEndpoint(String endpoint) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_endpointKey, endpoint);
}
static Future<void> clearEndpoint() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_endpointKey);
}
}
class AuthApi {
AuthApi._();
static final AuthApi instance = AuthApi._();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
static const String _accessTokenKey = 'api_access_token_v1';
static const String _refreshTokenKey = 'api_refresh_token_v1';
static const int _passwordHashVersion = 1;
static const int _kdfIterations = 100000;
static final Pbkdf2 _kdf = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: 256,
);
static final AesGcm _aes = AesGcm.with256bits();
Future<String?> get accessToken async =>
await _secureStorage.read(key: _accessTokenKey);
Future<String?> get refreshToken async =>
await _secureStorage.read(key: _refreshTokenKey);
Future<void> clearTokens() async {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
}
String hashPassword(String password) {
return crypto.sha256.convert(utf8.encode(password)).toString();
}
Future<String> encryptWithPassword(
String plaintext,
String password,
) async {
final List<int> salt = _randomBytes(16);
final SecretKey secretKey = await _kdf.deriveKey(
secretKey: SecretKey(utf8.encode(password)),
nonce: salt,
);
final List<int> nonce = _randomBytes(12);
final SecretBox box = await _aes.encrypt(
utf8.encode(plaintext),
secretKey: secretKey,
nonce: nonce,
);
return jsonEncode(<String, dynamic>{
'v': _passwordHashVersion,
'salt': base64Encode(salt),
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
}
Future<String> decryptWithPassword(
String encodedBox,
String password,
) async {
final Map<String, dynamic> payload = jsonDecode(encodedBox) as Map<String, dynamic>;
final List<int> salt = base64Decode(payload['salt'] as String);
final List<int> nonce = base64Decode(payload['nonce'] as String);
final List<int> cipherText = base64Decode(payload['cipherText'] as String);
final List<int> macBytes = base64Decode(payload['mac'] as String);
final SecretKey secretKey = await _kdf.deriveKey(
secretKey: SecretKey(utf8.encode(password)),
nonce: salt,
);
final SecretBox box = SecretBox(
cipherText,
nonce: nonce,
mac: Mac(macBytes),
);
final List<int> clearText = await _aes.decrypt(
box,
secretKey: secretKey,
);
return utf8.decode(clearText);
}
Future<Map<String, dynamic>> login(
String username,
String password, {
String? deviceName,
String? endpoint,
}) async {
final String base = endpoint ?? await ApiConfig.getEndpoint();
final Uri url = Uri.parse('$base/auth/login');
final Map<String, dynamic> body = {
'username': username,
'password': hashPassword(password),
};
if (deviceName != null) body['deviceName'] = deviceName;
final http.Response res = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
final String? access = json['accessToken'] as String?;
final String? refresh = json['refreshToken'] as String?;
if (access != null) {
await _secureStorage.write(key: _accessTokenKey, value: access);
}
if (refresh != null) {
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
}
return json;
}
// Try to decode error body for better diagnostics.
try {
final dynamic decoded = jsonDecode(res.body);
return {'error': true, 'status': res.statusCode, 'body': decoded};
} catch (_) {
return {'error': true, 'status': res.statusCode, 'body': res.body};
}
}
Future<Map<String, dynamic>> register(
String username,
String password, {
String? encryptedMasterKey,
String? endpoint,
}) async {
final String base = endpoint ?? await ApiConfig.getEndpoint();
final Uri url = Uri.parse('$base/auth/register');
final Map<String, dynamic> body = {
'username': username,
'password': hashPassword(password),
'encrypted_master_key': encryptedMasterKey,
};
final http.Response res = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
final String? access = json['accessToken'] as String?;
final String? refresh = json['refreshToken'] as String?;
if (access != null) {
await _secureStorage.write(key: _accessTokenKey, value: access);
}
if (refresh != null) {
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
}
return json;
}
try {
final dynamic decoded = jsonDecode(res.body);
return {'error': true, 'status': res.statusCode, 'body': decoded};
} catch (_) {
return {'error': true, 'status': res.statusCode, 'body': res.body};
}
}
List<int> _randomBytes(int length) {
final Random random = Random.secure();
return List<int>.generate(length, (_) => random.nextInt(256));
}
// ========== Sync API ==========
static const String _lastSyncAtKey = 'api_last_sync_at_v1';
Future<DateTime?> getLastSyncAt() async {
final prefs = await SharedPreferences.getInstance();
final String? timestamp = prefs.getString(_lastSyncAtKey);
if (timestamp == null) return null;
return DateTime.tryParse(timestamp);
}
Future<void> setLastSyncAt(DateTime timestamp) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String());
}
Future<Map<String, dynamic>> sync(
SyncRequest syncRequest, {
String? endpoint,
}) async {
final String? token = await accessToken;
if (token == null) {
return {'error': true, 'message': 'No access token available'};
}
final String base = endpoint ?? await ApiConfig.getEndpoint();
final Uri url = Uri.parse('$base/sync');
final Map<String, String> headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
final String bodyJson = jsonEncode(syncRequest.toJson());
// Log request (mask authorization header)
final Map<String, String> logHeaders = Map.from(headers);
if (logHeaders.containsKey('Authorization')) {
logHeaders['Authorization'] = 'REDACTED';
}
debugPrint('SYNC REQUEST -> POST $url');
debugPrint('Headers: $logHeaders');
debugPrint('Body: $bodyJson');
final http.Response res = await http.post(
url,
headers: headers,
body: bodyJson,
);
// Log response
debugPrint('SYNC RESPONSE <- ${res.statusCode}');
debugPrint('Response body: ${res.body}');
if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
return {'error': false, 'data': SyncResponse.fromJson(json)};
}
// If token expired (401), try to refresh
if (res.statusCode == 401) {
final String? refreshTok = await refreshToken;
if (refreshTok != null) {
final bool refreshed = await _refreshAccessToken(refreshTok, endpoint: endpoint);
if (refreshed) {
// Retry sync with new token
return sync(syncRequest, endpoint: endpoint);
}
}
}
// Try to decode error body for better diagnostics.
try {
final dynamic decoded = jsonDecode(res.body);
return {'error': true, 'status': res.statusCode, 'body': decoded};
} catch (_) {
return {'error': true, 'status': res.statusCode, 'body': res.body};
}
}
Future<bool> _refreshAccessToken(
String refreshToken, {
String? endpoint,
}) async {
final String base = endpoint ?? await ApiConfig.getEndpoint();
final Uri url = Uri.parse('$base/auth/refresh');
final http.Response res = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refreshToken': refreshToken}),
);
if (res.statusCode >= 200 && res.statusCode < 300) {
final Map<String, dynamic> json =
jsonDecode(res.body) as Map<String, dynamic>;
final String? newAccess = json['accessToken'] as String?;
if (newAccess != null) {
await _secureStorage.write(key: _accessTokenKey, value: newAccess);
return true;
}
}
return false;
}
}
+62 -19
View File
@@ -7,39 +7,70 @@ import 'package:path_provider/path_provider.dart';
part 'app_database.g.dart'; part 'app_database.g.dart';
@DataClassName('DbCategory')
class Categories extends Table {
TextColumn get uuid => text().unique()();
TextColumn get encryptedName => text().named('encrypted_name')();
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
@override
Set<Column> get primaryKey => {uuid};
}
@DataClassName('DbNote') @DataClassName('DbNote')
class Notes extends Table { class Notes extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get uuid => text().unique()();
TextColumn get title => text()(); TextColumn get title => text()();
TextColumn get body => text()(); TextColumn get body => text()();
DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get createdAt => dateTime().named('created_at')();
DateTimeColumn get updatedAt => dateTime()(); DateTimeColumn get updatedAt => dateTime().named('updated_at')();
IntColumn get sortIndex => integer().named('sort_index')(); IntColumn get sortIndex => integer().named('sort_index')();
IntColumn get serverVersion => integer().named('server_version').withDefault(const Constant(0))();
BoolColumn get isDeleted => boolean().named('is_deleted').withDefault(const Constant(false))();
TextColumn get categoryId => text().nullable().named('category_id')();
} }
@DriftDatabase(tables: [Notes]) @DriftDatabase(tables: [Notes, Categories])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
@override @override
int get schemaVersion => 1; int get schemaVersion => 1;
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
// ========== Categories ==========
Future<List<DbCategory>> getAllCategories() {
return (select(categories)..where((c) => c.isDeleted.equals(false))).get();
}
Future<int> upsertCategory(CategoriesCompanion category) {
return into(categories).insertOnConflictUpdate(category);
}
Future<void> deleteCategory(String uuid) {
return (update(categories)..where((c) => c.uuid.equals(uuid)))
.write(CategoriesCompanion(isDeleted: Value(true)));
}
// ========== Notes ==========
Future<List<DbNote>> getAllNotes() { Future<List<DbNote>> getAllNotes() {
return (select(notes)..orderBy([ return (select(notes)
(note) => OrderingTerm(expression: note.sortIndex), ..orderBy([(note) => OrderingTerm(expression: note.sortIndex)])
])).get(); ..where((n) => n.isDeleted.equals(false)))
.get();
} }
Future<int> insertNoteAtTop(NotesCompanion note) { Future<int> insertNoteAtTop(NotesCompanion note) {
return transaction(() async { return transaction(() async {
await customStatement('UPDATE notes SET sort_index = sort_index + 1'); await customStatement('UPDATE notes SET sort_index = sort_index + 1 WHERE is_deleted = 0');
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0))); return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
}); });
} }
Future<void> replaceAllNotes(List<NotesCompanion> noteList) { Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
return transaction(() async { return transaction(() async {
await delete(notes).go(); await (delete(notes)..where((n) => n.isDeleted.equals(false))).go();
for (final NotesCompanion note in noteList) { for (final NotesCompanion note in noteList) {
await into(notes).insert(note); await into(notes).insert(note);
@@ -51,17 +82,20 @@ class AppDatabase extends _$AppDatabase {
return update(notes).replace(note); return update(notes).replace(note);
} }
Future<void> deleteNote(int id, int removedIndex) async {
await (update(notes)..where((n) => n.id.equals(id))).write(NotesCompanion(isDeleted: Value(true)));
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND is_deleted = 0',
[removedIndex],
);
}
Future<void> deleteNoteAndShift({ Future<void> deleteNoteAndShift({
required int id, required int id,
required int removedIndex, required int removedIndex,
}) { }) {
return transaction(() async { return deleteNote(id, removedIndex);
await (delete(notes)..where((note) => note.id.equals(id))).go();
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ?',
[removedIndex],
);
});
} }
Future<void> moveNote({ Future<void> moveNote({
@@ -76,12 +110,12 @@ class AppDatabase extends _$AppDatabase {
return transaction(() async { return transaction(() async {
if (oldIndex < newIndex) { if (oldIndex < newIndex) {
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ?', 'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0',
[oldIndex, newIndex], [oldIndex, newIndex],
); );
} else { } else {
await customStatement( await customStatement(
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ?', 'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0',
[newIndex, oldIndex], [newIndex, oldIndex],
); );
} }
@@ -92,6 +126,15 @@ class AppDatabase extends _$AppDatabase {
); );
}); });
} }
// ========== Sync helpers ==========
Future<List<DbNote>> getUnsyncedNotes() {
return (select(notes)..where((n) => n.isDeleted.equals(true) | n.serverVersion.equals(0))).get();
}
Future<List<DbCategory>> getUnsyncedCategories() {
return (select(categories)..where((c) => c.isDeleted.equals(true) | c.serverVersion.equals(0))).get();
}
} }
LazyDatabase _openConnection(String encryptionKey) { LazyDatabase _openConnection(String encryptionKey) {
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -74,6 +74,14 @@ class LocalVaultService {
return _secureStorage.read(key: _encryptionKeyStorageKey); return _secureStorage.read(key: _encryptionKeyStorageKey);
} }
Future<void> storeEncryptionKey(String encryptionKey) async {
await _secureStorage.write(key: _encryptionKeyStorageKey, value: encryptionKey);
}
String generateEncryptionKey() {
return _generateEncryptionKey();
}
Future<bool> hasEncryptionKey() async { Future<bool> hasEncryptionKey() async {
return (await readStoredEncryptionKeyRaw()) != null; return (await readStoredEncryptionKeyRaw()) != null;
} }
@@ -117,10 +125,7 @@ class LocalVaultService {
Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async { Future<String> createEncryptionKey({bool protectWithBiometrics = false}) async {
final String encryptionKey = _generateEncryptionKey(); final String encryptionKey = _generateEncryptionKey();
await _secureStorage.write( await storeEncryptionKey(encryptionKey);
key: _encryptionKeyStorageKey,
value: encryptionKey,
);
// If requested, try to enable biometric protection. Only enable on mobile // If requested, try to enable biometric protection. Only enable on mobile
// platforms and only if the authentication succeeds. // platforms and only if the authentication succeeds.
+86
View File
@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:math';
import 'package:cryptography/cryptography.dart';
/// Encriptación de contenido de notas usando AES-GCM
/// Usa una clave derivada del master key del usuario
class NoteEncryption {
static final AesGcm _aes = AesGcm.with256bits();
static const int _passwordHashVersion = 1;
static const int _kdfIterations = 100000;
static final Pbkdf2 _kdf = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: 256,
);
/// Encripta el contenido de una nota usando el master key
/// Retorna el contenido encriptado en formato JSON base64
static Future<String> encryptNote(
String plaintext,
String masterKey,
) async {
final List<int> salt = _randomBytes(16);
final SecretKey secretKey = await _kdf.deriveKey(
secretKey: SecretKey(utf8.encode(masterKey)),
nonce: salt,
);
final List<int> nonce = _randomBytes(12);
final SecretBox box = await _aes.encrypt(
utf8.encode(plaintext),
secretKey: secretKey,
nonce: nonce,
);
return jsonEncode(<String, dynamic>{
'v': _passwordHashVersion,
'salt': base64Encode(salt),
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
}
/// Desencripta el contenido de una nota usando el master key
static Future<String> decryptNote(
String encodedBox,
String masterKey,
) async {
try {
final Map<String, dynamic> payload =
jsonDecode(encodedBox) as Map<String, dynamic>;
final List<int> salt = base64Decode(payload['salt'] as String);
final List<int> nonce = base64Decode(payload['nonce'] as String);
final List<int> cipherText =
base64Decode(payload['cipherText'] as String);
final List<int> macBytes = base64Decode(payload['mac'] as String);
final SecretKey secretKey = await _kdf.deriveKey(
secretKey: SecretKey(utf8.encode(masterKey)),
nonce: salt,
);
final SecretBox box = SecretBox(
cipherText,
nonce: nonce,
mac: Mac(macBytes),
);
final List<int> clearText = await _aes.decrypt(
box,
secretKey: secretKey,
);
return utf8.decode(clearText);
} catch (e) {
throw Exception('Failed to decrypt note: $e');
}
}
static List<int> _randomBytes(int length) {
final Random random = Random.secure();
return List<int>.generate(length, (_) => random.nextInt(256));
}
}
+183 -5
View File
@@ -1,10 +1,23 @@
import 'package:drift/drift.dart';
import 'package:notas/data/app_database.dart'; import 'package:notas/data/app_database.dart';
import 'package:notas/data/api_client.dart';
import 'package:notas/data/sync_models.dart';
import 'package:notas/models/note.dart'; import 'package:notas/models/note.dart';
import 'package:notas/models/category.dart';
import 'package:notas/data/note_encryption.dart';
class NoteRepository { class NoteRepository {
NoteRepository({required AppDatabase database}) : _database = database; NoteRepository({
required AppDatabase database,
required AuthApi authApi,
required String masterKey,
}) : _database = database,
_authApi = authApi,
_masterKey = masterKey;
final AppDatabase _database; final AppDatabase _database;
final AuthApi _authApi;
final String _masterKey;
Future<List<Note>> loadNotes() async { Future<List<Note>> loadNotes() async {
return _loadNotesFromDatabase(); return _loadNotesFromDatabase();
@@ -13,11 +26,15 @@ class NoteRepository {
Future<Note> createNote(Note note) async { Future<Note> createNote(Note note) async {
final int id = await _database.insertNoteAtTop( final int id = await _database.insertNoteAtTop(
NotesCompanion.insert( NotesCompanion.insert(
uuid: note.uuid,
title: note.title, title: note.title,
body: note.body, body: note.body,
createdAt: note.createdAt, createdAt: note.createdAt,
updatedAt: note.updatedAt, updatedAt: note.updatedAt,
sortIndex: 0, sortIndex: 0,
serverVersion: const Value(0),
isDeleted: const Value(false),
categoryId: const Value(null),
), ),
); );
@@ -30,11 +47,15 @@ class NoteRepository {
await _database.updateNoteRow( await _database.updateNoteRow(
DbNote( DbNote(
id: noteId, id: noteId,
uuid: note.uuid,
title: note.title, title: note.title,
body: note.body, body: note.body,
createdAt: note.createdAt, createdAt: note.createdAt,
updatedAt: note.updatedAt, updatedAt: note.updatedAt,
sortIndex: note.index, sortIndex: note.index,
serverVersion: note.serverVersion,
isDeleted: note.isDeleted,
categoryId: note.categoryId,
), ),
); );
@@ -60,19 +81,176 @@ class NoteRepository {
); );
} }
Future<List<Note>> _loadNotesFromDatabase() async { // ========== Sync logic ==========
final List<DbNote> rows = await _database.getAllNotes();
return rows.map(_fromRow).toList(); /// Sincroniza notas con el servidor.
/// Requiere que el usuario esté autenticado (token válido).
Future<Map<String, dynamic>> performSync({bool forceFull = false}) async {
try {
// Get last sync timestamp
final DateTime? lastSync = await _authApi.getLastSyncAt();
final DateTime? lastSyncForRequest = forceFull ? DateTime.utc(1970, 1, 1) : lastSync;
// Collect pending changes
final List<DbNote> unsyncedNotes = await _database.getUnsyncedNotes();
final List<DbCategory> unsyncedCategories = await _database.getUnsyncedCategories();
// Build sync request (note: we send encrypted data, but locally we have plaintext)
// Encrypt all notes before sending
final List<SyncNotePayload> encryptedNotesPayload = [];
for (final dbNote in unsyncedNotes) {
final note = _fromDbNote(dbNote);
final encryptedTitle = await NoteEncryption.encryptNote(note.title, _masterKey);
final encryptedBody = await NoteEncryption.encryptNote(note.body, _masterKey);
encryptedNotesPayload.add(
SyncNotePayload.fromNote(
note,
encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody,
),
);
}
final List<SyncCategoryPayload> categoriesPayload = unsyncedCategories
.map((cat) => SyncCategoryPayload.fromCategory(
_fromDbCategory(cat),
))
.toList();
final SyncRequest syncRequest = SyncRequest(
lastSyncAt: lastSyncForRequest,
changes: SyncChanges(
categories: categoriesPayload,
notes: encryptedNotesPayload,
),
);
// Call sync API
final Map<String, dynamic> syncResult =
await _authApi.sync(syncRequest);
if (syncResult['error'] == true) {
return {'error': true, 'message': syncResult['body']};
}
final SyncResponse response = syncResult['data'] as SyncResponse;
// Apply server changes to local database
await _applySyncResponse(response);
// Update lastSyncAt
await _authApi.setLastSyncAt(response.serverTimestamp);
return {
'error': false,
'synced': response.synced,
'notesCount': response.changes.notes.length,
'categoriesCount': response.changes.categories.length,
};
} catch (e) {
return {'error': true, 'message': e.toString()};
}
} }
Note _fromRow(DbNote row) { Future<void> _applySyncResponse(SyncResponse response) async {
// Apply categories from server
for (final SyncCategoryResponse catResponse in response.changes.categories) {
await _database.upsertCategory(
CategoriesCompanion(
uuid: Value(catResponse.id),
encryptedName: Value(catResponse.encryptedName),
serverVersion: Value(catResponse.serverVersion),
isDeleted: Value(catResponse.isDeleted),
updatedAt: Value(catResponse.updatedAt),
),
);
}
// Apply notes from server
for (final SyncNoteResponse noteResponse in response.changes.notes) {
final existingNote = await (_database.select(_database.notes)
..where((n) => n.uuid.equals(noteResponse.id)))
.getSingleOrNull();
// Decrypt note content
String decryptedTitle = 'Encrypted';
String decryptedBody = 'Encrypted';
try {
decryptedTitle = await NoteEncryption.decryptNote(
noteResponse.encryptedTitle,
_masterKey,
);
decryptedBody = await NoteEncryption.decryptNote(
noteResponse.encryptedBody,
_masterKey,
);
} catch (e) {
// If decryption fails, keep default encrypted placeholders
print('Failed to decrypt note ${noteResponse.id}: $e');
}
if (existingNote != null) {
// Update existing note
await _database.updateNoteRow(
DbNote(
id: existingNote.id,
uuid: noteResponse.id,
title: decryptedTitle,
body: decryptedBody,
createdAt: existingNote.createdAt,
updatedAt: noteResponse.updatedAt,
sortIndex: noteResponse.position,
serverVersion: noteResponse.serverVersion,
isDeleted: noteResponse.isDeleted,
categoryId: noteResponse.categoryId,
),
);
} else {
// Insert new note
await _database.into(_database.notes).insert(
NotesCompanion(
uuid: Value(noteResponse.id),
title: Value(decryptedTitle),
body: Value(decryptedBody),
createdAt: Value(noteResponse.updatedAt),
updatedAt: Value(noteResponse.updatedAt),
sortIndex: Value(noteResponse.position),
serverVersion: Value(noteResponse.serverVersion),
isDeleted: Value(noteResponse.isDeleted),
categoryId: Value(noteResponse.categoryId),
),
);
}
}
}
Future<List<Note>> _loadNotesFromDatabase() async {
final List<DbNote> rows = await _database.getAllNotes();
return rows.map(_fromDbNote).toList();
}
Note _fromDbNote(DbNote row) {
return Note( return Note(
id: row.id, id: row.id,
uuid: row.uuid,
title: row.title, title: row.title,
body: row.body, body: row.body,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
index: row.sortIndex, index: row.sortIndex,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
categoryId: row.categoryId,
);
}
Category _fromDbCategory(DbCategory row) {
return Category(
uuid: row.uuid,
encryptedName: row.encryptedName,
serverVersion: row.serverVersion,
isDeleted: row.isDeleted,
updatedAt: row.updatedAt,
); );
} }
} }
+258
View File
@@ -0,0 +1,258 @@
import 'package:notas/models/note.dart';
import 'package:notas/models/category.dart';
// DTOs para sincronización con el servidor
class SyncRequest {
SyncRequest({
DateTime? lastSyncAt,
required this.changes,
}) : lastSyncAt = lastSyncAt ?? DateTime.utc(1970, 1, 1);
final DateTime lastSyncAt;
final SyncChanges changes;
Map<String, dynamic> toJson() {
return {
'lastSyncAt': lastSyncAt.toIso8601String(),
'changes': changes.toJson(),
};
}
}
class SyncChanges {
const SyncChanges({
this.categories = const [],
this.notes = const [],
});
final List<SyncCategoryPayload> categories;
final List<SyncNotePayload> notes;
Map<String, dynamic> toJson() {
return {
if (categories.isNotEmpty)
'categories': categories.map((c) => c.toJson()).toList(),
if (notes.isNotEmpty)
'notes': notes.map((n) => n.toJson()).toList(),
};
}
}
class SyncChangesResponse {
const SyncChangesResponse({
this.categories = const [],
this.notes = const [],
});
final List<SyncCategoryResponse> categories;
final List<SyncNoteResponse> notes;
factory SyncChangesResponse.fromJson(Map<String, dynamic> json) {
final List<dynamic> categoriesJson = json['categories'] as List<dynamic>? ?? [];
final List<dynamic> notesJson = json['notes'] as List<dynamic>? ?? [];
return SyncChangesResponse(
categories: categoriesJson
.map((c) => SyncCategoryResponse.fromJson(c as Map<String, dynamic>))
.toList(),
notes: notesJson
.map((n) => SyncNoteResponse.fromJson(n as Map<String, dynamic>))
.toList(),
);
}
}
class SyncCategoryPayload {
const SyncCategoryPayload({
required this.id,
required this.encryptedName,
required this.serverVersion,
this.isDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String encryptedName;
final int serverVersion;
final bool isDeleted;
final DateTime updatedAt;
factory SyncCategoryPayload.fromCategory(Category category) {
return SyncCategoryPayload(
id: category.uuid,
encryptedName: category.encryptedName,
serverVersion: category.serverVersion,
isDeleted: category.isDeleted,
updatedAt: category.updatedAt,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'encrypted_name': encryptedName,
'serverVersion': serverVersion,
'isDeleted': isDeleted,
'updatedAt': updatedAt.toIso8601String(),
};
}
}
class SyncNotePayload {
const SyncNotePayload({
required this.id,
this.categoryId,
required this.encryptedTitle,
required this.encryptedBody,
required this.serverVersion,
this.position = 0,
this.isDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String? categoryId;
final String encryptedTitle;
final String encryptedBody;
final int serverVersion;
final int position;
final bool isDeleted;
final DateTime updatedAt;
factory SyncNotePayload.fromNote(
Note note, {
required String encryptedTitle,
required String encryptedBody,
}) {
return SyncNotePayload(
id: note.uuid,
categoryId: note.categoryId,
encryptedTitle: encryptedTitle,
encryptedBody: encryptedBody,
serverVersion: note.serverVersion,
position: note.index,
isDeleted: note.isDeleted,
updatedAt: note.updatedAt,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
if (categoryId != null) 'categoryId': categoryId,
'encrypted_title': encryptedTitle,
'encrypted_body': encryptedBody,
'serverVersion': serverVersion,
if (position != 0) 'position': position,
if (isDeleted) 'isDeleted': isDeleted,
'updatedAt': updatedAt.toIso8601String(),
};
}
}
class SyncResponse {
const SyncResponse({
required this.serverTimestamp,
required this.synced,
required this.changes,
});
final DateTime serverTimestamp;
final bool synced;
final SyncChangesResponse changes;
factory SyncResponse.fromJson(Map<String, dynamic> json) {
return SyncResponse(
serverTimestamp:
DateTime.parse(json['serverTimestamp'] as String),
synced: json['synced'] as bool? ?? false,
changes: SyncChangesResponse.fromJson(
json['changes'] as Map<String, dynamic>? ?? {}),
);
}
}
class SyncCategoryResponse {
const SyncCategoryResponse({
required this.id,
required this.encryptedName,
required this.serverVersion,
this.isDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String encryptedName;
final int serverVersion;
final bool isDeleted;
final DateTime updatedAt;
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
return SyncCategoryResponse(
id: json['id'] as String,
encryptedName: json['encrypted_name'] as String,
serverVersion: json['serverVersion'] as int,
isDeleted: json['isDeleted'] as bool? ?? false,
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
Category toCategory() {
return Category(
uuid: id,
encryptedName: encryptedName,
serverVersion: serverVersion,
isDeleted: isDeleted,
updatedAt: updatedAt,
);
}
}
class SyncNoteResponse {
const SyncNoteResponse({
required this.id,
this.categoryId,
required this.encryptedTitle,
required this.encryptedBody,
required this.serverVersion,
this.position = 0,
this.isDeleted = false,
required this.updatedAt,
});
final String id; // uuid
final String? categoryId;
final String encryptedTitle;
final String encryptedBody;
final int serverVersion;
final int position;
final bool isDeleted;
final DateTime updatedAt;
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
return SyncNoteResponse(
id: json['id'] as String,
categoryId: json['categoryId'] as String?,
encryptedTitle: json['encrypted_title'] as String,
encryptedBody: json['encrypted_body'] as String,
serverVersion: json['serverVersion'] as int,
position: json['position'] as int? ?? 0,
isDeleted: json['isDeleted'] as bool? ?? false,
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
Note toNote() {
return Note(
uuid: id,
title: 'Encrypted', // placeholder, será descifrado por la app
body: 'Encrypted', // placeholder, será descifrado por la app
createdAt: updatedAt,
updatedAt: updatedAt,
index: position,
serverVersion: serverVersion,
isDeleted: isDeleted,
categoryId: categoryId,
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:uuid/uuid.dart';
class Category {
Category({
String? uuid,
required this.encryptedName,
this.serverVersion = 0,
this.isDeleted = false,
required this.updatedAt,
}) : uuid = uuid ?? Uuid().v4();
final String uuid;
final String encryptedName;
final int serverVersion;
final bool isDeleted;
final DateTime updatedAt;
Category copyWith({
String? uuid,
String? encryptedName,
int? serverVersion,
bool? isDeleted,
DateTime? updatedAt,
}) {
return Category(
uuid: uuid ?? this.uuid,
encryptedName: encryptedName ?? this.encryptedName,
serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is Category && uuid == other.uuid;
}
@override
int get hashCode => uuid.hashCode;
}
+26 -5
View File
@@ -1,39 +1,60 @@
import 'package:uuid/uuid.dart';
// Model: Note // Model: Note
// - Representa una nota guardada en la app. // - Representa una nota guardada en la app.
// - `id` viene de SQLite y sirve como identificador estable. // - `id` es el identificador local de SQLite (autoincrement).
// - `uuid` es el identificador global sincronizado con el servidor.
// - `index` representa el orden visual dentro de la lista. // - `index` representa el orden visual dentro de la lista.
// - `serverVersion` se usa para resolver conflictos en sync.
// - `isDeleted` marca eliminaciones blandas.
class Note { class Note {
const Note({ Note({
this.id, this.id,
String? uuid,
required this.title, required this.title,
required this.body, required this.body,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.index, required this.index,
}); this.serverVersion = 0,
this.isDeleted = false,
this.categoryId,
}) : uuid = uuid ?? Uuid().v4();
final int? id; final int? id;
final String uuid;
final String title; final String title;
final String body; final String body;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final int index; final int index;
final int serverVersion;
final bool isDeleted;
final String? categoryId;
Note copyWith({ Note copyWith({
int? id, int? id,
String? uuid,
String? title, String? title,
String? body, String? body,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
int? index, int? index,
int? serverVersion,
bool? isDeleted,
String? categoryId,
}) { }) {
return Note( return Note(
id: id ?? this.id, id: id ?? this.id,
uuid: uuid ?? this.uuid,
title: title ?? this.title, title: title ?? this.title,
body: body ?? this.body, body: body ?? this.body,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
index: index ?? this.index, index: index ?? this.index,
serverVersion: serverVersion ?? this.serverVersion,
isDeleted: isDeleted ?? this.isDeleted,
categoryId: categoryId ?? this.categoryId,
); );
} }
@@ -43,9 +64,9 @@ class Note {
return true; return true;
} }
return other is Note && id != null && other.id == id; return other is Note && uuid == other.uuid;
} }
@override @override
int get hashCode => id?.hashCode ?? Object.hash(title, body, createdAt, updatedAt, index); int get hashCode => uuid.hashCode;
} }
+29 -4
View File
@@ -75,6 +75,12 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() { setState(() {
_notes = updatedNotes; _notes = updatedNotes;
}); });
// Trigger sync after creating a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
} }
} }
@@ -92,6 +98,12 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() { setState(() {
_notes = updatedNotes; _notes = updatedNotes;
}); });
// Trigger sync after deleting a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
} }
Future<void> _reorderNote(int oldIndex, int newIndex) async { Future<void> _reorderNote(int oldIndex, int newIndex) async {
@@ -140,6 +152,11 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() { setState(() {
_notes = _normalizeNotes(updatedNotes); _notes = _normalizeNotes(updatedNotes);
}); });
// Trigger sync after editing a note and refresh local list
try {
await widget.repository.performSync();
await _loadNotes();
} catch (_) {}
} }
} }
} }
@@ -172,9 +189,16 @@ class _HomeScreenState extends State<HomeScreen> {
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _notes.isEmpty : _notes.isEmpty
? const _EmptyState() ? const _EmptyState()
: MouseRegion( : RefreshIndicator(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic, onRefresh: () async {
child: MasonryGridView.count( try {
await widget.repository.performSync();
} catch (_) {}
await _loadNotes();
},
child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
child: MasonryGridView.count(
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
mainAxisSpacing: 10, mainAxisSpacing: 10,
crossAxisSpacing: 10, crossAxisSpacing: 10,
@@ -296,7 +320,8 @@ class _HomeScreenState extends State<HomeScreen> {
); );
}, },
), ),
); ),
);
return Scaffold( return Scaffold(
body: Container( body: Container(
+199
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:notas/data/local_vault_service.dart';
import 'package:notas/widgets/search_app_bar.dart'; import 'package:notas/widgets/search_app_bar.dart';
import 'package:notas/data/api_client.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({ const SettingsScreen({
@@ -17,6 +19,11 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _isBusy = false; bool _isBusy = false;
final TextEditingController _endpointController = TextEditingController();
final TextEditingController _encryptionKeyController = TextEditingController();
bool _endpointLoading = true;
bool _encryptionKeyLoading = false;
bool _encryptionKeyVisible = false;
Future<void> _confirmAndDeleteAll() async { Future<void> _confirmAndDeleteAll() async {
final bool? confirmed = await showDialog<bool>( final bool? confirmed = await showDialog<bool>(
@@ -59,6 +66,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
@override @override
void initState() {
super.initState();
_loadEndpoint();
}
Future<void> _loadEndpoint() async {
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
setState(() {
_endpointLoading = false;
});
}
Future<void> _loadEncryptionKey() async {
setState(() {
_encryptionKeyLoading = true;
});
try {
final String? encryptionKey = await LocalVaultService.instance.readEncryptionKey();
if (!mounted) return;
if (encryptionKey == null || encryptionKey.isEmpty) {
_encryptionKeyController.text = '';
_encryptionKeyVisible = false;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No se pudo leer la clave de cifrado.')),
);
return;
}
_encryptionKeyController.text = encryptionKey;
_encryptionKeyVisible = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Clave de cifrado mostrada.')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al leer la clave de cifrado: $error')),
);
} finally {
if (mounted) {
setState(() {
_encryptionKeyLoading = false;
});
}
}
}
void _hideEncryptionKey() {
setState(() {
_encryptionKeyVisible = false;
_encryptionKeyController.clear();
});
}
@override
void dispose() {
_endpointController.dispose();
_encryptionKeyController.dispose();
super.dispose();
}
Future<void> _saveEndpoint() async {
final String value = _endpointController.text.trim();
try {
await ApiConfig.setEndpoint(value);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint guardado')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error guardando endpoint: $e')));
}
}
Future<void> _resetEndpoint() async {
await ApiConfig.clearEndpoint();
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Endpoint restaurado al valor por defecto')));
}
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( body: Container(
@@ -97,6 +189,113 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'), const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'),
const SizedBox(height: 24),
const Text('API endpoint (ej: http://localhost:3000/api)'),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _endpointLoading
? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator()))
: TextField(
controller: _endpointController,
style: const TextStyle(color: Colors.white),
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'API endpoint',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
),
),
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: _endpointLoading ? null : _saveEndpoint,
child: const Text('Guardar'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _endpointLoading ? null : _resetEndpoint,
child: const Text('Restaurar'),
),
],
),
],
),
const SizedBox(height: 24),
const Text('Clave de cifrado local'),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _encryptionKeyController,
readOnly: true,
obscureText: !_encryptionKeyVisible,
enableSuggestions: false,
autocorrect: false,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: _encryptionKeyVisible ? 'Clave de cifrado' : 'Oculta hasta pulsar mostrar',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
),
),
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: _encryptionKeyLoading ? null : _loadEncryptionKey,
child: _encryptionKeyLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Mostrar'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _encryptionKeyVisible ? _hideEncryptionKey : null,
child: const Text('Ocultar'),
),
],
),
],
),
], ],
), ),
), ),
+60 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:notas/widgets/app_title_bar.dart'; import 'package:notas/widgets/app_title_bar.dart';
import 'package:notas/data/api_client.dart';
class VaultAccessScreen extends StatefulWidget { class VaultAccessScreen extends StatefulWidget {
const VaultAccessScreen({ const VaultAccessScreen({
@@ -22,6 +23,29 @@ class VaultAccessScreen extends StatefulWidget {
class _VaultAccessScreenState extends State<VaultAccessScreen> { class _VaultAccessScreenState extends State<VaultAccessScreen> {
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _endpointController = TextEditingController();
bool _endpointLoading = true;
@override
void initState() {
super.initState();
_loadEndpoint();
}
Future<void> _loadEndpoint() async {
final String endpoint = await ApiConfig.getEndpoint();
if (!mounted) return;
_endpointController.text = endpoint;
setState(() {
_endpointLoading = false;
});
}
Future<void> _persistEndpointFromField() async {
final String value = _endpointController.text.trim();
if (value.isEmpty) return;
await ApiConfig.setEndpoint(value);
}
@override @override
void dispose() { void dispose() {
@@ -31,6 +55,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
} }
Future<void> _handleCreateAccount() async { Future<void> _handleCreateAccount() async {
await _persistEndpointFromField();
await widget.onCreateAccountPressed( await widget.onCreateAccountPressed(
_emailController.text.trim(), _emailController.text.trim(),
_passwordController.text, _passwordController.text,
@@ -38,6 +64,8 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
} }
Future<void> _handleSignIn() async { Future<void> _handleSignIn() async {
await _persistEndpointFromField();
await widget.onSignInPressed( await widget.onSignInPressed(
_emailController.text.trim(), _emailController.text.trim(),
_passwordController.text, _passwordController.text,
@@ -112,13 +140,43 @@ class _VaultAccessScreenState extends State<VaultAccessScreen> {
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
_endpointLoading
? const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
)
: TextField(
controller: _endpointController,
enabled: !widget.isBusy,
keyboardType: TextInputType.url,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'API endpoint',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
),
),
),
const SizedBox(height: 16),
TextField( TextField(
controller: _emailController, controller: _emailController,
enabled: !widget.isBusy, enabled: !widget.isBusy,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.text,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Email', labelText: 'Usuario',
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)), labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
filled: true, filled: true,
fillColor: Colors.white.withValues(alpha: 0.05), fillColor: Colors.white.withValues(alpha: 0.05),
+73 -15
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:notas/platform/app_platform.dart'; import 'package:notas/platform/app_platform.dart';
import 'package:notas/widgets/sync_status_indicator.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
class AppTitleBar extends StatelessWidget { class AppTitleBar extends StatelessWidget {
const AppTitleBar({super.key}); const AppTitleBar({
this.syncStatus = SyncStatus.idle,
this.syncErrorMessage,
super.key,
});
final SyncStatus syncStatus;
final String? syncErrorMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -12,33 +20,62 @@ class AppTitleBar extends StatelessWidget {
} }
if (isMacOS) { if (isMacOS) {
return const SizedBox( return SizedBox(
height: 28, height: 28,
child: Center( child: Center(
child: Text( child: Row(
'Mis Notas', mainAxisAlignment: MainAxisAlignment.center,
style: TextStyle(color: Colors.white70, fontSize: 13), children: [
const Text(
'Mis Notas',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(width: 8),
SyncStatusIndicator(
status: syncStatus,
errorMessage: syncErrorMessage,
),
],
), ),
), ),
); );
} }
if (isLinux) { if (isLinux) {
return const _KdeTitleBar(); return _KdeTitleBar(
syncStatus: syncStatus,
syncErrorMessage: syncErrorMessage,
);
} }
return const SizedBox( return SizedBox(
height: 40, height: 40,
child: WindowCaption( child: WindowCaption(
brightness: Brightness.dark, brightness: Brightness.dark,
title: Text('Mis Notas', style: TextStyle(color: Colors.white)), title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Mis Notas', style: TextStyle(color: Colors.white)),
const SizedBox(width: 8),
SyncStatusIndicator(
status: syncStatus,
errorMessage: syncErrorMessage,
),
],
),
), ),
); );
} }
} }
class _KdeTitleBar extends StatefulWidget { class _KdeTitleBar extends StatefulWidget {
const _KdeTitleBar(); const _KdeTitleBar({
this.syncStatus = SyncStatus.idle,
this.syncErrorMessage,
});
final SyncStatus syncStatus;
final String? syncErrorMessage;
@override @override
State<_KdeTitleBar> createState() => _KdeTitleBarState(); State<_KdeTitleBar> createState() => _KdeTitleBarState();
@@ -130,12 +167,33 @@ class _KdeTitleBarState extends State<_KdeTitleBar> with WindowListener {
), ),
const IgnorePointer( const IgnorePointer(
child: Center( child: Center(
child: Text( child: Row(
'Mis Notas', mainAxisAlignment: MainAxisAlignment.center,
style: TextStyle( children: [
color: Color.fromARGB(255, 163, 163, 163), Text(
fontSize: 14, 'Mis Notas',
fontWeight: FontWeight.w500, style: TextStyle(
color: Color.fromARGB(255, 163, 163, 163),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: SyncStatusIndicator(
status: widget.syncStatus,
errorMessage: widget.syncErrorMessage,
),
), ),
), ),
), ),
+9 -1
View File
@@ -1,7 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:notas/widgets/sync_status_indicator.dart';
class AppTitleBar extends StatelessWidget { class AppTitleBar extends StatelessWidget {
const AppTitleBar({super.key}); const AppTitleBar({
this.syncStatus = SyncStatus.idle,
this.syncErrorMessage,
super.key,
});
final SyncStatus syncStatus;
final String? syncErrorMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
enum SyncStatus {
idle,
syncing,
synced,
error,
}
class SyncStatusIndicator extends StatelessWidget {
const SyncStatusIndicator({
required this.status,
this.errorMessage,
super.key,
});
final SyncStatus status;
final String? errorMessage;
@override
Widget build(BuildContext context) {
switch (status) {
case SyncStatus.idle:
return const SizedBox.shrink();
case SyncStatus.syncing:
return const Tooltip(
message: 'Sincronizando...',
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color.fromARGB(255, 150, 150, 150),
),
),
),
);
case SyncStatus.synced:
return const Tooltip(
message: 'Sincronizado',
child: Icon(
Icons.check_circle,
size: 16,
color: Colors.green,
),
);
case SyncStatus.error:
return Tooltip(
message: errorMessage ?? 'Error al sincronizar',
child: const Icon(
Icons.error,
size: 16,
color: Colors.red,
),
);
}
}
}
+25 -1
View File
@@ -162,13 +162,21 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cryptography:
dependency: "direct main"
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -360,6 +368,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
http:
dependency: "direct main"
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
url: "https://pub.dev"
source: hosted
version: "0.13.6"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -893,6 +909,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+5 -1
View File
@@ -44,6 +44,10 @@ dependencies:
flutter_secure_storage: ^10.2.0 flutter_secure_storage: ^10.2.0
local_auth: ^3.0.1 local_auth: ^3.0.1
sqlite3: ^3.3.1 sqlite3: ^3.3.1
http: ^0.13.6
crypto: ^3.0.6
cryptography: ^2.7.0
uuid: ^4.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -70,7 +74,7 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets: assets:
- assets/icon.png - assets/icon.png
hooks: hooks: