feat: Implement note positioning logic and tests for position conversion

This commit is contained in:
2026-05-22 17:31:40 +02:00
parent cdfd4f9342
commit 729e575a60
6 changed files with 228 additions and 87 deletions
+106 -39
View File
@@ -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) {
+46
View File
@@ -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,
);
}
+12 -8
View File
@@ -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,