feat: files in an order of one's own, with Reorder beside the tabs

A PDF now has a position among its studyset's files, as notes and paths
have: a fractional index in a column of its own (so moving it on one
device and renaming it on another don't overwrite each other), last
writer wins. The migration adds it to pdf_source on the server and on
each device with 'a0', so PDFs from before stand in the order they were
added (ties sort by id), and new ones go at the end.

The Files tab lists them in that order by default (Custom order, first
in the Sort menu; newest first was the default before). With two files
or more, Reorder beside the tabs shows the custom order whatever they
were sorted by, a grip in place of each file's options and the Sort menu
put away, until Done.

The next release changes synced tables anyway (card.buriedAt, since
v0.2.2), so older apps have to update for that already.

Also: card.position's doc no longer speaks of moving cards.

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 19:28:05 +02:00
commit 7975586994
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 21780 additions and 44 deletions

View file

@ -278,6 +278,7 @@
"studysetReorder": "Umsortieren",
"studysetReorderPaths": "Pfade umsortieren",
"studysetReorderNotes": "Notizen umsortieren",
"studysetReorderFiles": "Dateien umsortieren",
"studysetReorderDone": "Fertig",
"studysetOpenTopics": "Themen anzeigen",
"studysetTopics": "Themen",
@ -837,6 +838,7 @@
"filesEmpty": "Noch keine Dateien",
"filesEmptyHint": "Nutze „Material hinzufügen → PDF hochladen“, um eines hinzuzufügen.",
"filesSort": "Sortieren",
"filesSortCustom": "Eigene Reihenfolge",
"filesSortName": "Name (A–Z)",
"filesSortNewest": "Neueste zuerst",
"filesSortOldest": "Älteste zuerst",

View file

@ -308,6 +308,7 @@
"studysetReorder": "Reorder",
"studysetReorderPaths": "Reorder paths",
"studysetReorderNotes": "Reorder notes",
"studysetReorderFiles": "Reorder files",
"studysetReorderDone": "Done",
"studysetOpenTopics": "Show topics",
"studysetTopics": "Topics",
@ -952,6 +953,7 @@
"filesEmpty": "No files yet",
"filesEmptyHint": "Use \"Add material → Upload a PDF\" to add one.",
"filesSort": "Sort",
"filesSortCustom": "Custom order",
"filesSortName": "Name (A–Z)",
"filesSortNewest": "Newest first",
"filesSortOldest": "Oldest first",

View file

@ -1726,6 +1726,12 @@ abstract class AppLocalizations {
/// **'Reorder notes'**
String get studysetReorderNotes;
/// No description provided for @studysetReorderFiles.
///
/// In en, this message translates to:
/// **'Reorder files'**
String get studysetReorderFiles;
/// No description provided for @studysetReorderDone.
///
/// In en, this message translates to:
@ -5014,6 +5020,12 @@ abstract class AppLocalizations {
/// **'Sort'**
String get filesSort;
/// No description provided for @filesSortCustom.
///
/// In en, this message translates to:
/// **'Custom order'**
String get filesSortCustom;
/// No description provided for @filesSortName.
///
/// In en, this message translates to:

View file

@ -1033,6 +1033,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetReorderNotes => 'Notizen umsortieren';
@override
String get studysetReorderFiles => 'Dateien umsortieren';
@override
String get studysetReorderDone => 'Fertig';
@ -3009,6 +3012,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get filesSort => 'Sortieren';
@override
String get filesSortCustom => 'Eigene Reihenfolge';
@override
String get filesSortName => 'Name (A–Z)';

View file

@ -1026,6 +1026,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetReorderNotes => 'Reorder notes';
@override
String get studysetReorderFiles => 'Reorder files';
@override
String get studysetReorderDone => 'Done';
@ -2983,6 +2986,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get filesSort => 'Sort';
@override
String get filesSortCustom => 'Custom order';
@override
String get filesSortName => 'Name (A–Z)';

View file

@ -15,10 +15,14 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
/// The order the Files tab lists PDFs in.
enum PdfSort() {
/// In the person's own order, as Reorder put them (as added, until then);
/// the default, e.g. a reading order (decided 2026-10-08).
custom,
/// By title, A to Z.
name,
/// The most recently added first; the default.
/// The most recently added first.
newest,
/// The first added first.
@ -42,8 +46,8 @@ typedef PdfRow = ({
typedef PdfImportOutcome = ({PdfImport result, String title});
/// A studyset's PDFs through every change, in the order chosen, leaving out
/// those whose deletion can still be undone; and importing, renaming,
/// deadlines and reading marks for them.
/// those whose deletion can still be undone; and importing, moving,
/// renaming, deadlines and reading marks for them.
class PdfListBloc(
final PdfSourceRepository _pdfs,
final PdfDeletions _deletions,
@ -65,6 +69,10 @@ class PdfListBloc(
await _import(emit, bytes, fileName, true);
}
});
on<PdfListEventMoveRequested>(
(event, emit) =>
_pdfs.move(_studysetId, event.id, toIndex: event.toIndex),
);
on<PdfListEventRenamed>(
(event, emit) => _pdfs.rename(event.pdfId, event.title),
);
@ -204,8 +212,10 @@ class PdfListBloc(
furthestPage: _furthest[source.id],
),
];
// The repository lists them as added, the oldest first.
// The repository lists them in the person's order.
switch (state.sort) {
case PdfSort.custom:
break;
case PdfSort.name:
rows.sort(
(a, b) => a.source.content.title.toLowerCase().compareTo(
@ -215,7 +225,7 @@ class PdfListBloc(
case PdfSort.newest:
rows.sort((a, b) => b.source.id.compareTo(a.source.id));
case PdfSort.oldest:
break;
rows.sort((a, b) => a.source.id.compareTo(b.source.id));
case PdfSort.largest:
rows.sort((a, b) => (b.byteSize ?? 0).compareTo(a.byteSize ?? 0));
}
@ -242,7 +252,7 @@ final class const PdfListState({
final List<PdfRow> rows = const [],
/// The order chosen.
final PdfSort sort = PdfSort.newest,
final PdfSort sort = PdfSort.custom,
/// Whether a file is being imported.
final bool importing = false,
@ -297,6 +307,18 @@ final class const PdfListEventSorted(final PdfSort sort) extends PdfListEvent {
List<Object?> get props => [sort];
}
/// Move the PDF [id] to [toIndex] in the person's order.
final class const PdfListEventMoveRequested(
/// The PDF's id.
final String id,
/// Where it goes, counted in the list without it.
final int toIndex,
) extends PdfListEvent {
@override
List<Object?> get props => [id, toIndex];
}
/// The person picked [bytes], a file named [fileName], to add.
final class const PdfListEventImportRequested({
required final Uint8List bytes,

View file

@ -1,7 +1,12 @@
import 'dart:typed_data';
/// A PDF's row as the device's database stores it.
typedef PdfSourceRow = ({String id, String studysetId, ByteData payload});
typedef PdfSourceRow = ({
String id,
String studysetId,
String position,
ByteData payload,
});
/// An entry of a PDF's reading log as the device's database stores it.
typedef PdfReadingRow = ({
@ -24,9 +29,13 @@ abstract interface class PdfSourceGateway() {
/// Stores a new PDF; returns its id.
Future<String> insert({
required String studysetId,
required String position,
required ByteData payload,
});
/// Moves the PDF [id] to [position]; the only column that changes.
Future<void> updatePosition(String id, String position);
/// Replaces the PDF [id]'s payload; the only column that changes.
Future<void> updatePayload(String id, ByteData payload);

View file

@ -4,9 +4,11 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
@ -81,12 +83,18 @@ class PdfSourceRepository(
'reading',
);
/// The PDFs of [studysetId] on this device right now, in the order they
/// were added; PDFs this app can't read are left out.
Future<List<PdfSource>> sourcesOf(String studysetId) async => [
for (final row in await _gateway.sources(studysetId: studysetId))
?_decode(row),
]..sort((a, b) => a.id.compareTo(b.id));
/// The PDFs of [studysetId] on this device right now, in their order (as
/// added, until reordered); PDFs this app can't read are left out.
Future<List<PdfSource>> sourcesOf(String studysetId) async =>
[
for (final row in await _gateway.sources(studysetId: studysetId))
?_decode(row),
]..sort(
(a, b) => switch (a.position.compareTo(b.position)) {
0 => a.id.compareTo(b.id),
final order => order,
},
);
/// Every PDF of the person, in the order they were added.
Future<List<PdfSource>> all() async => [
@ -140,6 +148,15 @@ class PdfSourceRepository(
}
final id = await _gateway.insert(
studysetId: studysetId,
// At the end of the studyset's files, even of those this app can't
// read.
position: FractionalIndexing.generateKeyBetween(
lastPosition([
for (final row in await _gateway.sources(studysetId: studysetId))
row.position,
]),
null,
),
payload: encodePdfSourcePayload(
PdfSourceContent(
title: titleFromFileName(fileName),
@ -152,6 +169,20 @@ class PdfSourceRepository(
return PdfImported(id);
}
/// Moves the PDF [id] to [toIndex] among the PDFs of [studysetId].
Future<void> move(
String studysetId,
String id, {
required int toIndex,
}) async {
final others = [
for (final source in await sourcesOf(studysetId))
if (source.id != id) source.position,
];
await _gateway.updatePosition(id, positionAmong(others, toIndex));
_changes.add(null);
}
/// Titles the PDF [id] [title], without surrounding spaces.
Future<void> rename(String id, String title) =>
_update(id, (content) => content.withTitle(title.trim()));
@ -284,6 +315,7 @@ class PdfSourceRepository(
return PdfSource(
id: row.id,
studysetId: row.studysetId,
position: row.position,
content: decodePdfSourcePayload(row.payload),
);
} on FormatException catch (error) {

View file

@ -41,11 +41,16 @@ class SyncPdfSourceGateway(
@override
Future<String> insert({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final source = await protocol.PdfSource.db.insertRow(
_db,
protocol.PdfSource(studysetId: _uuid(studysetId), payload: payload),
protocol.PdfSource(
studysetId: _uuid(studysetId),
position: position,
payload: payload,
),
);
_sync.requestSync();
return source.id!.uuid;
@ -65,6 +70,20 @@ class SyncPdfSourceGateway(
}
}
@override
Future<void> updatePosition(String id, String position) async {
if (await protocol.PdfSource.db.findById(_db, _uuid(id))
case final source?) {
// Only the named column changes (ADR 0001, rule 1).
await protocol.PdfSource.db.updateRow(
_db,
source.copyWith(position: position),
columns: (t) => [t.position],
);
_sync.requestSync();
}
}
@override
Future<void> delete(String id) async {
// Its reading goes too: no content, but personal data that would
@ -127,6 +146,7 @@ class SyncPdfSourceGateway(
PdfSourceRow _row(protocol.PdfSource source) => (
id: source.id!.uuid,
studysetId: source.studysetId.uuid,
position: source.position,
payload: source.payload,
);

View file

@ -90,9 +90,14 @@ final class const PdfSource({
/// What it says about itself.
required final PdfSourceContent content,
/// Where it stands among its studyset's files: a fractional index. Files
/// from before there was one all stand at `a0`, in the order they were
/// added.
final String position = 'a0',
}) with Equatable {
@override
List<Object?> get props => [id, studysetId, content];
List<Object?> get props => [id, studysetId, content, position];
}
/// The title a PDF named [fileName] gets: the name without `.pdf`.

View file

@ -12,7 +12,9 @@ import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.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/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.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/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
@ -26,10 +28,13 @@ import 'package:material_ui/material_ui.dart';
/// The studyset's files, the PDFs for now, as [PdfListBloc] above lists
/// them: in the order chosen from the Sort menu, each with what it holds,
/// how far it is read and its reading deadline, opening on a tap. With none,
/// it says how to add one. Slivers, for a scrolling view to hold: the files
/// are built as they scroll into view.
/// it says how to add one. While [reordering] in the person's own order,
/// each has a handle to drag it elsewhere instead of its options, and the
/// Sort menu waits. Slivers, for a scrolling view to hold: the files are
/// built as they scroll into view.
class const SliverPdfList({
required final String studysetId,
final bool reordering = false,
/// The space around the files in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero,
@ -75,6 +80,31 @@ class const SliverPdfList({
);
}
final rows = state.rows;
if (reordering && state.sort == PdfSort.custom) {
return SliverPadding(
padding: padding,
sliver: SliverReorderableList(
itemCount: rows.length,
onReorderItem: (from, to) => context.read<PdfListBloc>().add(
PdfListEventMoveRequested(rows[from].source.id, to),
),
proxyDecorator: liftWhileDragged,
itemBuilder: (context, index) => ListTile(
key: ValueKey(rows[index].source.id),
leading: Icon(
LucideIcons.fileText,
color: theme.colorScheme.primary,
),
title: Text(
rows[index].source.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: ReorderGrip(index: index),
),
),
);
}
return SliverPadding(
padding: padding,
sliver: SliverMainAxisGroup(
@ -115,6 +145,7 @@ class const _SortMenu({required final PdfSort sort}) extends StatelessWidget {
onPressed: () =>
context.read<PdfListBloc>().add(PdfListEventSorted(option)),
child: Text(switch (option) {
PdfSort.custom => l10n.filesSortCustom,
PdfSort.name => l10n.filesSortName,
PdfSort.newest => l10n.filesSortNewest,
PdfSort.oldest => l10n.filesSortOldest,

View file

@ -478,6 +478,9 @@ class _ContentState()
/// Whether the notes show handles to drag them.
var _reorderingNotes = false;
/// Whether the files show handles to drag them, in their own order.
var _reorderingFiles = false;
/// Each tab's scrolling, to go back to its top.
final _scrolls = [
for (final _ in StudysetTab.values) ScrollController(),
@ -616,6 +619,13 @@ class _ContentState()
}
},
),
BlocListener<PdfListBloc, PdfListState>(
listener: (context, state) {
if (_reorderingFiles && state.rows.length < 2) {
setState(() => _reorderingFiles = false);
}
},
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -681,7 +691,28 @@ class _ContentState()
),
),
),
_ => const SizedBox.shrink(),
StudysetTab.files =>
BlocSelector<PdfListBloc, PdfListState, int>(
selector: (state) => state.rows.length,
builder: (context, count) => _ReorderBeside(
count: count,
reordering: _reorderingFiles,
labelled: constraints.maxWidth >= 480,
tooltip: l10n.studysetReorderFiles,
onToggle: () {
if (!_reorderingFiles) {
// What is dragged is the person's own
// order, whatever the files were sorted by.
context.read<PdfListBloc>().add(
const PdfListEventSorted(PdfSort.custom),
);
}
setState(
() => _reorderingFiles = !_reorderingFiles,
);
},
),
),
},
),
],
@ -720,6 +751,7 @@ class _ContentState()
StudysetTab.files,
SliverPdfList(
studysetId: widget.studysetId,
reordering: _reorderingFiles,
padding: _listPadding,
),
),

View file

@ -102,15 +102,15 @@ void main() {
),
);
// The newest first.
// In the person's own order by default: as added, until reordered.
expect(
[for (final row in state.rows) row.source.content.title],
[
'Kana',
'Grammar',
'Kana',
],
);
final row = state.rows.last;
final row = state.rows.first;
expect(row.cards, 1);
expect(row.notes, 1);
expect(row.furthestPage, 7);
@ -138,6 +138,29 @@ void main() {
bloc.add(const PdfListEventSorted(PdfSort.largest));
await until((s) => s.sort == PdfSort.largest);
expect(titles().first, 'a');
bloc.add(const PdfListEventSorted(PdfSort.newest));
await until((s) => s.sort == PdfSort.newest);
expect(titles(), ['c', 'a', 'b']);
});
test("moves a PDF in the person's own order, which the other orders "
'leave be', () async {
await add('b', 'b');
await add('a', 'a');
final c = await add('c', 'c');
start();
await until((s) => s.rows.length == 3);
List<String> titles() => [
for (final row in bloc.state.rows) row.source.content.title,
];
bloc.add(PdfListEventMoveRequested(c, 0));
await until((s) => s.rows.first.source.id == c);
expect(titles(), ['c', 'b', 'a']);
bloc.add(const PdfListEventSorted(PdfSort.oldest));
await until((s) => s.sort == PdfSort.oldest);
expect(titles(), ['b', 'a', 'c']);
});
test('leaves out a PDF whose deletion can still be undone', () async {

View file

@ -131,6 +131,41 @@ void main() {
expect((await pdfs.byId(id))!.content.deadline, isNull);
});
test('lists PDFs in their order: as added, PDFs from before there was one '
'too, until moved', () async {
// From before positions: all at a0, in the order they were added.
gateway
..receiveRemote(
id: 'pdf-0901',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(title: 'old 1', fileName: '', assetId: ''),
),
)
..receiveRemote(
id: 'pdf-0902',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(title: 'old 2', fileName: '', assetId: ''),
),
);
final added = await pdfs.import(
'japanese',
bytes: pdf('new'),
fileName: 'new.pdf',
);
Future<List<String>> titles() async => [
for (final source in await pdfs.sourcesOf('japanese'))
source.content.title,
];
expect(await titles(), ['old 1', 'old 2', 'new']);
await pdfs.move('japanese', (added as PdfImported).id, toIndex: 0);
expect(await titles(), ['new', 'old 1', 'old 2']);
await pdfs.move('japanese', 'pdf-0901', toIndex: 2);
expect(await titles(), ['new', 'old 2', 'old 1']);
});
test("leaves out a PDF it can't read, and counts the others by "
'studyset', () async {
await pdfs.import('japanese', bytes: pdf('a'), fileName: 'a.pdf');

View file

@ -176,7 +176,9 @@ void main() {
}
await showApp(tester);
double top(String title) => tester.getTopLeft(find.text(title)).dy;
expect(top('a'), lessThan(top('c')));
// In the person's own order by default: as added, until reordered.
expect(top('b'), lessThan(top('c')));
expect(top('c'), lessThan(top('a')));
await tester.tap(find.byTooltip('Sort'));
await tester.pumpAndSettle();
@ -186,6 +188,52 @@ void main() {
expect(top('b'), lessThan(top('c')));
});
testWidgets('Reorder beside the tabs shows the custom order, whatever the '
'files were sorted by, and drags a file elsewhere in it, until '
'Done', (tester) async {
final pdfs = repository();
for (final name in ['b', 'a']) {
await pdfs.import('japanese', bytes: pdf(name), fileName: '$name.pdf');
}
await showApp(tester);
await tester.tap(find.byTooltip('Sort'));
await tester.pumpAndSettle();
await choose(tester, 'Name (A–Z)');
double top(String title) => tester.getTopLeft(find.text(title)).dy;
expect(top('a'), lessThan(top('b')));
await tester.tap(find.text('Reorder'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
expect(find.byTooltip('Sort'), findsNothing);
expect(top('b'), lessThan(top('a')));
// The first file, dragged below the second.
final row = tester.getSize(find.widgetWithText(ListTile, 'b'));
final drag = await tester.startGesture(
tester.getCenter(find.byTooltip('Drag to reorder').first),
);
for (var step = 0; step < 6; step++) {
await drag.moveBy(Offset(0, row.height * 1.5 / 6));
await tester.pump(const Duration(milliseconds: 50));
}
await drag.up();
await tester.pumpAndSettle();
expect(
[
for (final source in await pdfs.sourcesOf('japanese'))
source.content.title,
],
['a', 'b'],
);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNothing);
expect(find.byTooltip('Sort'), findsOneWidget);
expect(top('a'), lessThan(top('b')));
});
testWidgets('renames, and deletes with a moment to undo', (tester) async {
await repository().import(
'japanese',

View file

@ -24,8 +24,14 @@ class FakePdfSourceGateway() implements PdfSourceGateway {
required String id,
required String studysetId,
required ByteData payload,
String position = 'a0',
}) {
_sources[id] = (id: id, studysetId: studysetId, payload: payload);
_sources[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
_remoteChanges.add(null);
}
@ -41,13 +47,31 @@ class FakePdfSourceGateway() implements PdfSourceGateway {
@override
Future<String> insert({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final id = _newId('pdf');
_sources[id] = (id: id, studysetId: studysetId, payload: payload);
_sources[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
return id;
}
@override
Future<void> updatePosition(String id, String position) async {
if (_sources[id] case final source?) {
_sources[id] = (
id: id,
studysetId: source.studysetId,
position: position,
payload: source.payload,
);
}
}
@override
Future<void> updatePayload(String id, ByteData payload) async {
if (_sources[id] case final source?) {
@ -57,6 +81,7 @@ class FakePdfSourceGateway() implements PdfSourceGateway {
_sources[id] = (
id: id,
studysetId: source.studysetId,
position: source.position,
payload: payload,
);
}