feat: Implement permanent deletion and restoration of notes with updated UI
This commit is contained in:
@@ -111,6 +111,18 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return deleteNote(id, removedIndex);
|
return deleteNote(id, removedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> moveNote({
|
Future<void> moveNote({
|
||||||
required int id,
|
required int id,
|
||||||
required int oldIndex,
|
required int oldIndex,
|
||||||
@@ -146,6 +158,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
)..where((n) => n.updatedAt.isBiggerThanValue(since))).get();
|
)..where((n) => n.updatedAt.isBiggerThanValue(since))).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<DbNote>> getDeletedNotes() {
|
||||||
|
return (select(notes)
|
||||||
|
..where((n) => n.isDeleted.equals(true) & n.title.isNotValue('') & n.body.isNotValue('')))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
|
Future<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
|
||||||
return (select(
|
return (select(
|
||||||
categories,
|
categories,
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ class NoteRepository {
|
|||||||
return _loadNotesFromDatabase();
|
return _loadNotesFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Note>> loadDeletedNotes() async {
|
||||||
|
return _loadDeletedNotesFromDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
||||||
@@ -63,12 +67,12 @@ class NoteRepository {
|
|||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
sortIndex: note.index,
|
sortIndex: note.index,
|
||||||
serverVersion: note.serverVersion,
|
serverVersion: note.serverVersion,
|
||||||
isDeleted: note.isDeleted,
|
isDeleted: false,
|
||||||
categoryId: note.categoryId,
|
categoryId: note.categoryId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return note;
|
return note.copyWith(isDeleted: false, isPermanentlyDeleted: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteNote(Note note) async {
|
Future<void> deleteNote(Note note) async {
|
||||||
@@ -76,7 +80,11 @@ class NoteRepository {
|
|||||||
note.id ??
|
note.id ??
|
||||||
(throw ArgumentError('Note id is required to delete a note.'));
|
(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<void> moveNote(Note note, int newIndex) async {
|
Future<void> moveNote(Note note, int newIndex) async {
|
||||||
@@ -131,7 +139,7 @@ class NoteRepository {
|
|||||||
final int totalNotesToEncrypt = unsyncedNotes.length;
|
final int totalNotesToEncrypt = unsyncedNotes.length;
|
||||||
|
|
||||||
// Build sync request (note: we send encrypted data, but locally we have plaintext)
|
// 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) {
|
if (totalNotesToEncrypt == 0) {
|
||||||
onProgress?.call(
|
onProgress?.call(
|
||||||
SyncStatus.encrypting,
|
SyncStatus.encrypting,
|
||||||
@@ -254,6 +262,7 @@ class NoteRepository {
|
|||||||
final String decryptedTitle = decryptedNote['title']! as String;
|
final String decryptedTitle = decryptedNote['title']! as String;
|
||||||
final String decryptedBody = decryptedNote['body']! as String;
|
final String decryptedBody = decryptedNote['body']! as String;
|
||||||
final SyncNoteResponse noteResponse = response.changes.notes[index];
|
final SyncNoteResponse noteResponse = response.changes.notes[index];
|
||||||
|
final bool isPermanentlyDeleted = noteResponse.isPermanentlyDeleted;
|
||||||
|
|
||||||
if (existingNote != null) {
|
if (existingNote != null) {
|
||||||
// Update existing note
|
// Update existing note
|
||||||
@@ -261,14 +270,14 @@ class NoteRepository {
|
|||||||
DbNote(
|
DbNote(
|
||||||
id: existingNote.id,
|
id: existingNote.id,
|
||||||
uuid: noteId,
|
uuid: noteId,
|
||||||
title: decryptedTitle,
|
title: isPermanentlyDeleted ? '' : decryptedTitle,
|
||||||
body: decryptedBody,
|
body: isPermanentlyDeleted ? '' : decryptedBody,
|
||||||
createdAt: existingNote.createdAt,
|
createdAt: existingNote.createdAt,
|
||||||
updatedAt: noteResponse.updatedAt,
|
updatedAt: noteResponse.updatedAt,
|
||||||
sortIndex: noteResponse.position,
|
sortIndex: noteResponse.position,
|
||||||
serverVersion: noteResponse.serverVersion,
|
serverVersion: noteResponse.serverVersion,
|
||||||
isDeleted: noteResponse.isDeleted,
|
isDeleted: noteResponse.isDeleted,
|
||||||
categoryId: noteResponse.categoryId,
|
categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -278,14 +287,14 @@ class NoteRepository {
|
|||||||
.insert(
|
.insert(
|
||||||
NotesCompanion(
|
NotesCompanion(
|
||||||
uuid: Value(noteResponse.id),
|
uuid: Value(noteResponse.id),
|
||||||
title: Value(decryptedTitle),
|
title: Value(isPermanentlyDeleted ? '' : decryptedTitle),
|
||||||
body: Value(decryptedBody),
|
body: Value(isPermanentlyDeleted ? '' : decryptedBody),
|
||||||
createdAt: Value(noteResponse.updatedAt),
|
createdAt: Value(noteResponse.updatedAt),
|
||||||
updatedAt: Value(noteResponse.updatedAt),
|
updatedAt: Value(noteResponse.updatedAt),
|
||||||
sortIndex: Value(noteResponse.position),
|
sortIndex: Value(noteResponse.position),
|
||||||
serverVersion: Value(noteResponse.serverVersion),
|
serverVersion: Value(noteResponse.serverVersion),
|
||||||
isDeleted: Value(noteResponse.isDeleted),
|
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();
|
return rows.map(_fromDbNote).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Note>> _loadDeletedNotesFromDatabase() async {
|
||||||
|
final List<DbNote> rows = await _database.getDeletedNotes();
|
||||||
|
return rows.map(_fromDbNote).toList();
|
||||||
|
}
|
||||||
|
|
||||||
Note _fromDbNote(DbNote row) {
|
Note _fromDbNote(DbNote row) {
|
||||||
return Note(
|
return Note(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -308,6 +322,7 @@ class NoteRepository {
|
|||||||
index: row.sortIndex,
|
index: row.sortIndex,
|
||||||
serverVersion: row.serverVersion,
|
serverVersion: row.serverVersion,
|
||||||
isDeleted: row.isDeleted,
|
isDeleted: row.isDeleted,
|
||||||
|
isPermanentlyDeleted: _isPermanentlyDeleted(row),
|
||||||
categoryId: row.categoryId,
|
categoryId: row.categoryId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -443,10 +458,13 @@ Map<String, Object?> _syncNoteToDecryptionInput(
|
|||||||
'id': row.id,
|
'id': row.id,
|
||||||
'encryptedTitle': row.encryptedTitle,
|
'encryptedTitle': row.encryptedTitle,
|
||||||
'encryptedBody': row.encryptedBody,
|
'encryptedBody': row.encryptedBody,
|
||||||
|
'isPermanentlyDeleted': row.isPermanentlyDeleted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
|
Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
|
||||||
|
final bool isPermanentlyDeleted = _isPermanentlyDeleted(row);
|
||||||
|
|
||||||
return <String, Object?>{
|
return <String, Object?>{
|
||||||
'index': index,
|
'index': index,
|
||||||
'uuid': row.uuid,
|
'uuid': row.uuid,
|
||||||
@@ -458,6 +476,7 @@ Map<String, Object?> _dbNoteToEncryptionInput(DbNote row, int index) {
|
|||||||
'serverVersion': row.serverVersion,
|
'serverVersion': row.serverVersion,
|
||||||
'position': row.sortIndex,
|
'position': row.sortIndex,
|
||||||
'isDeleted': row.isDeleted,
|
'isDeleted': row.isDeleted,
|
||||||
|
'isPermanentlyDeleted': isPermanentlyDeleted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +489,7 @@ SyncNotePayload _syncNotePayloadFromEncryptionResult(Map<String, Object?> row) {
|
|||||||
serverVersion: row['serverVersion']! as int,
|
serverVersion: row['serverVersion']! as int,
|
||||||
position: row['position']! as int,
|
position: row['position']! as int,
|
||||||
isDeleted: row['isDeleted']! as bool,
|
isDeleted: row['isDeleted']! as bool,
|
||||||
|
isPermanentlyDeleted: row['isPermanentlyDeleted']! as bool,
|
||||||
updatedAt: DateTime.parse(row['updatedAt']! as String),
|
updatedAt: DateTime.parse(row['updatedAt']! as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,17 +503,25 @@ Future<List<Map<String, Object?>>> _encryptNoteBatch(
|
|||||||
|
|
||||||
final List<Map<String, Object?>> encryptedNotes = [];
|
final List<Map<String, Object?>> encryptedNotes = [];
|
||||||
for (final Map<String, Object?> note in notes) {
|
for (final Map<String, Object?> note in notes) {
|
||||||
|
final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
|
||||||
final String title = note['title']! as String;
|
final String title = note['title']! as String;
|
||||||
final String body = note['body']! as String;
|
final String body = note['body']! as String;
|
||||||
|
|
||||||
final String encryptedTitle = await NoteEncryption.encryptNote(
|
final String encryptedTitle;
|
||||||
title,
|
final String encryptedBody;
|
||||||
masterKey,
|
if (isPermanentlyDeleted) {
|
||||||
);
|
encryptedTitle = '';
|
||||||
final String encryptedBody = await NoteEncryption.encryptNote(
|
encryptedBody = '';
|
||||||
body,
|
} else {
|
||||||
masterKey,
|
encryptedTitle = await NoteEncryption.encryptNote(
|
||||||
);
|
title,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
encryptedBody = await NoteEncryption.encryptNote(
|
||||||
|
body,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
encryptedNotes.add(<String, Object?>{
|
encryptedNotes.add(<String, Object?>{
|
||||||
'index': note['index'] as int,
|
'index': note['index'] as int,
|
||||||
@@ -504,6 +532,7 @@ Future<List<Map<String, Object?>>> _encryptNoteBatch(
|
|||||||
'serverVersion': note['serverVersion']! as int,
|
'serverVersion': note['serverVersion']! as int,
|
||||||
'position': note['position']! as int,
|
'position': note['position']! as int,
|
||||||
'isDeleted': note['isDeleted']! as bool,
|
'isDeleted': note['isDeleted']! as bool,
|
||||||
|
'isPermanentlyDeleted': isPermanentlyDeleted,
|
||||||
'updatedAt': note['updatedAt']! as String,
|
'updatedAt': note['updatedAt']! as String,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -520,19 +549,25 @@ Future<List<Map<String, Object?>>> _decryptNoteBatch(
|
|||||||
|
|
||||||
final List<Map<String, Object?>> decryptedNotes = [];
|
final List<Map<String, Object?>> decryptedNotes = [];
|
||||||
for (final Map<String, Object?> note in notes) {
|
for (final Map<String, Object?> note in notes) {
|
||||||
|
final bool isPermanentlyDeleted = note['isPermanentlyDeleted']! as bool;
|
||||||
String decryptedTitle = 'Encrypted';
|
String decryptedTitle = 'Encrypted';
|
||||||
String decryptedBody = 'Encrypted';
|
String decryptedBody = 'Encrypted';
|
||||||
try {
|
if (!isPermanentlyDeleted) {
|
||||||
decryptedTitle = await NoteEncryption.decryptNote(
|
try {
|
||||||
note['encryptedTitle']! as String,
|
decryptedTitle = await NoteEncryption.decryptNote(
|
||||||
masterKey,
|
note['encryptedTitle']! as String,
|
||||||
);
|
masterKey,
|
||||||
decryptedBody = await NoteEncryption.decryptNote(
|
);
|
||||||
note['encryptedBody']! as String,
|
decryptedBody = await NoteEncryption.decryptNote(
|
||||||
masterKey,
|
note['encryptedBody']! as String,
|
||||||
);
|
masterKey,
|
||||||
} catch (e) {
|
);
|
||||||
print('Failed to decrypt note ${note['id']}: $e');
|
} catch (e) {
|
||||||
|
print('Failed to decrypt note ${note['id']}: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decryptedTitle = '';
|
||||||
|
decryptedBody = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
decryptedNotes.add(<String, Object?>{
|
decryptedNotes.add(<String, Object?>{
|
||||||
@@ -540,12 +575,17 @@ Future<List<Map<String, Object?>>> _decryptNoteBatch(
|
|||||||
'id': note['id'] as String,
|
'id': note['id'] as String,
|
||||||
'title': decryptedTitle,
|
'title': decryptedTitle,
|
||||||
'body': decryptedBody,
|
'body': decryptedBody,
|
||||||
|
'isPermanentlyDeleted': isPermanentlyDeleted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return decryptedNotes;
|
return decryptedNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isPermanentlyDeleted(DbNote row) {
|
||||||
|
return row.isDeleted && row.title.isEmpty && row.body.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
int _parallelWorkerCount(int itemCount) {
|
int _parallelWorkerCount(int itemCount) {
|
||||||
final int cappedByCpu = math.max(
|
final int cappedByCpu = math.max(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ String _readStringValue(dynamic value) {
|
|||||||
return jsonEncode(value);
|
return jsonEncode(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _readOptionalStringValue(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return _readStringValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
int _readIntValue(dynamic value) {
|
int _readIntValue(dynamic value) {
|
||||||
if (value is int) {
|
if (value is int) {
|
||||||
return value;
|
return value;
|
||||||
@@ -127,6 +134,7 @@ class SyncNotePayload {
|
|||||||
required this.serverVersion,
|
required this.serverVersion,
|
||||||
this.position = 0,
|
this.position = 0,
|
||||||
this.isDeleted = false,
|
this.isDeleted = false,
|
||||||
|
this.isPermanentlyDeleted = false,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,12 +145,14 @@ class SyncNotePayload {
|
|||||||
final int serverVersion;
|
final int serverVersion;
|
||||||
final int position;
|
final int position;
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
|
final bool isPermanentlyDeleted;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
factory SyncNotePayload.fromNote(
|
factory SyncNotePayload.fromNote(
|
||||||
Note note, {
|
Note note, {
|
||||||
required String encryptedTitle,
|
required String encryptedTitle,
|
||||||
required String encryptedBody,
|
required String encryptedBody,
|
||||||
|
bool isPermanentlyDeleted = false,
|
||||||
}) {
|
}) {
|
||||||
return SyncNotePayload(
|
return SyncNotePayload(
|
||||||
id: note.uuid,
|
id: note.uuid,
|
||||||
@@ -152,6 +162,7 @@ class SyncNotePayload {
|
|||||||
serverVersion: note.serverVersion,
|
serverVersion: note.serverVersion,
|
||||||
position: note.index,
|
position: note.index,
|
||||||
isDeleted: note.isDeleted,
|
isDeleted: note.isDeleted,
|
||||||
|
isPermanentlyDeleted: isPermanentlyDeleted,
|
||||||
updatedAt: note.updatedAt,
|
updatedAt: note.updatedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,6 +176,7 @@ class SyncNotePayload {
|
|||||||
'serverVersion': serverVersion,
|
'serverVersion': serverVersion,
|
||||||
if (position != 0) 'position': position,
|
if (position != 0) 'position': position,
|
||||||
if (isDeleted) 'isDeleted': isDeleted,
|
if (isDeleted) 'isDeleted': isDeleted,
|
||||||
|
if (isPermanentlyDeleted) 'isPermanentlyDeleted': isPermanentlyDeleted,
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,6 +249,7 @@ class SyncNoteResponse {
|
|||||||
required this.serverVersion,
|
required this.serverVersion,
|
||||||
this.position = 0,
|
this.position = 0,
|
||||||
this.isDeleted = false,
|
this.isDeleted = false,
|
||||||
|
this.isPermanentlyDeleted = false,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,6 +260,7 @@ class SyncNoteResponse {
|
|||||||
final int serverVersion;
|
final int serverVersion;
|
||||||
final int position;
|
final int position;
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
|
final bool isPermanentlyDeleted;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
|
factory SyncNoteResponse.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -255,11 +269,12 @@ class SyncNoteResponse {
|
|||||||
categoryId: json['categoryId'] == null
|
categoryId: json['categoryId'] == null
|
||||||
? null
|
? null
|
||||||
: _readStringValue(json['categoryId']),
|
: _readStringValue(json['categoryId']),
|
||||||
encryptedTitle: _readStringValue(json['encrypted_title']),
|
encryptedTitle: _readOptionalStringValue(json['encrypted_title']),
|
||||||
encryptedBody: _readStringValue(json['encrypted_body']),
|
encryptedBody: _readOptionalStringValue(json['encrypted_body']),
|
||||||
serverVersion: _readIntValue(json['serverVersion']),
|
serverVersion: _readIntValue(json['serverVersion']),
|
||||||
position: json['position'] as int? ?? 0,
|
position: json['position'] as int? ?? 0,
|
||||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||||
|
isPermanentlyDeleted: json['isPermanentlyDeleted'] as bool? ?? false,
|
||||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,8 +282,8 @@ class SyncNoteResponse {
|
|||||||
Note toNote() {
|
Note toNote() {
|
||||||
return Note(
|
return Note(
|
||||||
uuid: id,
|
uuid: id,
|
||||||
title: 'Encrypted', // placeholder, será descifrado por la app
|
title: isPermanentlyDeleted ? '' : 'Encrypted',
|
||||||
body: 'Encrypted', // placeholder, será descifrado por la app
|
body: isPermanentlyDeleted ? '' : 'Encrypted',
|
||||||
createdAt: updatedAt,
|
createdAt: updatedAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
index: position,
|
index: position,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Note {
|
|||||||
required this.index,
|
required this.index,
|
||||||
this.serverVersion = 0,
|
this.serverVersion = 0,
|
||||||
this.isDeleted = false,
|
this.isDeleted = false,
|
||||||
|
this.isPermanentlyDeleted = false,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
}) : uuid = uuid ?? Uuid().v4();
|
}) : uuid = uuid ?? Uuid().v4();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class Note {
|
|||||||
final int index;
|
final int index;
|
||||||
final int serverVersion;
|
final int serverVersion;
|
||||||
final bool isDeleted;
|
final bool isDeleted;
|
||||||
|
final bool isPermanentlyDeleted;
|
||||||
final String? categoryId;
|
final String? categoryId;
|
||||||
|
|
||||||
Note copyWith({
|
Note copyWith({
|
||||||
@@ -42,6 +44,7 @@ class Note {
|
|||||||
int? index,
|
int? index,
|
||||||
int? serverVersion,
|
int? serverVersion,
|
||||||
bool? isDeleted,
|
bool? isDeleted,
|
||||||
|
bool? isPermanentlyDeleted,
|
||||||
String? categoryId,
|
String? categoryId,
|
||||||
}) {
|
}) {
|
||||||
return Note(
|
return Note(
|
||||||
@@ -54,6 +57,7 @@ class Note {
|
|||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
serverVersion: serverVersion ?? this.serverVersion,
|
serverVersion: serverVersion ?? this.serverVersion,
|
||||||
isDeleted: isDeleted ?? this.isDeleted,
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
|
isPermanentlyDeleted: isPermanentlyDeleted ?? this.isPermanentlyDeleted,
|
||||||
categoryId: categoryId ?? this.categoryId,
|
categoryId: categoryId ?? this.categoryId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
bool _isMenuOpen = false;
|
bool _isMenuOpen = false;
|
||||||
|
bool _showDeletedNotes = false;
|
||||||
PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse;
|
PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse;
|
||||||
|
|
||||||
bool _requiresLongPressToDrag(PointerDeviceKind kind) {
|
bool _requiresLongPressToDrag(PointerDeviceKind kind) {
|
||||||
@@ -71,7 +72,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
Future<void> _loadNotes() async {
|
Future<void> _loadNotes() async {
|
||||||
try {
|
try {
|
||||||
final List<Note> storedNotes = await widget.repository.loadNotes();
|
final List<Note> storedNotes = _showDeletedNotes
|
||||||
|
? await widget.repository.loadDeletedNotes()
|
||||||
|
: await widget.repository.loadNotes();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -95,19 +98,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result is Note) {
|
if (result is Note) {
|
||||||
final Note createdNote = await widget.repository.createNote(result);
|
await widget.repository.createNote(result);
|
||||||
final List<Note> updatedNotes = _normalizeNotes(<Note>[
|
await _loadNotes();
|
||||||
createdNote,
|
|
||||||
..._notes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_notes = updatedNotes;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger sync after creating a note.
|
// Trigger sync after creating a note.
|
||||||
try {
|
try {
|
||||||
@@ -118,18 +110,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
Future<void> _deleteNote(Note note) async {
|
Future<void> _deleteNote(Note note) async {
|
||||||
await widget.repository.deleteNote(note);
|
await widget.repository.deleteNote(note);
|
||||||
|
await _loadNotes();
|
||||||
final List<Note> updatedNotes = _normalizeNotes(
|
|
||||||
_notes.where((Note item) => item.id != note.id).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_notes = updatedNotes;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger sync after deleting a note.
|
// Trigger sync after deleting a note.
|
||||||
try {
|
try {
|
||||||
@@ -142,19 +123,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Note> updatedNotes = [..._notes];
|
final Note movedNote = _notes[oldIndex];
|
||||||
final Note movedNote = updatedNotes.removeAt(oldIndex);
|
|
||||||
updatedNotes.insert(newIndex, movedNote);
|
|
||||||
|
|
||||||
await widget.repository.moveNote(movedNote, newIndex);
|
await widget.repository.moveNote(movedNote, newIndex);
|
||||||
|
await _loadNotes();
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_notes = _normalizeNotes(updatedNotes);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openNoteEditor(Note note) async {
|
Future<void> _openNoteEditor(Note note) async {
|
||||||
@@ -173,19 +145,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result is Note) {
|
if (result is Note) {
|
||||||
final int noteIndex = _notes.indexWhere((Note item) => item == note);
|
if (_notes.any((Note item) => item == note)) {
|
||||||
if (noteIndex != -1) {
|
await widget.repository.updateNote(result);
|
||||||
final Note savedNote = await widget.repository.updateNote(result);
|
await _loadNotes();
|
||||||
final List<Note> updatedNotes = [..._notes];
|
|
||||||
updatedNotes[noteIndex] = savedNote;
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_notes = _normalizeNotes(updatedNotes);
|
|
||||||
});
|
|
||||||
// Trigger sync after editing a note.
|
// Trigger sync after editing a note.
|
||||||
try {
|
try {
|
||||||
await widget.onRequestSync();
|
await widget.onRequestSync();
|
||||||
@@ -194,12 +156,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Note> _normalizeNotes(List<Note> notes) {
|
|
||||||
return notes.asMap().entries.map((MapEntry<int, Note> entry) {
|
|
||||||
return entry.value.copyWith(index: entry.key);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Note> _getFilteredNotes() {
|
List<Note> _getFilteredNotes() {
|
||||||
if (_searchQuery.isEmpty) {
|
if (_searchQuery.isEmpty) {
|
||||||
return _notes;
|
return _notes;
|
||||||
@@ -215,6 +171,36 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final double width = MediaQuery.of(context).size.width;
|
final double width = MediaQuery.of(context).size.width;
|
||||||
@@ -223,8 +209,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final Widget body = _isLoading
|
final Widget body = _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _notes.isEmpty
|
: _notes.isEmpty
|
||||||
? const _EmptyState()
|
? _EmptyState(showDeletedNotes: _showDeletedNotes)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await widget.onRequestSync();
|
await widget.onRequestSync();
|
||||||
},
|
},
|
||||||
@@ -599,15 +585,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
color: const Color.fromRGBO(24, 25, 26, 1),
|
color: const Color.fromRGBO(24, 25, 26, 1),
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
child: MenuDrawer(
|
child: MenuDrawer(
|
||||||
onMenuItemTapped: (String item) {
|
onMenuItemTapped: _handleMenuItemTapped,
|
||||||
setState(() {
|
selectedItem: _showDeletedNotes
|
||||||
_isMenuOpen = false;
|
? 'deleted_notes'
|
||||||
});
|
: 'all_notes',
|
||||||
|
|
||||||
if (item == 'settings') {
|
|
||||||
widget.onOpenSettings();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -618,41 +599,47 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: _showDeletedNotes
|
||||||
onPressed: _openNoteComposer,
|
? null
|
||||||
child: const MouseRegion(
|
: FloatingActionButton(
|
||||||
cursor: SystemMouseCursors.click,
|
onPressed: _openNoteComposer,
|
||||||
child: Icon(Icons.add),
|
child: const MouseRegion(
|
||||||
),
|
cursor: SystemMouseCursors.click,
|
||||||
),
|
child: Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
const _EmptyState();
|
const _EmptyState({required this.showDeletedNotes});
|
||||||
|
|
||||||
|
final bool showDeletedNotes;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: const [
|
children: [
|
||||||
Icon(Icons.note_add_outlined, color: Colors.white54, size: 48),
|
const Icon(Icons.note_add_outlined, color: Colors.white54, size: 48),
|
||||||
SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Aún no hay notas',
|
showDeletedNotes ? 'No hay notas borradas' : 'Aún no hay notas',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white70),
|
style: const TextStyle(color: Colors.white70),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -93,18 +93,21 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _deleteNote() {
|
void _deleteNote() {
|
||||||
|
final bool isDeletedNote = _currentNote.isDeleted;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: const Color(0xFF303134),
|
backgroundColor: const Color(0xFF303134),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Eliminar nota',
|
isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar nota',
|
||||||
style: TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'¿Estás seguro de que deseas eliminar esta nota?',
|
isDeletedNote
|
||||||
style: TextStyle(color: Colors.white70),
|
? '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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -119,9 +122,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
Navigator.of(context).pop('delete');
|
Navigator.of(context).pop('delete');
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Eliminar',
|
isDeletedNote ? 'Eliminar permanentemente' : 'Eliminar',
|
||||||
style: TextStyle(color: Colors.red),
|
style: const TextStyle(color: Colors.red),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ class MenuDrawer extends StatelessWidget {
|
|||||||
const MenuDrawer({
|
const MenuDrawer({
|
||||||
super.key,
|
super.key,
|
||||||
this.onMenuItemTapped,
|
this.onMenuItemTapped,
|
||||||
|
this.selectedItem,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ValueChanged<String>? onMenuItemTapped;
|
final ValueChanged<String>? onMenuItemTapped;
|
||||||
|
final String? selectedItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -19,9 +21,11 @@ class MenuDrawer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
_MenuItemTile(
|
_MenuItemTile(
|
||||||
icon: Icons.note,
|
icon: Icons.note,
|
||||||
label: 'Todas mis notas',
|
label: 'Todas mis notas',
|
||||||
|
selected: selectedItem == 'all_notes',
|
||||||
onTap: () => onMenuItemTapped?.call('all_notes'),
|
onTap: () => onMenuItemTapped?.call('all_notes'),
|
||||||
),
|
),
|
||||||
Expanded(
|
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),
|
const Divider(color: Colors.white12, height: 16),
|
||||||
_MenuItemTile(
|
_MenuItemTile(
|
||||||
icon: Icons.settings,
|
icon: Icons.settings,
|
||||||
label: 'Configuración',
|
label: 'Configuración',
|
||||||
onTap: () => onMenuItemTapped?.call('settings'),
|
onTap: () => onMenuItemTapped?.call('settings'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -49,23 +60,49 @@ class _MenuItemTile extends StatelessWidget {
|
|||||||
const _MenuItemTile({
|
const _MenuItemTile({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
|
this.selected = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
final bool selected;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
final Color backgroundColor = selected
|
||||||
leading: Icon(icon, color: Colors.white70),
|
? Colors.white.withValues(alpha: 0.10)
|
||||||
title: Text(
|
: Colors.transparent;
|
||||||
label,
|
final Color foregroundColor = selected ? Colors.white : Colors.white70;
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
|
||||||
|
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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user