feat(app): notes in an order of one's own, with Reorder beside the tabs

With two notes or more, Reorder beside the tabs gives each note a grip
in place of its options, as paths have, until Done; e.g. to follow a
reading order. A note's position was stored already; the repository
now moves it (only that column changes, and it syncs).

The notes' bloc sits with the studyset's other lists now, above the
tabs, so the button knows how many there are. The position helpers the
paths kept to themselves are shared (core/sync/fractional_position.dart),
and so is the grip (ReorderGrip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 18:49:36 +02:00
commit 04cbf00e3c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 399 additions and 130 deletions

View file

@ -97,8 +97,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `liftWhileDragged` and `SliverCentered`, and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged` and `SliverCentered`, and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a grip in place of each row's options), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. - **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand.
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.

View file

@ -277,6 +277,7 @@
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}", "cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
"studysetReorder": "Umsortieren", "studysetReorder": "Umsortieren",
"studysetReorderPaths": "Pfade umsortieren", "studysetReorderPaths": "Pfade umsortieren",
"studysetReorderNotes": "Notizen umsortieren",
"studysetReorderDone": "Fertig", "studysetReorderDone": "Fertig",
"studysetOpenTopics": "Themen anzeigen", "studysetOpenTopics": "Themen anzeigen",
"studysetTopics": "Themen", "studysetTopics": "Themen",

View file

@ -307,6 +307,7 @@
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}}, "@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
"studysetReorder": "Reorder", "studysetReorder": "Reorder",
"studysetReorderPaths": "Reorder paths", "studysetReorderPaths": "Reorder paths",
"studysetReorderNotes": "Reorder notes",
"studysetReorderDone": "Done", "studysetReorderDone": "Done",
"studysetOpenTopics": "Show topics", "studysetOpenTopics": "Show topics",
"studysetTopics": "Topics", "studysetTopics": "Topics",

View file

@ -1720,6 +1720,12 @@ abstract class AppLocalizations {
/// **'Reorder paths'** /// **'Reorder paths'**
String get studysetReorderPaths; String get studysetReorderPaths;
/// No description provided for @studysetReorderNotes.
///
/// In en, this message translates to:
/// **'Reorder notes'**
String get studysetReorderNotes;
/// No description provided for @studysetReorderDone. /// No description provided for @studysetReorderDone.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1030,6 +1030,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetReorderPaths => 'Pfade umsortieren'; String get studysetReorderPaths => 'Pfade umsortieren';
@override
String get studysetReorderNotes => 'Notizen umsortieren';
@override @override
String get studysetReorderDone => 'Fertig'; String get studysetReorderDone => 'Fertig';

View file

@ -1023,6 +1023,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetReorderPaths => 'Reorder paths'; String get studysetReorderPaths => 'Reorder paths';
@override
String get studysetReorderNotes => 'Reorder notes';
@override @override
String get studysetReorderDone => 'Done'; String get studysetReorderDone => 'Done';

View file

@ -0,0 +1,30 @@
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
/// The greatest of [positions], after which something new goes at the end;
/// null for none.
String? lastPosition(Iterable<String> positions) {
String? last;
for (final position in positions) {
if (last == null || position.compareTo(last) > 0) {
last = position;
}
}
return last;
}
/// A position at [index] among [others], the positions of the other items
/// in order: between the one before and the one after. Two devices
/// appending at once can pick the same position; the new one then goes after
/// every item sharing the lower one.
String positionAmong(List<String> others, int index) {
final at = index.clamp(0, others.length);
final lower = at > 0 ? others[at - 1] : null;
String? upper;
for (final other in others.skip(at)) {
if (lower == null || other.compareTo(lower) > 0) {
upper = other;
break;
}
}
return FractionalIndexing.generateKeyBetween(lower, upper);
}

View file

@ -0,0 +1,22 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The handle a row is dragged by while its list is reordered, in place of
/// its options: the row at [index] of the [SliverReorderableList] around.
class const ReorderGrip({required final int index, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => ReorderableDragStartListener(
index: index,
child: IconButton(
tooltip: AppLocalizations.of(context).homeDragToReorder,
// The drag starts on the pointer going down; a tap does nothing.
onPressed: () {},
icon: Icon(
LucideIcons.gripHorizontal,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
@ -169,15 +170,9 @@ class CardRepository(final CardGateway _gateway) {
return ids; return ids;
} }
Future<String?> _lastPosition(String studysetId) async { Future<String?> _lastPosition(String studysetId) async => lastPosition([
String? last; for (final card in await _gateway.cardsOf(studysetId)) card.position,
for (final card in await _gateway.cardsOf(studysetId)) { ]);
if (last == null || card.position.compareTo(last) > 0) {
last = card.position;
}
}
return last;
}
/// Stores [content] as the next revision of [card], replacing all its /// Stores [content] as the next revision of [card], replacing all its
/// current heads, which resolves a conflict. Nothing unchanged is written. /// current heads, which resolves a conflict. Nothing unchanged is written.

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
@ -55,7 +56,7 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
final id = await _gateway.insertPath( final id = await _gateway.insertPath(
studysetId: studysetId, studysetId: studysetId,
position: FractionalIndexing.generateKeyBetween( position: FractionalIndexing.generateKeyBetween(
_last([ lastPosition([
for (final row in await _gateway.paths(studysetId: studysetId)) for (final row in await _gateway.paths(studysetId: studysetId))
row.position, row.position,
]), ]),
@ -86,7 +87,10 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
for (final path in await pathsOf(studysetId)) for (final path in await pathsOf(studysetId))
if (path.id != id) (id: path.id, position: path.position), if (path.id != id) (id: path.id, position: path.position),
]; ];
await _gateway.updatePathPosition(id, _between(others, toIndex)); await _gateway.updatePathPosition(
id,
positionAmong([for (final other in others) other.position], toIndex),
);
_changes.add(null); _changes.add(null);
} }
@ -147,7 +151,7 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
final ids = await _gateway.insertStep( final ids = await _gateway.insertStep(
pathId: pathId, pathId: pathId,
position: FractionalIndexing.generateKeyBetween( position: FractionalIndexing.generateKeyBetween(
_last([ lastPosition([
for (final row in await _gateway.steps({pathId})) row.position, for (final row in await _gateway.steps({pathId})) row.position,
]), ]),
null, null,
@ -187,7 +191,10 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
for (final row in await _gateway.steps({pathId})) for (final row in await _gateway.steps({pathId}))
if (row.id != id) (id: row.id, position: row.position), if (row.id != id) (id: row.id, position: row.position),
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id)); ]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
await _gateway.updateStepPosition(id, _between(others, toIndex)); await _gateway.updateStepPosition(
id,
positionAmong([for (final other in others) other.position], toIndex),
);
_changes.add(null); _changes.add(null);
} }
@ -272,33 +279,6 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
} }
} }
/// The greatest of [positions], or null for none.
String? _last(Iterable<String> positions) {
String? last;
for (final position in positions) {
if (last == null || position.compareTo(last) > 0) {
last = position;
}
}
return last;
}
/// A position at [index] among [others], in order. Two devices appending
/// at once can pick the same position; the key then goes after every item
/// sharing the lower one.
String _between(List<({String id, String position})> others, int index) {
final at = index.clamp(0, others.length);
final lower = at > 0 ? others[at - 1].position : null;
String? upper;
for (final other in others.skip(at)) {
if (lower == null || other.position.compareTo(lower) > 0) {
upper = other.position;
break;
}
}
return FractionalIndexing.generateKeyBetween(lower, upper);
}
/// By position; ties by id, which sorts by creation. /// By position; ties by id, which sorts by creation.
int _byPosition(String a, String aId, String b, String bId) => int _byPosition(String a, String aId, String b, String bId) =>
switch (a.compareTo(b)) { switch (a.compareTo(b)) {

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart'; import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart'; import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
@ -85,17 +86,7 @@ class const SliverPathList({
kicker: path.content.secondaryTitle, kicker: path.content.secondaryTitle,
titleMaxLines: 1, titleMaxLines: 1,
), ),
trailing: ReorderableDragStartListener( trailing: ReorderGrip(index: index),
index: index,
child: IconButton(
tooltip: l10n.homeDragToReorder,
onPressed: () {},
icon: Icon(
LucideIcons.gripHorizontal,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
); );
}, },
), ),

View file

@ -8,7 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
/// A studyset's notes in order, through every change, leaving out those /// A studyset's notes in order, through every change, leaving out those
/// whose deletion can still be undone. /// whose deletion can still be undone; moves them.
class NoteListBloc( class NoteListBloc(
final NoteRepository _notes, final NoteRepository _notes,
final NoteDeletions _deletions, final NoteDeletions _deletions,
@ -16,6 +16,10 @@ class NoteListBloc(
) extends Bloc<NoteListEvent, NoteListState> { ) extends Bloc<NoteListEvent, NoteListState> {
this : super(const NoteListState()) { this : super(const NoteListState()) {
on<NoteListEventStarted>(_onStarted); on<NoteListEventStarted>(_onStarted);
on<NoteListEventMoveRequested>(
(event, emit) =>
_notes.move(_studysetId, event.id, toIndex: event.toIndex),
);
} }
/// The notes as last read, and whether they were read at all. /// The notes as last read, and whether they were read at all.
@ -88,3 +92,15 @@ sealed class const NoteListEvent() with Equatable {
/// The list opens: follow the studyset's notes and the deletions. /// The list opens: follow the studyset's notes and the deletions.
final class const NoteListEventStarted() extends NoteListEvent; final class const NoteListEventStarted() extends NoteListEvent;
/// Move the note [id] to [toIndex].
final class const NoteListEventMoveRequested(
/// The note's id.
final String id,
/// Where it goes, counted in the list without it.
final int toIndex,
) extends NoteListEvent {
@override
List<Object?> get props => [id, toIndex];
}

View file

@ -43,6 +43,9 @@ abstract interface class NoteGateway() {
required ByteData payload, required ByteData payload,
}); });
/// Moves the note [noteId] to [position]; the only column that changes.
Future<void> updatePosition(String noteId, String position);
/// Blanks the payload of every revision of the note [noteId]. /// Blanks the payload of every revision of the note [noteId].
Future<void> blankRevisions(String noteId); Future<void> blankRevisions(String noteId);

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
@ -103,21 +104,34 @@ class NoteRepository(final NoteGateway _gateway) {
String studysetId, String studysetId,
NoteContent content, NoteContent content,
) async { ) async {
String? last;
for (final note in await _gateway.notesOf(studysetId)) {
if (last == null || note.position.compareTo(last) > 0) {
last = note.position;
}
}
final ids = await _gateway.insertNote( final ids = await _gateway.insertNote(
studysetId: studysetId, studysetId: studysetId,
position: FractionalIndexing.generateKeyBetween(last, null), position: FractionalIndexing.generateKeyBetween(
lastPosition([
for (final note in await _gateway.notesOf(studysetId)) note.position,
]),
null,
),
payload: encodeNotePayload(content.normalized), payload: encodeNotePayload(content.normalized),
); );
_changes.add(null); _changes.add(null);
return ids; return ids;
} }
/// Moves the note [noteId] to [toIndex] among the notes of [studysetId].
Future<void> move(
String studysetId,
String noteId, {
required int toIndex,
}) async {
final others = [
for (final note in await notesOf(studysetId))
if (note.id != noteId) note.position,
];
await _gateway.updatePosition(noteId, positionAmong(others, toIndex));
_changes.add(null);
}
/// Stores [content] as the next revision of the note [noteId], replacing /// Stores [content] as the next revision of the note [noteId], replacing
/// [basedOn]: the revisions the edit started from (all current heads to /// [basedOn]: the revisions the edit started from (all current heads to
/// settle a conflict). What another device wrote meanwhile stays a head of /// settle a conflict). What another device wrote meanwhile stays a head of

View file

@ -96,6 +96,24 @@ class SyncNoteGateway(
return revision.id!.uuid; return revision.id!.uuid;
} }
@override
Future<void> updatePosition(String noteId, String position) async {
final note = await protocol.Note.db.findById(
_db,
protocol.UuidValue.withValidation(noteId),
);
if (note == null) {
return;
}
// Only the named column changes (ADR 0001, rule 1).
await protocol.Note.db.updateRow(
_db,
note.copyWith(position: position),
columns: (t) => [t.position],
);
_sync.requestSync();
}
@override @override
Future<void> blankRevisions(String noteId) async { Future<void> blankRevisions(String noteId) async {
final revisions = await protocol.NoteRevision.db.find( final revisions = await protocol.NoteRevision.db.find(

View file

@ -8,6 +8,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
@ -17,72 +19,92 @@ import 'package:halkyon_learn_flutter/src/feature/note/widget/note_info.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The notes of the studyset [studysetId], in order: each with its title and /// The notes of the studyset [studysetId], in order, from the [NoteListBloc]
/// when it was last edited, opening on a tap. With none, it says so. /// above: each with its title and when it was last edited, opening on a
/// Slivers, for a scrolling view to hold: the notes are built as they scroll /// tap. With none, it says so. While [reordering], each has a handle to drag
/// into view. /// it elsewhere instead of its options. Slivers, for a scrolling view to
/// hold: the notes are built as they scroll into view.
class const SliverNoteList({ class const SliverNoteList({
required final String studysetId, required final String studysetId,
final bool reordering = false,
/// The space around the notes in the scrolling view. /// The space around the notes in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero, final EdgeInsets padding = EdgeInsets.zero,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocProvider( Widget build(BuildContext context) =>
create: (context) { BlocBuilder<NoteListBloc, NoteListState>(
final person = PersonScope.of(context); builder: (context, state) {
return NoteListBloc(person.notes, person.noteDeletions, studysetId) final l10n = AppLocalizations.of(context);
..add(const NoteListEventStarted()); final theme = Theme.of(context);
}, if (!state.loaded) {
child: BlocBuilder<NoteListBloc, NoteListState>( return SliverCentered(
builder: (context, state) { padding: padding,
final l10n = AppLocalizations.of(context); child: const AppLoadingIndicator(),
final theme = Theme.of(context); );
if (!state.loaded) { }
return SliverCentered( if (state.notes.isEmpty) {
padding: padding, return SliverCentered(
child: const AppLoadingIndicator(), padding: padding,
); child: Padding(
} padding: const EdgeInsets.all(24),
if (state.notes.isEmpty) { child: Column(
return SliverCentered( mainAxisSize: MainAxisSize.min,
padding: padding, children: [
child: Padding( Text(l10n.notesEmpty, style: theme.textTheme.titleMedium),
padding: const EdgeInsets.all(24), const SizedBox(height: 4),
child: Column( Text(
mainAxisSize: MainAxisSize.min, l10n.notesEmptyHint,
children: [ textAlign: TextAlign.center,
Text(l10n.notesEmpty, style: theme.textTheme.titleMedium), style: theme.textTheme.bodySmall?.copyWith(
const SizedBox(height: 4), color: theme.colorScheme.onSurfaceVariant,
Text( ),
l10n.notesEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
), ),
), ],
], ),
), ),
);
}
final notes = state.notes;
if (reordering) {
return SliverPadding(
padding: padding,
sliver: SliverReorderableList(
itemCount: notes.length,
onReorderItem: (from, to) => context.read<NoteListBloc>().add(
NoteListEventMoveRequested(notes[from].id, to),
),
proxyDecorator: liftWhileDragged,
itemBuilder: (context, index) {
final title = notes[index].content.title;
return ListTile(
key: ValueKey(notes[index].id),
title: Text(
title.isEmpty ? l10n.noteUntitled : title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: ReorderGrip(index: index),
);
},
),
);
}
return SliverPadding(
padding: padding,
sliver: SliverList.separated(
itemCount: notes.length,
itemBuilder: (context, index) => _NoteTile(
key: ValueKey(notes[index].id),
studysetId: studysetId,
note: notes[index],
),
separatorBuilder: (context, _) => const Divider(height: 1),
), ),
); );
} },
final notes = state.notes; );
return SliverPadding(
padding: padding,
sliver: SliverList.separated(
itemCount: notes.length,
itemBuilder: (context, index) => _NoteTile(
key: ValueKey(notes[index].id),
studysetId: studysetId,
note: notes[index],
),
separatorBuilder: (context, _) => const Divider(height: 1),
),
);
},
),
);
} }
/// One note: its title ("Untitled note" without one) and when it was last /// One note: its title ("Untitled note" without one) and when it was last

View file

@ -29,6 +29,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_b
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart'; import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
@ -94,6 +95,14 @@ class const StudysetScreen({
studyset.id, studyset.id,
)..add(const PathListEventStarted()), )..add(const PathListEventStarted()),
), ),
// Its notes, for their tab and Reorder beside the tabs.
BlocProvider(
create: (context) => NoteListBloc(
person.notes,
person.noteDeletions,
studyset.id,
)..add(const NoteListEventStarted()),
),
// Its PDFs, for the Files tab and for Add material. // Its PDFs, for the Files tab and for Add material.
BlocProvider( BlocProvider(
create: (context) => PdfListBloc( create: (context) => PdfListBloc(
@ -466,6 +475,9 @@ class _ContentState()
/// Whether the paths show handles to drag them. /// Whether the paths show handles to drag them.
var _reorderingPaths = false; var _reorderingPaths = false;
/// Whether the notes show handles to drag them.
var _reorderingNotes = false;
/// Each tab's scrolling, to go back to its top. /// Each tab's scrolling, to go back to its top.
final _scrolls = [ final _scrolls = [
for (final _ in StudysetTab.values) ScrollController(), for (final _ in StudysetTab.values) ScrollController(),
@ -596,6 +608,14 @@ class _ContentState()
} }
}, },
), ),
BlocListener<NoteListBloc, NoteListState>(
listener: (context, state) {
// Nothing left to reorder.
if (_reorderingNotes && state.notes.length < 2) {
setState(() => _reorderingNotes = false);
}
},
),
], ],
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -634,14 +654,33 @@ class _ContentState()
padding: EdgeInsets.only(right: 16), padding: EdgeInsets.only(right: 16),
child: CardListToolbar(), child: CardListToolbar(),
), ),
StudysetTab.paths => _ReorderPaths( StudysetTab.paths =>
reordering: _reorderingPaths, BlocSelector<PathListBloc, PathListState, int>(
// Below that, an icon alone. selector: (state) => state.paths.length,
labelled: constraints.maxWidth >= 480, builder: (context, count) => _ReorderBeside(
onToggle: () => setState( count: count,
() => _reorderingPaths = !_reorderingPaths, reordering: _reorderingPaths,
// Below that, an icon alone.
labelled: constraints.maxWidth >= 480,
tooltip: l10n.studysetReorderPaths,
onToggle: () => setState(
() => _reorderingPaths = !_reorderingPaths,
),
),
),
StudysetTab.notes =>
BlocSelector<NoteListBloc, NoteListState, int>(
selector: (state) => state.notes.length,
builder: (context, count) => _ReorderBeside(
count: count,
reordering: _reorderingNotes,
labelled: constraints.maxWidth >= 480,
tooltip: l10n.studysetReorderNotes,
onToggle: () => setState(
() => _reorderingNotes = !_reorderingNotes,
),
),
), ),
),
_ => const SizedBox.shrink(), _ => const SizedBox.shrink(),
}, },
), ),
@ -673,6 +712,7 @@ class _ContentState()
StudysetTab.notes, StudysetTab.notes,
SliverNoteList( SliverNoteList(
studysetId: widget.studysetId, studysetId: widget.studysetId,
reordering: _reorderingNotes,
padding: _listPadding, padding: _listPadding,
), ),
), ),
@ -692,19 +732,18 @@ class _ContentState()
} }
} }
/// Beside the tabs while the paths show, once there are two: Reorder, and /// Beside the tabs while a list that can be reordered shows, once it has
/// Done while reordering; [labelled] with its word, else an icon alone. /// [count] two or more: Reorder, and Done while [reordering]; [labelled]
class const _ReorderPaths({ /// with its word, else an icon alone whose [tooltip] says what it reorders.
class const _ReorderBeside({
required final int count,
required final bool reordering, required final bool reordering,
required final bool labelled, required final bool labelled,
required final String tooltip,
required final VoidCallback onToggle, required final VoidCallback onToggle,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final count = context.select<PathListBloc, int>(
(bloc) => bloc.state.paths.length,
);
if (count < 2 && !reordering) { if (count < 2 && !reordering) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -713,7 +752,7 @@ class const _ReorderPaths({
child: ReorderToggle( child: ReorderToggle(
reordering: reordering, reordering: reordering,
labelled: labelled, labelled: labelled,
tooltip: l10n.studysetReorderPaths, tooltip: tooltip,
onToggle: onToggle, onToggle: onToggle,
), ),
); );

View file

@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
void main() {
test('the last position is the greatest, or none', () {
expect(lastPosition(['a1', 'a3', 'a2']), 'a3');
expect(lastPosition(const []), isNull);
});
test('a position goes between its neighbours, at the start and at the '
'end', () {
const others = ['a1', 'a2'];
expect(positionAmong(others, 0).compareTo('a1'), lessThan(0));
final between = positionAmong(others, 1);
expect(between.compareTo('a1'), greaterThan(0));
expect(between.compareTo('a2'), lessThan(0));
expect(positionAmong(others, 2).compareTo('a2'), greaterThan(0));
// Past either end, it stays at that end.
expect(positionAmong(others, 9).compareTo('a2'), greaterThan(0));
});
test('after two items sharing a position, it goes after both', () {
const others = ['a1', 'a1', 'a2'];
final position = positionAmong(others, 1);
expect(position.compareTo('a1'), greaterThan(0));
expect(position.compareTo('a2'), lessThan(0));
});
}

View file

@ -57,6 +57,25 @@ void main() {
); );
}); });
test('moves a note', () async {
await note('Particles');
final verbs = await note('Verbs');
await bloc.stream.firstWhere((s) => s.notes.length == 2);
bloc.add(NoteListEventMoveRequested(verbs, 0));
final moved = await bloc.stream.firstWhere(
(s) => s.notes.first.id == verbs,
);
expect(
[for (final n in moved.notes) n.content.title],
[
'Verbs',
'Particles',
],
);
});
test('says when there are none', () async { test('says when there are none', () async {
expect(await titles(), isEmpty); expect(await titles(), isEmpty);
}); });

View file

@ -42,6 +42,27 @@ void main() {
expect(notes.last.content, note('Verbs', '食べる')); expect(notes.last.content, note('Verbs', '食べる'));
}); });
test(
'moves a note between two others, to the start and to the end',
() async {
final ids = [
for (final title in ['a', 'b', 'c'])
(await repository.create('japanese', note(title, ''))).noteId,
];
Future<Iterable<String>> order() async =>
titles(await repository.notesOf('japanese'));
await repository.move('japanese', ids[2], toIndex: 1);
expect(await order(), ['a', 'c', 'b']);
await repository.move('japanese', ids[1], toIndex: 0);
expect(await order(), ['b', 'a', 'c']);
await repository.move('japanese', ids[1], toIndex: 2);
expect(await order(), ['a', 'c', 'b']);
},
);
test('says when a note was last edited, here or elsewhere', () async { test('says when a note was last edited, here or elsewhere', () async {
final created = await withClock( final created = await withClock(
Clock.fixed(DateTime.utc(2026, 10, 2, 9)), Clock.fixed(DateTime.utc(2026, 10, 2, 9)),

Some files were not shown because too many files have changed in this diff Show more