diff --git a/lib/data/app_database.dart b/lib/data/app_database.dart index db14b55..40f7407 100644 --- a/lib/data/app_database.dart +++ b/lib/data/app_database.dart @@ -111,6 +111,18 @@ class AppDatabase extends _$AppDatabase { return deleteNote(id, removedIndex); } + Future permanentlyDeleteNote(int id) async { + await (update(notes)..where((n) => n.id.equals(id))).write( + NotesCompanion( + title: const Value(''), + body: const Value(''), + categoryId: const Value(null), + isDeleted: const Value(true), + updatedAt: Value(DateTime.now()), + ), + ); + } + Future moveNote({ required int id, required int oldIndex, @@ -146,6 +158,12 @@ class AppDatabase extends _$AppDatabase { )..where((n) => n.updatedAt.isBiggerThanValue(since))).get(); } + Future> getDeletedNotes() { + return (select(notes) + ..where((n) => n.isDeleted.equals(true) & n.title.isNotValue('') & n.body.isNotValue(''))) + .get(); + } + Future> getCategoriesChangedSince(DateTime since) { return (select( categories, diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 64fd582..13ce29d 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -30,6 +30,10 @@ class NoteRepository { return _loadNotesFromDatabase(); } + Future> loadDeletedNotes() async { + return _loadDeletedNotesFromDatabase(); + } + Future createNote(Note note) async { final int id = await _database.insertNoteAtTop( NotesCompanion.insert( @@ -63,12 +67,12 @@ class NoteRepository { updatedAt: note.updatedAt, sortIndex: note.index, serverVersion: note.serverVersion, - isDeleted: note.isDeleted, + isDeleted: false, categoryId: note.categoryId, ), ); - return note; + return note.copyWith(isDeleted: false, isPermanentlyDeleted: false); } Future deleteNote(Note note) async { @@ -76,7 +80,11 @@ class NoteRepository { note.id ?? (throw ArgumentError('Note id is required to delete a note.')); - await _database.deleteNoteAndShift(id: noteId, removedIndex: note.index); + if (note.isDeleted || note.isPermanentlyDeleted) { + await _database.permanentlyDeleteNote(noteId); + } else { + await _database.deleteNoteAndShift(id: noteId, removedIndex: note.index); + } } Future moveNote(Note note, int newIndex) async { @@ -131,7 +139,7 @@ class NoteRepository { final int totalNotesToEncrypt = unsyncedNotes.length; // Build sync request (note: we send encrypted data, but locally we have plaintext) - // Encrypt all notes before sending. + // Encrypt notes that still contain content before sending. if (totalNotesToEncrypt == 0) { onProgress?.call( SyncStatus.encrypting, @@ -254,6 +262,7 @@ class NoteRepository { final String decryptedTitle = decryptedNote['title']! as String; final String decryptedBody = decryptedNote['body']! as String; final SyncNoteResponse noteResponse = response.changes.notes[index]; + final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted; if (existingNote != null) { // Update existing note @@ -261,14 +270,14 @@ class NoteRepository { DbNote( id: existingNote.id, uuid: noteId, - title: decryptedTitle, - body: decryptedBody, + title: isPermanentlyDeleted ? '' : decryptedTitle, + body: isPermanentlyDeleted ? '' : decryptedBody, createdAt: existingNote.createdAt, updatedAt: noteResponse.updatedAt, sortIndex: noteResponse.position, serverVersion: noteResponse.serverVersion, isDeleted: noteResponse.isDeleted, - categoryId: noteResponse.categoryId, + categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId, ), ); } else { @@ -278,14 +287,14 @@ class NoteRepository { .insert( NotesCompanion( uuid: Value(noteResponse.id), - title: Value(decryptedTitle), - body: Value(decryptedBody), + title: Value(isPermanentlyDeleted ? '' : decryptedTitle), + body: Value(isPermanentlyDeleted ? '' : 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), + categoryId: Value(isPermanentlyDeleted ? null : noteResponse.categoryId), ), ); } @@ -297,6 +306,11 @@ class NoteRepository { return rows.map(_fromDbNote).toList(); } + Future> _loadDeletedNotesFromDatabase() async { + final List rows = await _database.getDeletedNotes(); + return rows.map(_fromDbNote).toList(); + } + Note _fromDbNote(DbNote row) { return Note( id: row.id, @@ -308,6 +322,7 @@ class NoteRepository { index: row.sortIndex, serverVersion: row.serverVersion, isDeleted: row.isDeleted, + isPermanentlyDeleted: _isPermanentlyDeleted(row), categoryId: row.categoryId, ); } @@ -443,10 +458,13 @@ Map _syncNoteToDecryptionInput( 'id': row.id, 'encryptedTitle': row.encryptedTitle, 'encryptedBody': row.encryptedBody, + 'isPermanentlyDeleted': row.isPermanentlyDeleted, }; } Map _dbNoteToEncryptionInput(DbNote row, int index) { + final bool isPermanentlyDeleted = _isPermanentlyDeleted(row); + return { 'index': index, 'uuid': row.uuid, @@ -458,6 +476,7 @@ Map _dbNoteToEncryptionInput(DbNote row, int index) { 'serverVersion': row.serverVersion, 'position': row.sortIndex, 'isDeleted': row.isDeleted, + 'isPermanentlyDeleted': isPermanentlyDeleted, }; } @@ -470,6 +489,7 @@ SyncNotePayload _syncNotePayloadFromEncryptionResult(Map row) { serverVersion: row['serverVersion']! as int, position: row['position']! as int, isDeleted: row['isDeleted']! as bool, + isPermanentlyDeleted: row['isPermanentlyDeleted']! as bool, updatedAt: DateTime.parse(row['updatedAt']! as String), ); } @@ -483,17 +503,25 @@ Future>> _encryptNoteBatch( final List> encryptedNotes = []; for (final Map note in notes) { + final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool; final String title = note['title']! as String; final String body = note['body']! as String; - final String encryptedTitle = await NoteEncryption.encryptNote( - title, - masterKey, - ); - final String encryptedBody = await NoteEncryption.encryptNote( - body, - masterKey, - ); + final String encryptedTitle; + final String encryptedBody; + if (isPermanentlyDeleted) { + encryptedTitle = ''; + encryptedBody = ''; + } else { + encryptedTitle = await NoteEncryption.encryptNote( + title, + masterKey, + ); + encryptedBody = await NoteEncryption.encryptNote( + body, + masterKey, + ); + } encryptedNotes.add({ 'index': note['index'] as int, @@ -504,6 +532,7 @@ Future>> _encryptNoteBatch( 'serverVersion': note['serverVersion']! as int, 'position': note['position']! as int, 'isDeleted': note['isDeleted']! as bool, + 'isPermanentlyDeleted': isPermanentlyDeleted, 'updatedAt': note['updatedAt']! as String, }); } @@ -520,19 +549,25 @@ Future>> _decryptNoteBatch( final List> decryptedNotes = []; for (final Map note in notes) { + final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool; String decryptedTitle = 'Encrypted'; String decryptedBody = 'Encrypted'; - try { - decryptedTitle = await NoteEncryption.decryptNote( - note['encryptedTitle']! as String, - masterKey, - ); - decryptedBody = await NoteEncryption.decryptNote( - note['encryptedBody']! as String, - masterKey, - ); - } catch (e) { - print('Failed to decrypt note ${note['id']}: $e'); + if (!isPermanentlyDeleted) { + try { + decryptedTitle = await NoteEncryption.decryptNote( + note['encryptedTitle']! as String, + masterKey, + ); + decryptedBody = await NoteEncryption.decryptNote( + note['encryptedBody']! as String, + masterKey, + ); + } catch (e) { + print('Failed to decrypt note ${note['id']}: $e'); + } + } else { + decryptedTitle = ''; + decryptedBody = ''; } decryptedNotes.add({ @@ -540,12 +575,17 @@ Future>> _decryptNoteBatch( 'id': note['id'] as String, 'title': decryptedTitle, 'body': decryptedBody, + 'isPermanentlyDeleted': isPermanentlyDeleted, }); } return decryptedNotes; } +bool _isPermanentlyDeleted(DbNote row) { + return row.isDeleted && row.title.isEmpty && row.body.isEmpty; +} + int _parallelWorkerCount(int itemCount) { final int cappedByCpu = math.max( 1, diff --git a/lib/data/sync_models.dart b/lib/data/sync_models.dart index c69f5c5..2432fa7 100644 --- a/lib/data/sync_models.dart +++ b/lib/data/sync_models.dart @@ -69,6 +69,13 @@ String _readStringValue(dynamic value) { return jsonEncode(value); } +String _readOptionalStringValue(dynamic value) { + if (value == null) { + return ''; + } + return _readStringValue(value); +} + int _readIntValue(dynamic value) { if (value is int) { return value; @@ -127,6 +134,7 @@ class SyncNotePayload { required this.serverVersion, this.position = 0, this.isDeleted = false, + this.isPermanentlyDeleted = false, required this.updatedAt, }); @@ -137,12 +145,14 @@ class SyncNotePayload { final int serverVersion; final int position; final bool isDeleted; + final bool isPermanentlyDeleted; final DateTime updatedAt; factory SyncNotePayload.fromNote( Note note, { required String encryptedTitle, required String encryptedBody, + bool isPermanentlyDeleted = false, }) { return SyncNotePayload( id: note.uuid, @@ -152,6 +162,7 @@ class SyncNotePayload { serverVersion: note.serverVersion, position: note.index, isDeleted: note.isDeleted, + isPermanentlyDeleted: isPermanentlyDeleted, updatedAt: note.updatedAt, ); } @@ -165,6 +176,7 @@ class SyncNotePayload { 'serverVersion': serverVersion, if (position != 0) 'position': position, if (isDeleted) 'isDeleted': isDeleted, + if (isPermanentlyDeleted) 'isPermanentlyDeleted': isPermanentlyDeleted, 'updatedAt': updatedAt.toIso8601String(), }; } @@ -237,6 +249,7 @@ class SyncNoteResponse { required this.serverVersion, this.position = 0, this.isDeleted = false, + this.isPermanentlyDeleted = false, required this.updatedAt, }); @@ -247,6 +260,7 @@ class SyncNoteResponse { final int serverVersion; final int position; final bool isDeleted; + final bool isPermanentlyDeleted; final DateTime updatedAt; factory SyncNoteResponse.fromJson(Map json) { @@ -255,11 +269,12 @@ class SyncNoteResponse { categoryId: json['categoryId'] == null ? null : _readStringValue(json['categoryId']), - encryptedTitle: _readStringValue(json['encrypted_title']), - encryptedBody: _readStringValue(json['encrypted_body']), + encryptedTitle: _readOptionalStringValue(json['encrypted_title']), + encryptedBody: _readOptionalStringValue(json['encrypted_body']), serverVersion: _readIntValue(json['serverVersion']), position: json['position'] as int? ?? 0, isDeleted: json['isDeleted'] as bool? ?? false, + isPermanentlyDeleted: json['isPermanentlyDeleted'] as bool? ?? false, updatedAt: DateTime.parse(json['updatedAt'] as String), ); } @@ -267,8 +282,8 @@ class SyncNoteResponse { Note toNote() { return Note( uuid: id, - title: 'Encrypted', // placeholder, será descifrado por la app - body: 'Encrypted', // placeholder, será descifrado por la app + title: isPermanentlyDeleted ? '' : 'Encrypted', + body: isPermanentlyDeleted ? '' : 'Encrypted', createdAt: updatedAt, updatedAt: updatedAt, index: position, diff --git a/lib/models/note.dart b/lib/models/note.dart index 93f998d..ae5fd68 100644 --- a/lib/models/note.dart +++ b/lib/models/note.dart @@ -18,6 +18,7 @@ class Note { required this.index, this.serverVersion = 0, this.isDeleted = false, + this.isPermanentlyDeleted = false, this.categoryId, }) : uuid = uuid ?? Uuid().v4(); @@ -30,6 +31,7 @@ class Note { final int index; final int serverVersion; final bool isDeleted; + final bool isPermanentlyDeleted; final String? categoryId; Note copyWith({ @@ -42,6 +44,7 @@ class Note { int? index, int? serverVersion, bool? isDeleted, + bool? isPermanentlyDeleted, String? categoryId, }) { return Note( @@ -54,6 +57,7 @@ class Note { index: index ?? this.index, serverVersion: serverVersion ?? this.serverVersion, isDeleted: isDeleted ?? this.isDeleted, + isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted, categoryId: categoryId ?? this.categoryId, ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e9b4ff4..bbfddcd 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -47,6 +47,7 @@ class _HomeScreenState extends State { bool _isLoading = true; bool _isDragging = false; bool _isMenuOpen = false; + bool _showDeletedNotes = false; PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; bool _requiresLongPressToDrag(PointerDeviceKind kind) { @@ -71,7 +72,9 @@ class _HomeScreenState extends State { Future _loadNotes() async { try { - final List storedNotes = await widget.repository.loadNotes(); + final List storedNotes = _showDeletedNotes + ? await widget.repository.loadDeletedNotes() + : await widget.repository.loadNotes(); if (!mounted) return; @@ -95,19 +98,8 @@ class _HomeScreenState extends State { } if (result is Note) { - final Note createdNote = await widget.repository.createNote(result); - final List updatedNotes = _normalizeNotes([ - createdNote, - ..._notes, - ]); - - if (!mounted) { - return; - } - - setState(() { - _notes = updatedNotes; - }); + await widget.repository.createNote(result); + await _loadNotes(); // Trigger sync after creating a note. try { @@ -118,18 +110,7 @@ class _HomeScreenState extends State { Future _deleteNote(Note note) async { await widget.repository.deleteNote(note); - - final List updatedNotes = _normalizeNotes( - _notes.where((Note item) => item.id != note.id).toList(), - ); - - if (!mounted) { - return; - } - - setState(() { - _notes = updatedNotes; - }); + await _loadNotes(); // Trigger sync after deleting a note. try { @@ -142,19 +123,10 @@ class _HomeScreenState extends State { return; } - final List updatedNotes = [..._notes]; - final Note movedNote = updatedNotes.removeAt(oldIndex); - updatedNotes.insert(newIndex, movedNote); + final Note movedNote = _notes[oldIndex]; await widget.repository.moveNote(movedNote, newIndex); - - if (!mounted) { - return; - } - - setState(() { - _notes = _normalizeNotes(updatedNotes); - }); + await _loadNotes(); } Future _openNoteEditor(Note note) async { @@ -173,19 +145,9 @@ class _HomeScreenState extends State { } if (result is Note) { - final int noteIndex = _notes.indexWhere((Note item) => item == note); - if (noteIndex != -1) { - final Note savedNote = await widget.repository.updateNote(result); - final List updatedNotes = [..._notes]; - updatedNotes[noteIndex] = savedNote; - - if (!mounted) { - return; - } - - setState(() { - _notes = _normalizeNotes(updatedNotes); - }); + if (_notes.any((Note item) => item == note)) { + await widget.repository.updateNote(result); + await _loadNotes(); // Trigger sync after editing a note. try { await widget.onRequestSync(); @@ -194,12 +156,6 @@ class _HomeScreenState extends State { } } - List _normalizeNotes(List notes) { - return notes.asMap().entries.map((MapEntry entry) { - return entry.value.copyWith(index: entry.key); - }).toList(); - } - List _getFilteredNotes() { if (_searchQuery.isEmpty) { return _notes; @@ -215,6 +171,36 @@ class _HomeScreenState extends State { .toList(); } + Future _handleMenuItemTapped(String item) async { + setState(() { + _isMenuOpen = false; + }); + + if (item == 'settings') { + widget.onOpenSettings(); + return; + } + + if (item == 'deleted_notes') { + setState(() { + _showDeletedNotes = true; + _searchQuery = ''; + _isLoading = true; + }); + await _loadNotes(); + return; + } + + if (item == 'all_notes') { + setState(() { + _showDeletedNotes = false; + _searchQuery = ''; + _isLoading = true; + }); + await _loadNotes(); + } + } + @override Widget build(BuildContext context) { final double width = MediaQuery.of(context).size.width; @@ -223,8 +209,8 @@ class _HomeScreenState extends State { final Widget body = _isLoading ? const Center(child: CircularProgressIndicator()) : _notes.isEmpty - ? const _EmptyState() - : RefreshIndicator( + ? _EmptyState(showDeletedNotes: _showDeletedNotes) + : RefreshIndicator( onRefresh: () async { await widget.onRequestSync(); }, @@ -599,15 +585,10 @@ class _HomeScreenState extends State { color: const Color.fromRGBO(24, 25, 26, 1), elevation: 8, child: MenuDrawer( - onMenuItemTapped: (String item) { - setState(() { - _isMenuOpen = false; - }); - - if (item == 'settings') { - widget.onOpenSettings(); - } - }, + onMenuItemTapped: _handleMenuItemTapped, + selectedItem: _showDeletedNotes + ? 'deleted_notes' + : 'all_notes', ), ), ), @@ -618,41 +599,47 @@ class _HomeScreenState extends State { ), ), ), - floatingActionButton: FloatingActionButton( - onPressed: _openNoteComposer, - child: const MouseRegion( - cursor: SystemMouseCursors.click, - child: Icon(Icons.add), - ), - ), + floatingActionButton: _showDeletedNotes + ? null + : FloatingActionButton( + onPressed: _openNoteComposer, + child: const MouseRegion( + cursor: SystemMouseCursors.click, + child: Icon(Icons.add), + ), + ), ); } } class _EmptyState extends StatelessWidget { - const _EmptyState(); + const _EmptyState({required this.showDeletedNotes}); + + final bool showDeletedNotes; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.note_add_outlined, color: Colors.white54, size: 48), - SizedBox(height: 12), + children: [ + const Icon(Icons.note_add_outlined, color: Colors.white54, size: 48), + const SizedBox(height: 12), Text( - 'Aún no hay notas', - style: TextStyle( + showDeletedNotes ? 'No hay notas borradas' : 'Aún no hay notas', + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, ), ), - SizedBox(height: 8), + const SizedBox(height: 8), Text( - 'Pulsa el botón + para crear la primera.', + showDeletedNotes + ? 'Las notas borradas aparecerán aquí para poder restaurarlas.' + : 'Pulsa el botón + para crear la primera.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: const TextStyle(color: Colors.white70), ), ], ), diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index f91ba94..f906a43 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -93,18 +93,21 @@ class _NoteEditorScreenState extends State { } void _deleteNote() { + final bool isDeletedNote = _currentNote.isDeleted; showDialog( context: context, builder: (BuildContext context) { return AlertDialog( backgroundColor: const Color(0xFF303134), - title: const Text( - 'Eliminar nota', - style: TextStyle(color: Colors.white), + title: Text( + isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar nota', + style: const TextStyle(color: Colors.white), ), - content: const Text( - '¿Estás seguro de que deseas eliminar esta nota?', - style: TextStyle(color: Colors.white70), + content: Text( + isDeletedNote + ? 'Esta nota ya está borrada. Si la eliminas ahora, se borrará permanentemente.' + : '¿Estás seguro de que deseas eliminar esta nota?', + style: const TextStyle(color: Colors.white70), ), actions: [ TextButton( @@ -119,9 +122,9 @@ class _NoteEditorScreenState extends State { Navigator.of(context).pop(); Navigator.of(context).pop('delete'); }, - child: const Text( - 'Eliminar', - style: TextStyle(color: Colors.red), + child: Text( + isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar', + style: const TextStyle(color: Colors.red), ), ), ], diff --git a/lib/widgets/menu_drawer.dart b/lib/widgets/menu_drawer.dart index 7e070a3..de5de1b 100644 --- a/lib/widgets/menu_drawer.dart +++ b/lib/widgets/menu_drawer.dart @@ -4,9 +4,11 @@ class MenuDrawer extends StatelessWidget { const MenuDrawer({ super.key, this.onMenuItemTapped, + this.selectedItem, }); final ValueChanged? onMenuItemTapped; + final String? selectedItem; @override Widget build(BuildContext context) { @@ -19,9 +21,11 @@ class MenuDrawer extends StatelessWidget { ), child: Column( children: [ + const SizedBox(height: 8), _MenuItemTile( icon: Icons.note, label: 'Todas mis notas', + selected: selectedItem == 'all_notes', onTap: () => onMenuItemTapped?.call('all_notes'), ), Expanded( @@ -33,12 +37,19 @@ class MenuDrawer extends StatelessWidget { ), ), ), + _MenuItemTile( + icon: Icons.delete_outline, + label: 'Mis notas borradas', + selected: selectedItem == 'deleted_notes', + onTap: () => onMenuItemTapped?.call('deleted_notes'), + ), const Divider(color: Colors.white12, height: 16), _MenuItemTile( icon: Icons.settings, label: 'Configuración', onTap: () => onMenuItemTapped?.call('settings'), ), + const SizedBox(height: 6), ], ), ); @@ -49,23 +60,49 @@ class _MenuItemTile extends StatelessWidget { const _MenuItemTile({ required this.icon, required this.label, + this.selected = false, this.onTap, }); final IconData icon; final String label; + final bool selected; final VoidCallback? onTap; @override Widget build(BuildContext context) { - return ListTile( - leading: Icon(icon, color: Colors.white70), - title: Text( - label, - style: const TextStyle(color: Colors.white70, fontSize: 14), + final Color backgroundColor = selected + ? Colors.white.withValues(alpha: 0.10) + : Colors.transparent; + final Color foregroundColor = selected ? Colors.white : Colors.white70; + + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + margin: EdgeInsets.only( + left: selected ? 0 : 8, + right: 8, + top: 2, + bottom: 2, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: selected + ? const BorderRadius.only( + topRight: Radius.circular(999), + bottomRight: Radius.circular(999), + ) + : BorderRadius.circular(12), + ), + child: ListTile( + leading: Icon(icon, color: foregroundColor), + title: Text( + label, + style: TextStyle(color: foregroundColor, fontSize: 14), + ), + onTap: onTap, + hoverColor: Colors.white.withValues(alpha: 0.1), ), - onTap: onTap, - hoverColor: Colors.white.withValues(alpha: 0.1), ); } }