feat: Implement note positioning logic and tests for position conversion
This commit is contained in:
+106
-39
@@ -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<DbNote> activeNotes = await (select(notes)
|
||||
..where((n) => n.isDeleted.equals(false))
|
||||
..orderBy([
|
||||
(note) => OrderingTerm(expression: note.sortIndex),
|
||||
]))
|
||||
.get();
|
||||
|
||||
final List<int> 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<List<DbNote>> 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<int> 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<int>(nextSortIndex)),
|
||||
);
|
||||
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(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<void> deleteNoteAndShift({
|
||||
@@ -165,44 +205,64 @@ class AppDatabase extends _$AppDatabase {
|
||||
required int oldIndex,
|
||||
required int newIndex,
|
||||
}) {
|
||||
if (oldIndex == newIndex) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
return transaction(() async {
|
||||
final List<DbNote> all = await (select(
|
||||
notes,
|
||||
)..where((n) => n.isDeleted.equals(false))).get();
|
||||
final List<DbNote> 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<int> 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<int>(rebalancedPositions[index]),
|
||||
updatedAt: Value<DateTime>(DateTime.now()),
|
||||
isDirty: const Value(true),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await (update(notes)..where((n) => n.id.equals(id))).write(
|
||||
NotesCompanion(
|
||||
sortIndex: Value<int>(safeNew),
|
||||
sortIndex: Value<int>(newStoredPosition),
|
||||
updatedAt: Value<DateTime>(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<List<DbCategory>> getCategoriesChangedSince(DateTime since) {
|
||||
|
||||
@@ -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<int> 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<int> rebalanceNotePositions(int itemCount) {
|
||||
return List<int>.generate(
|
||||
itemCount,
|
||||
(int index) => (itemCount - 1 - index) * notePositionStep,
|
||||
);
|
||||
}
|
||||
@@ -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<Note> 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<Note> 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<String, Object?> _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<List<Map<String, Object?>>> _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,
|
||||
|
||||
@@ -87,7 +87,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_loadNotesAndCategories();
|
||||
}
|
||||
|
||||
Future<void> _loadNotesAndCategories({bool showLoadingIndicator = true}) async {
|
||||
Future<void> _loadNotesAndCategories({
|
||||
bool showLoadingIndicator = true,
|
||||
}) async {
|
||||
if (showLoadingIndicator) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -97,8 +99,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final Future<List<Note>> notesFuture = _showDeletedNotes
|
||||
? widget.repository.loadDeletedNotes()
|
||||
: widget.repository.loadNotes();
|
||||
final Future<List<Category>> categoriesFuture =
|
||||
widget.repository.loadCategories();
|
||||
final Future<List<Category>> categoriesFuture = widget.repository
|
||||
.loadCategories();
|
||||
|
||||
List<Note> notesResult = <Note>[];
|
||||
List<Category> categoriesResult = <Category>[];
|
||||
@@ -362,7 +364,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final Category? currentCategory = _currentCategory();
|
||||
final Map<String, Color> categoryBorderColors = <String, Color>{
|
||||
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<HomeScreen> {
|
||||
_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<HomeScreen> {
|
||||
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<void> _runPostSaveCallbacks({
|
||||
required Future<void> Function() onCategoriesChanged,
|
||||
required Future<void> Function() onRequestSync,
|
||||
required Future<void> Function() onCategoryDeleted,
|
||||
}) async {
|
||||
try {
|
||||
await onCategoriesChanged();
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await onCategoryDeleted();
|
||||
} catch (_) {}
|
||||
|
||||
unawaited(
|
||||
onRequestSync().catchError((_) {}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteCategory() async {
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
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<String>('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(() {
|
||||
|
||||
@@ -336,7 +336,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
|
||||
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<NoteEditorScreen> {
|
||||
}) {
|
||||
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<NoteEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<NoteEditorScreen> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user