diff --git a/lib/data/app_database.dart b/lib/data/app_database.dart index 6637db1..1ebd8cc 100644 --- a/lib/data/app_database.dart +++ b/lib/data/app_database.dart @@ -5,6 +5,8 @@ import 'package:drift/native.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:notas/data/note_positioning.dart'; + part 'app_database.g.dart'; @DataClassName('DbCategory') @@ -48,7 +50,7 @@ class Notes extends Table { @DriftDatabase(tables: [Notes, Categories]) class AppDatabase extends _$AppDatabase { @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -69,6 +71,29 @@ class AppDatabase extends _$AppDatabase { await customStatement('UPDATE categories SET color_value = NULL'); await customStatement('UPDATE categories SET icon_code_point = NULL'); } + if (from < 4) { + final List activeNotes = await (select(notes) + ..where((n) => n.isDeleted.equals(false)) + ..orderBy([ + (note) => OrderingTerm(expression: note.sortIndex), + ])) + .get(); + + final List rebalancedPositions = rebalanceNotePositions( + activeNotes.length, + ); + + for (var index = 0; index < activeNotes.length; index += 1) { + final DbNote row = activeNotes[index]; + await (update(notes)..where((n) => n.id.equals(row.id))).write( + NotesCompanion( + sortIndex: Value(rebalancedPositions[index]), + updatedAt: Value(DateTime.now()), + isDirty: const Value(true), + ), + ); + } + } }, ); @@ -97,17 +122,37 @@ class AppDatabase extends _$AppDatabase { // ========== Notes ========== Future> getAllNotes() { return (select(notes) - ..orderBy([(note) => OrderingTerm(expression: note.sortIndex)]) + ..orderBy([ + (note) => OrderingTerm( + expression: note.sortIndex, + mode: OrderingMode.desc, + ), + ]) ..where((n) => n.isDeleted.equals(false))) .get(); } Future insertNoteAtTop(NotesCompanion note) { return transaction(() async { - await customStatement( - 'UPDATE notes SET sort_index = sort_index + 1, is_dirty = 1 WHERE is_deleted = 0', + final DbNote? topNote = await (select(notes) + ..where((n) => n.isDeleted.equals(false)) + ..orderBy([ + (note) => OrderingTerm( + expression: note.sortIndex, + mode: OrderingMode.desc, + ), + ]) + ..limit(1)) + .getSingleOrNull(); + + final int nextSortIndex = topNote == null + ? 0 + : topNote.sortIndex + notePositionStep; + + await into(notes).insert( + note.copyWith(sortIndex: Value(nextSortIndex)), ); - return into(notes).insert(note.copyWith(sortIndex: const Value(0))); + return nextSortIndex; }); } @@ -133,11 +178,6 @@ class AppDatabase extends _$AppDatabase { isDirty: const Value(true), ), ); - - await customStatement( - 'UPDATE notes SET sort_index = sort_index - 1, is_dirty = 1 WHERE sort_index > ? AND is_deleted = 0', - [removedIndex], - ); } Future deleteNoteAndShift({ @@ -165,44 +205,64 @@ class AppDatabase extends _$AppDatabase { required int oldIndex, required int newIndex, }) { - if (oldIndex == newIndex) { - return Future.value(); - } - return transaction(() async { - final List all = await (select( - notes, - )..where((n) => n.isDeleted.equals(false))).get(); + final List orderedNotes = await (select(notes) + ..where((n) => n.isDeleted.equals(false)) + ..orderBy([ + (note) => OrderingTerm( + expression: note.sortIndex, + mode: OrderingMode.desc, + ), + ])) + .get(); - final int count = all.length; - if (count == 0) { + final int currentIndex = orderedNotes.indexWhere((DbNote row) => row.id == id); + if (currentIndex == -1) { return; } - final int maxIndex = count - 1; - - final int safeOld = oldIndex.clamp(0, maxIndex); - final int safeNew = newIndex.clamp(0, maxIndex); - - if (safeOld == safeNew) { + final int safeNewIndex = newIndex.clamp(0, orderedNotes.length - 1); + if (currentIndex == safeNewIndex) { return; } - if (safeOld < safeNew) { - await customStatement( - 'UPDATE notes SET sort_index = sort_index - 1, is_dirty = 1 WHERE sort_index > ? AND sort_index <= ? AND is_deleted = 0', - [safeOld, safeNew], - ); + final DbNote movedNote = orderedNotes.removeAt(currentIndex); + orderedNotes.insert(safeNewIndex, movedNote); + + final int? newStoredPosition; + if (safeNewIndex == 0) { + newStoredPosition = orderedNotes[1].sortIndex + notePositionStep; + } else if (safeNewIndex == orderedNotes.length - 1) { + newStoredPosition = + orderedNotes[orderedNotes.length - 2].sortIndex - notePositionStep; } else { - await customStatement( - 'UPDATE notes SET sort_index = sort_index + 1, is_dirty = 1 WHERE sort_index >= ? AND sort_index < ? AND is_deleted = 0', - [safeNew, safeOld], + newStoredPosition = midpointNotePosition( + higherPosition: orderedNotes[safeNewIndex - 1].sortIndex, + lowerPosition: orderedNotes[safeNewIndex + 1].sortIndex, ); } + if (newStoredPosition == null) { + final List rebalancedPositions = rebalanceNotePositions( + orderedNotes.length, + ); + + for (var index = 0; index < orderedNotes.length; index += 1) { + final DbNote row = orderedNotes[index]; + await (update(notes)..where((n) => n.id.equals(row.id))).write( + NotesCompanion( + sortIndex: Value(rebalancedPositions[index]), + updatedAt: Value(DateTime.now()), + isDirty: const Value(true), + ), + ); + } + return; + } + await (update(notes)..where((n) => n.id.equals(id))).write( NotesCompanion( - sortIndex: Value(safeNew), + sortIndex: Value(newStoredPosition), updatedAt: Value(DateTime.now()), isDirty: const Value(true), ), @@ -221,11 +281,18 @@ class AppDatabase extends _$AppDatabase { // and at least one of `title` or `body` is not empty. Previously the // query required both title AND body to be non-empty which excluded // notes that had an empty body (common) from appearing in the trash. - return (select(notes)..where( - (n) => n.isDeleted.equals(true) & - (n.title.isNotValue('') | n.body.isNotValue('')), - )) - .get(); + return (select(notes) + ..where( + (n) => n.isDeleted.equals(true) & + (n.title.isNotValue('') | n.body.isNotValue('')), + ) + ..orderBy([ + (note) => OrderingTerm( + expression: note.sortIndex, + mode: OrderingMode.desc, + ), + ])) + .get(); } Future> getCategoriesChangedSince(DateTime since) { diff --git a/lib/data/note_positioning.dart b/lib/data/note_positioning.dart new file mode 100644 index 0000000..69c517b --- /dev/null +++ b/lib/data/note_positioning.dart @@ -0,0 +1,46 @@ +const int notePositionScale = 10; +const int notePositionStep = 1000 * notePositionScale; +const int notePositionRebalanceThreshold = 1; + +int toStoredNotePosition(double position) { + return (position * notePositionScale).round(); +} + +double fromStoredNotePosition(int storedPosition) { + return storedPosition / notePositionScale; +} + +int nextTopNotePosition(Iterable storedPositions) { + int? highestPosition; + + for (final int position in storedPositions) { + if (highestPosition == null || position > highestPosition) { + highestPosition = position; + } + } + + if (highestPosition == null) { + return 0; + } + + return highestPosition + notePositionStep; +} + +int? midpointNotePosition({ + required int higherPosition, + required int lowerPosition, +}) { + final int gap = higherPosition - lowerPosition; + if (gap <= notePositionRebalanceThreshold) { + return null; + } + + return lowerPosition + (gap ~/ 2); +} + +List rebalanceNotePositions(int itemCount) { + return List.generate( + itemCount, + (int index) => (itemCount - 1 - index) * notePositionStep, + ); +} \ No newline at end of file diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index c16d818..7181803 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -6,6 +6,7 @@ import 'dart:io' show Platform; import 'package:drift/drift.dart'; import 'package:notas/data/app_database.dart'; import 'package:notas/data/api_client.dart'; +import 'package:notas/data/note_positioning.dart'; import 'package:notas/data/sync_models.dart'; import 'package:notas/models/note.dart'; import 'package:notas/models/category.dart'; @@ -91,7 +92,7 @@ class NoteRepository { } Future createNote(Note note) async { - await _database.insertNoteAtTop( + final int storedPosition = await _database.insertNoteAtTop( NotesCompanion.insert( id: note.id, title: note.title, @@ -106,7 +107,10 @@ class NoteRepository { ), ); - return note.copyWith(position: 0, isDirty: true); + return note.copyWith( + position: fromStoredNotePosition(storedPosition), + isDirty: true, + ); } Future updateNote(Note note) async { @@ -137,7 +141,7 @@ class NoteRepository { isDeleted: false, isPermanentlyDeleted: false, isDirty: true, - position: row.sortIndex.toDouble(), + position: fromStoredNotePosition(row.sortIndex), ); } @@ -364,7 +368,7 @@ class NoteRepository { body: isPermanentlyDeleted ? '' : decryptedBody, createdAt: existingNote.createdAt, updatedAt: noteResponse.updatedAt, - sortIndex: noteResponse.position.round(), + sortIndex: toStoredNotePosition(noteResponse.position), serverVersion: noteResponse.serverVersion, isDeleted: noteResponse.isDeleted, categoryId: isPermanentlyDeleted ? null : noteResponse.categoryId, @@ -382,7 +386,7 @@ class NoteRepository { body: Value(isPermanentlyDeleted ? '' : decryptedBody), createdAt: Value(noteResponse.updatedAt), updatedAt: Value(noteResponse.updatedAt), - sortIndex: Value(noteResponse.position.round()), + sortIndex: Value(toStoredNotePosition(noteResponse.position)), serverVersion: Value(noteResponse.serverVersion), isDeleted: Value(noteResponse.isDeleted), categoryId: Value( @@ -412,7 +416,7 @@ class NoteRepository { body: row.body, createdAt: row.createdAt, updatedAt: row.updatedAt, - position: row.sortIndex.toDouble(), + position: fromStoredNotePosition(row.sortIndex), serverVersion: row.serverVersion, isDeleted: row.isDeleted, isPermanentlyDeleted: _isPermanentlyDeleted(row), @@ -585,7 +589,7 @@ Map _dbNoteToEncryptionInput(DbNote row, int index) { 'updatedAt': row.updatedAt.toIso8601String(), 'categoryId': row.categoryId, 'serverVersion': row.serverVersion, - 'position': row.sortIndex, + 'position': fromStoredNotePosition(row.sortIndex), 'isDeleted': row.isDeleted, 'isPermanentlyDeleted': isPermanentlyDeleted, }; @@ -635,7 +639,7 @@ Future>> _encryptNoteBatch( 'encryptedTitle': encryptedTitle, 'encryptedBody': encryptedBody, 'serverVersion': note['serverVersion']! as int, - 'position': note['position']! as int, + 'position': (note['position'] as num).toDouble(), 'isDeleted': note['isDeleted']! as bool, 'isPermanentlyDeleted': isPermanentlyDeleted, 'updatedAt': note['updatedAt']! as String, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 9a0eada..4ee103f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -87,7 +87,9 @@ class _HomeScreenState extends State { _loadNotesAndCategories(); } - Future _loadNotesAndCategories({bool showLoadingIndicator = true}) async { + Future _loadNotesAndCategories({ + bool showLoadingIndicator = true, + }) async { if (showLoadingIndicator) { setState(() { _isLoading = true; @@ -97,8 +99,8 @@ class _HomeScreenState extends State { final Future> notesFuture = _showDeletedNotes ? widget.repository.loadDeletedNotes() : widget.repository.loadNotes(); - final Future> categoriesFuture = - widget.repository.loadCategories(); + final Future> categoriesFuture = widget.repository + .loadCategories(); List notesResult = []; List categoriesResult = []; @@ -362,7 +364,8 @@ class _HomeScreenState extends State { final Category? currentCategory = _currentCategory(); final Map categoryBorderColors = { for (final Category category in _categories) - if (category.colorValue != null) category.id: Color(category.colorValue!), + if (category.colorValue != null) + category.id: Color(category.colorValue!), }; final Widget body = _isLoading @@ -421,9 +424,12 @@ class _HomeScreenState extends State { _lastPointerKind, ); - final Widget draggableNote = _DraggableNote( + final Widget + draggableNote = _DraggableNote( note: filteredNotes[index], - borderColor: categoryBorderColors[filteredNotes[index].categoryId], + borderColor: + categoryBorderColors[filteredNotes[index] + .categoryId], dataIndex: _notes.indexOf( filteredNotes[index], ), @@ -582,7 +588,7 @@ class _HomeScreenState extends State { child: MenuDrawer( onMenuItemTapped: _handleMenuItemTapped, selectedItem: _selectedCategoryId != null - ? 'category_$_selectedCategoryId' + ? 'category_$_selectedCategoryId' : (_showDeletedNotes ? 'deleted_notes' : 'all_notes'), @@ -840,12 +846,11 @@ class _CategoryDialogState extends State<_CategoryDialog> { void initState() { super.initState(); _controller = TextEditingController(text: widget.category?.name ?? ''); - _selectedColor = - widget.category == null + _selectedColor = widget.category == null ? CategoryStyle.colors.first : widget.category!.colorValue != null - ? Color(widget.category!.colorValue!) - : null; + ? Color(widget.category!.colorValue!) + : null; if (widget.category != null && widget.category!.iconCodePoint != null) { _selectedIcon = CategoryStyle.icons.firstWhere( (IconData icon) => icon.codePoint == widget.category!.iconCodePoint, @@ -906,7 +911,6 @@ class _CategoryDialogState extends State<_CategoryDialog> { } widget.onRequestSync().catchError((_) {}); - } catch (e) { debugPrint('ERROR creating category: $e'); if (mounted) { @@ -922,24 +926,6 @@ class _CategoryDialogState extends State<_CategoryDialog> { } } - Future _runPostSaveCallbacks({ - required Future Function() onCategoriesChanged, - required Future Function() onRequestSync, - required Future Function() onCategoryDeleted, - }) async { - try { - await onCategoriesChanged(); - } catch (_) {} - - try { - await onCategoryDeleted(); - } catch (_) {} - - unawaited( - onRequestSync().catchError((_) {}), - ); - } - Future _deleteCategory() async { final bool? confirm = await showDialog( context: context, @@ -1002,11 +988,14 @@ class _CategoryDialogState extends State<_CategoryDialog> { }); } }, - decoration: const InputDecoration( - hintText: 'Nombre de la categoría', - ).copyWith( - errorText: _nameHasError ? 'El nombre es obligatorio' : null, - ), + decoration: + const InputDecoration( + hintText: 'Nombre de la categoría', + ).copyWith( + errorText: _nameHasError + ? 'El nombre es obligatorio' + : null, + ), ), const SizedBox(height: 16), Container( @@ -1084,7 +1073,9 @@ class _CategoryDialogState extends State<_CategoryDialog> { key: const ValueKey('icons'), spacing: 10, runSpacing: 10, - children: CategoryStyle.icons.map((IconData icon) { + children: CategoryStyle.icons.map(( + IconData icon, + ) { final bool isSelected = _selectedIcon == icon; return GestureDetector( onTap: () => setState(() { diff --git a/lib/screens/note_editor_screen.dart b/lib/screens/note_editor_screen.dart index 9869a5c..e418d0a 100644 --- a/lib/screens/note_editor_screen.dart +++ b/lib/screens/note_editor_screen.dart @@ -336,7 +336,9 @@ class _NoteEditorScreenState extends State { Widget _buildCategorySelectorBox({Category? category}) { final String label = category?.name ?? 'Sin categoría'; - final IconData icon = CategoryStyle.iconForCodePoint(category?.iconCodePoint); + final IconData icon = CategoryStyle.iconForCodePoint( + category?.iconCodePoint, + ); final Color backgroundColor = _categoryBackgroundColor(category); final Color foregroundColor = _categoryForegroundColor(category); @@ -475,7 +477,9 @@ class _NoteEditorScreenState extends State { }) { final Color backgroundColor = _categoryBackgroundColor(category); final Color foregroundColor = _categoryForegroundColor(category); - final IconData icon = CategoryStyle.iconForCodePoint(category?.iconCodePoint); + final IconData icon = CategoryStyle.iconForCodePoint( + category?.iconCodePoint, + ); return InkWell( onTap: onTap, @@ -510,8 +514,7 @@ class _NoteEditorScreenState extends State { ), ), ), - if (isSelected) - Icon(Icons.check, color: foregroundColor, size: 18), + if (isSelected) Icon(Icons.check, color: foregroundColor, size: 18), ], ), ), @@ -565,6 +568,13 @@ class _NoteEditorScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text( + 'Posicion: ${_currentNote.position}', + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + ), + ), Text( 'Creado: ${_formatDate(_currentNote.createdAt)}', style: const TextStyle( diff --git a/test/note_positioning_test.dart b/test/note_positioning_test.dart new file mode 100644 index 0000000..79beffc --- /dev/null +++ b/test/note_positioning_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:notas/data/note_positioning.dart'; + +void main() { + test('converts between stored and displayed positions', () { + expect(toStoredNotePosition(1500.5), 15005); + expect(fromStoredNotePosition(15005), 1500.5); + }); + + test('supports descending gaps and rebalance', () { + expect(nextTopNotePosition([0, 10000, 20000]), 30000); + expect( + midpointNotePosition(higherPosition: 20000, lowerPosition: 10000), + 15000, + ); + expect( + midpointNotePosition(higherPosition: 2, lowerPosition: 1), + isNull, + ); + expect(rebalanceNotePositions(3), [20000, 10000, 0]); + }); +} \ No newline at end of file