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:
parent
04cbf00e3c
commit
7975586994
36 changed files with 21780 additions and 44 deletions
|
|
@ -143,7 +143,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
||||
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
|
||||
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "alterTable",
|
||||
"alterTable": {
|
||||
"__className__": "serverpod.TableMigration",
|
||||
"name": "pdf_source",
|
||||
"schema": "public",
|
||||
"addColumns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "position",
|
||||
"fieldName": "position",
|
||||
"columnType": 0,
|
||||
"isNullable": false,
|
||||
"columnDefault": "'a0'",
|
||||
"dartType": "String"
|
||||
}
|
||||
],
|
||||
"deleteColumns": [],
|
||||
"modifyColumns": [],
|
||||
"addIndexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "pdf_source_studyset_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "studysetId"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "position"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"deleteIndexes": [
|
||||
"pdf_source_studyset_idx"
|
||||
],
|
||||
"addForeignKeys": [],
|
||||
"deleteForeignKeys": [],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ part '20261002191122122-notes/migration.dart';
|
|||
part '20261002211346701-learning-paths/migration.dart';
|
||||
part '20261003110855698-pdf-sources/migration.dart';
|
||||
part '20261007205440868-card-buried-at/migration.dart';
|
||||
part '20261008165051407-pdf-position/migration.dart';
|
||||
|
||||
/// Migration registry for the client-side database.
|
||||
class MigrationRegistry {
|
||||
|
|
@ -26,6 +27,7 @@ class MigrationRegistry {
|
|||
_Migration20261002211346701_learning_paths(),
|
||||
_Migration20261003110855698_pdf_sources(),
|
||||
_Migration20261007205440868_card_buried_at(),
|
||||
_Migration20261008165051407_pdf_position(),
|
||||
];
|
||||
|
||||
/// List of all client-side database migration versions.
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ abstract class Card
|
|||
/// The studyset the card belongs to; the card goes with it.
|
||||
_ie5k1jc2.Studyset? studyset;
|
||||
|
||||
/// Where the card stands in its studyset: a fractional index, so a card can
|
||||
/// move between two others without renumbering the rest; last writer wins
|
||||
/// (decided 2026-09-30).
|
||||
/// Where the card stands in its studyset: a fractional index (decided
|
||||
/// 2026-09-30), set when the card is created or imported, the order new cards
|
||||
/// and cramming follow. Cards aren't reordered by hand (decided 2026-10-08).
|
||||
String position;
|
||||
|
||||
/// When the card was buried (set aside), in UTC; null while it isn't. It
|
||||
|
|
@ -257,9 +257,9 @@ class CardTable extends _isd.Table<_isc.UuidValue?> {
|
|||
/// The studyset the card belongs to; the card goes with it.
|
||||
_ie5k1jc2.StudysetTable? _studyset;
|
||||
|
||||
/// Where the card stands in its studyset: a fractional index, so a card can
|
||||
/// move between two others without renumbering the rest; last writer wins
|
||||
/// (decided 2026-09-30).
|
||||
/// Where the card stands in its studyset: a fractional index (decided
|
||||
/// 2026-09-30), set when the card is created or imported, the order new cards
|
||||
/// and cramming follow. Cards aren't reordered by hand (decided 2026-10-08).
|
||||
late final _isd.ColumnString position;
|
||||
|
||||
/// When the card was buried (set aside), in UTC; null while it isn't. It
|
||||
|
|
|
|||
|
|
@ -31,16 +31,18 @@ abstract class PdfSource
|
|||
this.spaceId,
|
||||
required this.studysetId,
|
||||
this.studyset,
|
||||
String? position,
|
||||
this.keyId,
|
||||
required this.payload,
|
||||
this.nonce,
|
||||
});
|
||||
}) : position = position ?? 'a0';
|
||||
|
||||
factory PdfSource({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
required _isc.UuidValue studysetId,
|
||||
_ie5k1jc2.Studyset? studyset,
|
||||
String? position,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
|
|
@ -60,6 +62,7 @@ abstract class PdfSource
|
|||
: _itx5j3f5.Protocol().deserialize<_ie5k1jc2.Studyset>(
|
||||
jsonSerialization['studyset'],
|
||||
),
|
||||
position: jsonSerialization['position'] as String?,
|
||||
keyId: jsonSerialization['keyId'] as String?,
|
||||
payload: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['payload'],
|
||||
|
|
@ -85,6 +88,13 @@ abstract class PdfSource
|
|||
/// The studyset the PDF belongs to; the PDF goes with it.
|
||||
_ie5k1jc2.Studyset? studyset;
|
||||
|
||||
/// Where the PDF stands among its studyset's files, as the person put them
|
||||
/// with Reorder: a fractional index, like a note's; last writer wins (decided
|
||||
/// 2026-10-08). A column of its own, so that moving it on one device and
|
||||
/// renaming it on another don't overwrite each other. PDFs from before this
|
||||
/// column all start at 'a0'; ties sort by id, the order they were added in.
|
||||
String position;
|
||||
|
||||
/// The encryption key's id; always null (ADR 0002, rule 6).
|
||||
String? keyId;
|
||||
|
||||
|
|
@ -107,6 +117,7 @@ abstract class PdfSource
|
|||
int? spaceId,
|
||||
_isc.UuidValue? studysetId,
|
||||
_ie5k1jc2.Studyset? studyset,
|
||||
String? position,
|
||||
String? keyId,
|
||||
_idt.ByteData? payload,
|
||||
_idt.ByteData? nonce,
|
||||
|
|
@ -119,6 +130,7 @@ abstract class PdfSource
|
|||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'studysetId': studysetId.toJson(),
|
||||
if (studyset != null) 'studyset': studyset?.toJson(),
|
||||
'position': position,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
|
|
@ -133,6 +145,7 @@ abstract class PdfSource
|
|||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'studysetId': studysetId.toJson(),
|
||||
if (studyset != null) 'studyset': studyset?.toJsonForProtocol(),
|
||||
'position': position,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
|
|
@ -175,6 +188,7 @@ class _PdfSourceImpl extends PdfSource {
|
|||
int? spaceId,
|
||||
required _isc.UuidValue studysetId,
|
||||
_ie5k1jc2.Studyset? studyset,
|
||||
String? position,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
|
|
@ -183,6 +197,7 @@ class _PdfSourceImpl extends PdfSource {
|
|||
spaceId: spaceId,
|
||||
studysetId: studysetId,
|
||||
studyset: studyset,
|
||||
position: position,
|
||||
keyId: keyId,
|
||||
payload: payload,
|
||||
nonce: nonce,
|
||||
|
|
@ -197,6 +212,7 @@ class _PdfSourceImpl extends PdfSource {
|
|||
Object? spaceId = _Undefined,
|
||||
_isc.UuidValue? studysetId,
|
||||
Object? studyset = _Undefined,
|
||||
String? position,
|
||||
Object? keyId = _Undefined,
|
||||
_idt.ByteData? payload,
|
||||
Object? nonce = _Undefined,
|
||||
|
|
@ -208,6 +224,7 @@ class _PdfSourceImpl extends PdfSource {
|
|||
studyset: studyset is _ie5k1jc2.Studyset?
|
||||
? studyset
|
||||
: this.studyset?.copyWith(),
|
||||
position: position ?? this.position,
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
payload: payload ?? this.payload.clone(),
|
||||
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
|
||||
|
|
@ -230,6 +247,11 @@ class PdfSourceUpdateTable extends _isd.UpdateTable<PdfSourceTable> {
|
|||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> position(String value) => _isd.ColumnValue(
|
||||
table.position,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
|
||||
table.keyId,
|
||||
value,
|
||||
|
|
@ -259,6 +281,11 @@ class PdfSourceTable extends _isd.Table<_isc.UuidValue?> {
|
|||
'studysetId',
|
||||
this,
|
||||
);
|
||||
position = _isd.ColumnString(
|
||||
'position',
|
||||
this,
|
||||
hasDefault: true,
|
||||
);
|
||||
keyId = _isd.ColumnString(
|
||||
'keyId',
|
||||
this,
|
||||
|
|
@ -283,6 +310,13 @@ class PdfSourceTable extends _isd.Table<_isc.UuidValue?> {
|
|||
/// The studyset the PDF belongs to; the PDF goes with it.
|
||||
_ie5k1jc2.StudysetTable? _studyset;
|
||||
|
||||
/// Where the PDF stands among its studyset's files, as the person put them
|
||||
/// with Reorder: a fractional index, like a note's; last writer wins (decided
|
||||
/// 2026-10-08). A column of its own, so that moving it on one device and
|
||||
/// renaming it on another don't overwrite each other. PDFs from before this
|
||||
/// column all start at 'a0'; ties sort by id, the order they were added in.
|
||||
late final _isd.ColumnString position;
|
||||
|
||||
/// The encryption key's id; always null (ADR 0002, rule 6).
|
||||
late final _isd.ColumnString keyId;
|
||||
|
||||
|
|
@ -312,6 +346,7 @@ class PdfSourceTable extends _isd.Table<_isc.UuidValue?> {
|
|||
id,
|
||||
spaceId,
|
||||
studysetId,
|
||||
position,
|
||||
keyId,
|
||||
payload,
|
||||
nonce,
|
||||
|
|
|
|||
|
|
@ -1471,6 +1471,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
isNullable: false,
|
||||
dartType: 'UuidValue',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'position',
|
||||
columnType: _isd.ColumnType.text,
|
||||
isNullable: false,
|
||||
dartType: 'String',
|
||||
columnDefault: '\'a0\'',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'keyId',
|
||||
columnType: _isd.ColumnType.text,
|
||||
|
|
@ -1535,6 +1542,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'studysetId',
|
||||
),
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'position',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue