feat(app): the library's options move, import into and set how a studyset is studied
A studyset's options in the library now offer, as on its own screen, Edit, Move to…, Study options, Import from Anki, Archive and Delete. Moving goes through the same sheet and says where the studyset went; importing reads an Anki deck into it as Add material does. Study options say how a studyset is studied, on every device: how many new cards it starts a day (by default as the person's study settings say), how many reviews it brings a day (no limit by default; cards in their learning steps come anyway), and whether reviews or new cards come first, or the new ones spread evenly among the reviews. They live in the studyset's payload, schema 3; a studyset without them is still written in schema 2, so that older apps, which skip a payload in a newer schema whole, keep seeing every studyset nobody gave options. Editing a studyset keeps its options, and Reset to defaults takes them away. The study queue takes each studyset's limits for its own cards and the order of the studyset studied; a card's schedule now knows when it was last reviewed, to count the reviews of the day. Sessions, the round's end and the library's badges all count the same way, the badges now with the person's own number of new cards a day too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3ee9bce0c5
commit
d42afb078a
30 changed files with 1095 additions and 137 deletions
|
|
@ -135,10 +135,10 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
|
||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). Where content doesn't use the newest field, a codec may write the older schema, which older apps still read (the studyset's study options: schema 3 only for a studyset that has them). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -428,7 +428,16 @@
|
|||
"studyFilteredBy": "{count, plural, =1{Eingegrenzt durch 1 Tag-Filter} other{Eingegrenzt durch {count} Tag-Filter}}",
|
||||
"studySettingsCardsPerRound": "Karten pro Runde",
|
||||
"studySettingsNewPerDay": "Neue Karten pro Tag",
|
||||
"studySettingsNewPerDayHint": "Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt. Fällige Wiederholungen werden nie begrenzt.",
|
||||
"studySettingsNewPerDayHint": "Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt, sofern seine eigenen Lernoptionen nichts anderes sagen.",
|
||||
"studyOptionsTitle": "Lernoptionen",
|
||||
"studyOptionsDescription": "Wie viele Karten dieses Lernset pro Tag bringt und welche zuerst kommen, auf all deinen Geräten. Die Lernsets darin behalten ihre eigenen.",
|
||||
"studyOptionsReviewsPerDay": "Wiederholungen pro Tag",
|
||||
"studyOptionsReviewsPerDayHint": "Karten in ihren Lernschritten kommen trotzdem.",
|
||||
"studyOptionsOrder": "Reihenfolge",
|
||||
"studyOptionsReviewsFirst": "Wiederholungen zuerst",
|
||||
"studyOptionsMixed": "Gemischt",
|
||||
"studyOptionsNewFirst": "Neue zuerst",
|
||||
"studyOptionsReset": "Standard wiederherstellen",
|
||||
"studySettingsUnlimited": "Unbegrenzt",
|
||||
"studySettingsSortBy": "Sortieren nach",
|
||||
"studySettingsLearnMode": "Lernmodus",
|
||||
|
|
|
|||
|
|
@ -478,7 +478,16 @@
|
|||
"@studyFilteredBy": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studySettingsCardsPerRound": "Cards per round",
|
||||
"studySettingsNewPerDay": "New cards per day",
|
||||
"studySettingsNewPerDayHint": "How many never-studied cards each studyset starts a day. Due reviews are never limited.",
|
||||
"studySettingsNewPerDayHint": "How many never-studied cards each studyset starts a day, unless its own study options say otherwise.",
|
||||
"studyOptionsTitle": "Study options",
|
||||
"studyOptionsDescription": "How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.",
|
||||
"studyOptionsReviewsPerDay": "Reviews per day",
|
||||
"studyOptionsReviewsPerDayHint": "Cards in their learning steps come anyway.",
|
||||
"studyOptionsOrder": "Order",
|
||||
"studyOptionsReviewsFirst": "Reviews first",
|
||||
"studyOptionsMixed": "Mixed",
|
||||
"studyOptionsNewFirst": "New first",
|
||||
"studyOptionsReset": "Reset to defaults",
|
||||
"studySettingsUnlimited": "Unlimited",
|
||||
"studySettingsSortBy": "Sort by",
|
||||
"studySettingsLearnMode": "Learn mode",
|
||||
|
|
|
|||
|
|
@ -2587,9 +2587,63 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studySettingsNewPerDayHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How many never-studied cards each studyset starts a day. Due reviews are never limited.'**
|
||||
/// **'How many never-studied cards each studyset starts a day, unless its own study options say otherwise.'**
|
||||
String get studySettingsNewPerDayHint;
|
||||
|
||||
/// No description provided for @studyOptionsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study options'**
|
||||
String get studyOptionsTitle;
|
||||
|
||||
/// No description provided for @studyOptionsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.'**
|
||||
String get studyOptionsDescription;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsPerDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reviews per day'**
|
||||
String get studyOptionsReviewsPerDay;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsPerDayHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards in their learning steps come anyway.'**
|
||||
String get studyOptionsReviewsPerDayHint;
|
||||
|
||||
/// No description provided for @studyOptionsOrder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Order'**
|
||||
String get studyOptionsOrder;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reviews first'**
|
||||
String get studyOptionsReviewsFirst;
|
||||
|
||||
/// No description provided for @studyOptionsMixed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mixed'**
|
||||
String get studyOptionsMixed;
|
||||
|
||||
/// No description provided for @studyOptionsNewFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New first'**
|
||||
String get studyOptionsNewFirst;
|
||||
|
||||
/// No description provided for @studyOptionsReset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset to defaults'**
|
||||
String get studyOptionsReset;
|
||||
|
||||
/// No description provided for @studySettingsUnlimited.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1526,7 +1526,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studySettingsNewPerDayHint =>
|
||||
'Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt. Fällige Wiederholungen werden nie begrenzt.';
|
||||
'Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt, sofern seine eigenen Lernoptionen nichts anderes sagen.';
|
||||
|
||||
@override
|
||||
String get studyOptionsTitle => 'Lernoptionen';
|
||||
|
||||
@override
|
||||
String get studyOptionsDescription =>
|
||||
'Wie viele Karten dieses Lernset pro Tag bringt und welche zuerst kommen, auf all deinen Geräten. Die Lernsets darin behalten ihre eigenen.';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Wiederholungen pro Tag';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Karten in ihren Lernschritten kommen trotzdem.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Reihenfolge';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsFirst => 'Wiederholungen zuerst';
|
||||
|
||||
@override
|
||||
String get studyOptionsMixed => 'Gemischt';
|
||||
|
||||
@override
|
||||
String get studyOptionsNewFirst => 'Neue zuerst';
|
||||
|
||||
@override
|
||||
String get studyOptionsReset => 'Standard wiederherstellen';
|
||||
|
||||
@override
|
||||
String get studySettingsUnlimited => 'Unbegrenzt';
|
||||
|
|
|
|||
|
|
@ -1514,7 +1514,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studySettingsNewPerDayHint =>
|
||||
'How many never-studied cards each studyset starts a day. Due reviews are never limited.';
|
||||
'How many never-studied cards each studyset starts a day, unless its own study options say otherwise.';
|
||||
|
||||
@override
|
||||
String get studyOptionsTitle => 'Study options';
|
||||
|
||||
@override
|
||||
String get studyOptionsDescription =>
|
||||
'How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Reviews per day';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Cards in their learning steps come anyway.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Order';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsFirst => 'Reviews first';
|
||||
|
||||
@override
|
||||
String get studyOptionsMixed => 'Mixed';
|
||||
|
||||
@override
|
||||
String get studyOptionsNewFirst => 'New first';
|
||||
|
||||
@override
|
||||
String get studyOptionsReset => 'Reset to defaults';
|
||||
|
||||
@override
|
||||
String get studySettingsUnlimited => 'Unlimited';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -80,6 +81,12 @@ class StudySessionBloc(
|
|||
/// The cards crammed in earlier rounds of this session.
|
||||
final _crammed = <String>{};
|
||||
|
||||
// Each studyset's cards in the round under way, in tree order, with each
|
||||
// one's study options, and the order the studied studyset asks for.
|
||||
var _groupIds = <List<String>>[];
|
||||
var _groupOptions = <StudysetStudyOptions>[];
|
||||
var _order = NewCardOrder.reviewsFirst;
|
||||
|
||||
Future<void> _startRound(Emitter<StudySessionState> emit) async {
|
||||
emit(const StudySessionStateLoading());
|
||||
_options = (await _settings.read()).study;
|
||||
|
|
@ -111,17 +118,10 @@ class StudySessionBloc(
|
|||
return;
|
||||
}
|
||||
final schedules = await _schedules([..._cardsById.keys]);
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
_groupIds = [
|
||||
for (final group in groups) [for (final card in group) card.id],
|
||||
],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
newPerDay: _options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? 1 << 30
|
||||
: _options.newPerDay,
|
||||
);
|
||||
];
|
||||
final queue = _queue(schedules, now);
|
||||
_round = _ordered(queue).take(_options.cardsPerRound).toList();
|
||||
if (_round.isEmpty) {
|
||||
final upcoming = [
|
||||
|
|
@ -133,14 +133,35 @@ class StudySessionBloc(
|
|||
await _showNext(emit, now);
|
||||
}
|
||||
|
||||
/// The study queue of the round's studysets with their [schedules] at
|
||||
/// [now].
|
||||
List<String> _queue(Map<String, CardSchedule> schedules, DateTime now) =>
|
||||
studyQueue(
|
||||
groups: _groupIds,
|
||||
options: _groupOptions,
|
||||
order: _order,
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
);
|
||||
|
||||
/// The cards of every studyset studied, each studyset's in order, with
|
||||
/// the tags chosen for the studyset applied: by the focus, if any, else
|
||||
/// by the settings.
|
||||
/// by the settings. Takes each one's study options along: its own, else
|
||||
/// the settings' new cards a day.
|
||||
Future<List<List<Card>>> _groups() async {
|
||||
final tagged = await _cardsWithChosenTags();
|
||||
final all = await _studysets.all();
|
||||
final studied = _focus?.topics == false
|
||||
? [_studysetId]
|
||||
: studiedIn(_studysetId, await _studysets.all());
|
||||
: studiedIn(_studysetId, all);
|
||||
StudysetStudyOptions? optionsOf(String id) =>
|
||||
all.where((each) => each.id == id).firstOrNull?.content.studyOptions;
|
||||
_groupOptions = [
|
||||
for (final id in studied)
|
||||
optionsOf(id) ?? StudysetStudyOptions(newPerDay: _options.newPerDay),
|
||||
];
|
||||
_order = optionsOf(_studysetId)?.order ?? NewCardOrder.reviewsFirst;
|
||||
return [
|
||||
for (final studysetId in studied)
|
||||
[
|
||||
|
|
@ -288,14 +309,7 @@ class StudySessionBloc(
|
|||
more = _cardsById.keys.any((id) => !_crammed.contains(id));
|
||||
} else {
|
||||
final now = clock.now().toUtc();
|
||||
final ids = [..._cardsById.keys];
|
||||
final schedules = await _schedules(ids);
|
||||
more = studyQueue(
|
||||
groups: [ids],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
).isNotEmpty;
|
||||
more = _queue(await _schedules([..._cardsById.keys]), now).isNotEmpty;
|
||||
}
|
||||
return StudySessionStateRoundDone(tally: _tally(), more: more);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ final class const CardSchedule({
|
|||
/// When the card was first reviewed; null for a card never reviewed.
|
||||
final DateTime? firstReviewedAt,
|
||||
|
||||
/// When the card was last reviewed; null for a card never reviewed.
|
||||
final DateTime? lastReviewedAt,
|
||||
|
||||
/// Whether the card is in its learning or relearning steps.
|
||||
final bool isLearning = false,
|
||||
|
||||
|
|
@ -54,6 +57,7 @@ final class const CardSchedule({
|
|||
reviewCount,
|
||||
due,
|
||||
firstReviewedAt,
|
||||
lastReviewedAt,
|
||||
isLearning,
|
||||
stability,
|
||||
];
|
||||
|
|
@ -85,6 +89,7 @@ CardSchedule scheduleOf(String cardId, Iterable<Review> reviews) {
|
|||
reviewCount: ordered.length,
|
||||
due: _fuzzedDue(cardId, ordered.length, state),
|
||||
firstReviewedAt: ordered.first.reviewedAt.toUtc(),
|
||||
lastReviewedAt: ordered.last.reviewedAt.toUtc(),
|
||||
isLearning: state.state != fsrs.State.review,
|
||||
stability: state.stability,
|
||||
state: state,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// Where the studying of some cards stands, as the library shows it, with
|
||||
/// how many notes and learning paths there are beside them.
|
||||
|
|
@ -45,7 +46,8 @@ final class const StudyProgress({
|
|||
}
|
||||
|
||||
/// The progress of the cards in [groups] (each studyset's, in tree order, as
|
||||
/// [studyQueue] takes them) with their [schedules] at [now], in the local day
|
||||
/// [studyQueue] takes them, with their [options]) with their [schedules] at
|
||||
/// [now], in the local day
|
||||
/// that began at [startOfToday]: what a session started now would study, and
|
||||
/// how much is mature.
|
||||
StudyProgress studyProgress({
|
||||
|
|
@ -53,10 +55,12 @@ StudyProgress studyProgress({
|
|||
required Map<String, CardSchedule> schedules,
|
||||
required DateTime now,
|
||||
required DateTime startOfToday,
|
||||
List<StudysetStudyOptions> options = const [],
|
||||
}) {
|
||||
final cards = [for (final group in groups) ...group];
|
||||
final queue = studyQueue(
|
||||
groups: groups,
|
||||
options: options,
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
|
|
|
|||
|
|
@ -1,42 +1,78 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// The cards to study at [now], in order (decided 2026-09-30; over a studyset
|
||||
/// with what is inside it, 2026-10-01).
|
||||
/// with what is inside it, 2026-10-01; with each studyset's study options,
|
||||
/// 2026-10-07).
|
||||
///
|
||||
/// [groups] holds the cards of every studyset studied, in tree order, each in
|
||||
/// its own order. First the cards due for review or in their learning steps,
|
||||
/// earliest first, from every studyset; then each studyset's new cards, in
|
||||
/// tree order, at most [newPerDay] started per studyset and local day, the day
|
||||
/// that began at [startOfToday]. When nothing else is due, a learning card due
|
||||
/// within [learnAhead] comes early.
|
||||
/// its own order, and [options] the study options of each, in the same order;
|
||||
/// a studyset without starts [newPerDay] new cards and reviews all that is
|
||||
/// due. The cards due for review or in their learning steps come earliest
|
||||
/// first from every studyset, at most each studyset's reviews a day (cards in
|
||||
/// their learning steps come anyway); the new cards come in tree order, at
|
||||
/// most each studyset's new cards a day. A day is the local one that began at
|
||||
/// [startOfToday], and what was reviewed or started in it counts. [order]
|
||||
/// (the studied studyset's) says whether the due cards or the new ones come
|
||||
/// first, or the new ones spread among the due. When nothing else is due, a
|
||||
/// learning card due within [learnAhead] comes early.
|
||||
List<String> studyQueue({
|
||||
required List<List<String>> groups,
|
||||
required Map<String, CardSchedule> schedules,
|
||||
required DateTime now,
|
||||
required DateTime startOfToday,
|
||||
List<StudysetStudyOptions> options = const [],
|
||||
int newPerDay = 20,
|
||||
NewCardOrder order = NewCardOrder.reviewsFirst,
|
||||
Duration learnAhead = const Duration(minutes: 20),
|
||||
}) {
|
||||
DateTime? dueOf(String id) => schedules[id]?.due;
|
||||
int byDue(String a, String b) => dueOf(a)!.compareTo(dueOf(b)!);
|
||||
bool startedToday(String id) =>
|
||||
schedules[id]?.firstReviewedAt?.isBefore(startOfToday) == false;
|
||||
bool today(DateTime? at) => at?.isBefore(startOfToday) == false;
|
||||
bool startedToday(String id) => today(schedules[id]?.firstReviewedAt);
|
||||
// A review today of a card started before today.
|
||||
bool reviewedToday(String id) =>
|
||||
today(schedules[id]?.lastReviewedAt) && !startedToday(id);
|
||||
int left(int limit, int used) => limit == StudysetStudyOptions.unlimited
|
||||
? 1 << 30
|
||||
: math.max(0, limit - used);
|
||||
final cards = [for (final group in groups) ...group];
|
||||
|
||||
final due = [
|
||||
for (final id in cards)
|
||||
final due = <String>[];
|
||||
final fresh = <String>[];
|
||||
for (final (index, group) in groups.indexed) {
|
||||
final limits = index < options.length
|
||||
? options[index]
|
||||
: StudysetStudyOptions(newPerDay: newPerDay);
|
||||
final groupDue = [
|
||||
for (final id in group)
|
||||
if (dueOf(id) case final due? when !due.isAfter(now)) id,
|
||||
]..sort(byDue);
|
||||
final fresh = [
|
||||
for (final group in groups)
|
||||
...[
|
||||
final learning = groupDue.where((id) => schedules[id]!.isLearning);
|
||||
due
|
||||
..addAll(learning)
|
||||
..addAll(
|
||||
groupDue
|
||||
.where((id) => !schedules[id]!.isLearning)
|
||||
.take(
|
||||
left(limits.reviewsPerDay, group.where(reviewedToday).length),
|
||||
),
|
||||
);
|
||||
fresh.addAll(
|
||||
[
|
||||
for (final id in group)
|
||||
if (schedules[id]?.isNew ?? true) id,
|
||||
].take(math.max(0, newPerDay - group.where(startedToday).length)),
|
||||
];
|
||||
final queue = [...due, ...fresh];
|
||||
].take(left(limits.newPerDay, group.where(startedToday).length)),
|
||||
);
|
||||
}
|
||||
due.sort(byDue);
|
||||
final queue = switch (order) {
|
||||
NewCardOrder.reviewsFirst => [...due, ...fresh],
|
||||
NewCardOrder.newFirst => [...fresh, ...due],
|
||||
NewCardOrder.mixed => _spread(fresh, among: due),
|
||||
};
|
||||
if (queue.isNotEmpty) {
|
||||
return queue;
|
||||
}
|
||||
|
|
@ -50,6 +86,22 @@ List<String> studyQueue({
|
|||
return soon.take(1).toList();
|
||||
}
|
||||
|
||||
/// [these] spread evenly [among] the others, each list in its order: four
|
||||
/// cards and one new one give two cards, the new one, two cards.
|
||||
List<String> _spread(List<String> these, {required List<String> among}) {
|
||||
final spread = <String>[];
|
||||
var next = 0;
|
||||
for (final (index, id) in these.indexed) {
|
||||
// An even share of the others before each one of these.
|
||||
final until = ((index + 1) * among.length / (these.length + 1)).round();
|
||||
while (next < until) {
|
||||
spread.add(among[next++]);
|
||||
}
|
||||
spread.add(id);
|
||||
}
|
||||
return spread..addAll(among.skip(next));
|
||||
}
|
||||
|
||||
/// The start of the learner's local calendar day at [now], in UTC: a day's
|
||||
/// limits count from the local midnight, not from UTC's (AGENTS.md).
|
||||
DateTime startOfLocalDay(DateTime now) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Whether a session over a studyset brings the cards due for review or the
|
||||
/// new ones first.
|
||||
enum NewCardOrder() {
|
||||
/// The cards due first, then the new ones.
|
||||
reviewsFirst,
|
||||
|
||||
/// The new cards spread evenly among the cards due.
|
||||
mixed,
|
||||
|
||||
/// The new cards first, then the cards due.
|
||||
newFirst,
|
||||
}
|
||||
|
||||
/// How a studyset is studied, the same on every device (in the studyset's
|
||||
/// synced content; decided 2026-10-07): how many new cards it starts and how
|
||||
/// many it reviews a local day, and which come first. A studyset without
|
||||
/// options of its own starts as many new cards as the person's study
|
||||
/// settings say, reviews all that is due, and brings the reviews first.
|
||||
final class const StudysetStudyOptions({
|
||||
/// How many never-studied cards the studyset starts a day; [unlimited]
|
||||
/// for no limit.
|
||||
final int newPerDay = 20,
|
||||
|
||||
/// How many cards due for review it brings a day; [unlimited] for no
|
||||
/// limit. Cards in their learning steps come anyway.
|
||||
final int reviewsPerDay = unlimited,
|
||||
|
||||
/// Whether the reviews or the new cards come first.
|
||||
final NewCardOrder order = NewCardOrder.reviewsFirst,
|
||||
}) with Equatable {
|
||||
/// A limit for no limit.
|
||||
static const unlimited = 0;
|
||||
|
||||
/// The highest daily limit of new cards.
|
||||
static const maxNewPerDay = 100;
|
||||
|
||||
/// The highest daily limit of reviews.
|
||||
static const maxReviewsPerDay = 1000;
|
||||
|
||||
/// These options with some replaced, the limits kept in their ranges.
|
||||
StudysetStudyOptions copyWith({
|
||||
int? newPerDay,
|
||||
int? reviewsPerDay,
|
||||
NewCardOrder? order,
|
||||
}) => StudysetStudyOptions(
|
||||
newPerDay: (newPerDay ?? this.newPerDay).clamp(unlimited, maxNewPerDay),
|
||||
reviewsPerDay: (reviewsPerDay ?? this.reviewsPerDay).clamp(
|
||||
unlimited,
|
||||
maxReviewsPerDay,
|
||||
),
|
||||
order: order ?? this.order,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [newPerDay, reviewsPerDay, order];
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A number with fewer and more beside it; a null action can't go further.
|
||||
class const CountStepper({
|
||||
required final String value,
|
||||
required final VoidCallback? onFewer,
|
||||
required final VoidCallback? onMore,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyFewer,
|
||||
onPressed: onFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyMore,
|
||||
onPressed: onMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ 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/feature/study/bloc/study_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/count_stepper.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows how the studyset [studysetId] is studied, to change it: the tags
|
||||
|
|
@ -135,7 +135,7 @@ class const _Options({required final StudyOptions options})
|
|||
children: [
|
||||
Text(l10n.studySettingsCardsPerRound, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
_Stepper(
|
||||
CountStepper(
|
||||
value: '${options.cardsPerRound}',
|
||||
onFewer: options.cardsPerRound > StudyOptions.minCardsPerRound
|
||||
? () => change(
|
||||
|
|
@ -159,7 +159,7 @@ class const _Options({required final StudyOptions options})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Stepper(
|
||||
CountStepper(
|
||||
value: options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? l10n.studySettingsUnlimited
|
||||
: '${options.newPerDay}',
|
||||
|
|
@ -231,37 +231,3 @@ class const _Options({required final StudyOptions options})
|
|||
|
||||
/// How far the daily limit of new cards moves at a step.
|
||||
const _newStep = 5;
|
||||
|
||||
/// A number with fewer and more beside it; a null action can't go further.
|
||||
class const _Stepper({
|
||||
required final String value,
|
||||
required final VoidCallback? onFewer,
|
||||
required final VoidCallback? onMore,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyFewer,
|
||||
onPressed: onFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyMore,
|
||||
onPressed: onMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/count_stepper.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What the study options sheet ended with: the options to keep, or null to
|
||||
/// go back to the defaults.
|
||||
typedef StudyOptionsChoice = ({StudysetStudyOptions? options});
|
||||
|
||||
/// Shows a studyset's study options to change them: its [current] ones, or
|
||||
/// the defaults (starting [defaultNewPerDay] new cards a day, as the person's
|
||||
/// study settings say) when it has none. Completes with what the person
|
||||
/// saved or reset, or with null when they close the sheet.
|
||||
Future<StudyOptionsChoice?> showStudysetStudyOptionsSheet(
|
||||
BuildContext context, {
|
||||
required StudysetStudyOptions? current,
|
||||
required int defaultNewPerDay,
|
||||
}) => showAdaptiveSheet<StudyOptionsChoice>(
|
||||
context,
|
||||
builder: (context) => _StudyOptionsSheet(
|
||||
current: current,
|
||||
defaults: StudysetStudyOptions(newPerDay: defaultNewPerDay),
|
||||
),
|
||||
);
|
||||
|
||||
/// How far the daily limit of new cards moves at a step.
|
||||
const _newStep = 5;
|
||||
|
||||
/// How far the daily limit of reviews moves at a step.
|
||||
const _reviewsStep = 50;
|
||||
|
||||
class const _StudyOptionsSheet({
|
||||
required final StudysetStudyOptions? current,
|
||||
required final StudysetStudyOptions defaults,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_StudyOptionsSheet> createState() => _StudyOptionsSheetState();
|
||||
}
|
||||
|
||||
class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
||||
late var _options = widget.current ?? widget.defaults;
|
||||
|
||||
void _change(StudysetStudyOptions options) =>
|
||||
setState(() => _options = options);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final heading = theme.textTheme.titleSmall;
|
||||
String limit(int value) => value == StudysetStudyOptions.unlimited
|
||||
? l10n.studySettingsUnlimited
|
||||
: '$value';
|
||||
final StudysetStudyOptions(:newPerDay, :reviewsPerDay, :order) = _options;
|
||||
return SheetScaffold(
|
||||
title: l10n.studyOptionsTitle,
|
||||
footer: Row(
|
||||
children: [
|
||||
if (widget.current != null)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: null)),
|
||||
child: Text(l10n.studyOptionsReset),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: _options)),
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.studyOptionsDescription,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studySettingsNewPerDay, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: limit(newPerDay),
|
||||
onFewer: newPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(newPerDay: newPerDay - _newStep),
|
||||
)
|
||||
: null,
|
||||
onMore: newPerDay < StudysetStudyOptions.maxNewPerDay
|
||||
? () => _change(
|
||||
_options.copyWith(newPerDay: newPerDay + _newStep),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studyOptionsReviewsPerDay, style: heading),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.studyOptionsReviewsPerDayHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: limit(reviewsPerDay),
|
||||
onFewer: reviewsPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(
|
||||
reviewsPerDay: reviewsPerDay - _reviewsStep,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onMore: reviewsPerDay < StudysetStudyOptions.maxReviewsPerDay
|
||||
? () => _change(
|
||||
_options.copyWith(
|
||||
reviewsPerDay: reviewsPerDay + _reviewsStep,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studyOptionsOrder, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<NewCardOrder>(
|
||||
segments: [
|
||||
for (final each in NewCardOrder.values)
|
||||
ButtonSegment(
|
||||
value: each,
|
||||
label: Text(switch (each) {
|
||||
NewCardOrder.reviewsFirst => l10n.studyOptionsReviewsFirst,
|
||||
NewCardOrder.mixed => l10n.studyOptionsMixed,
|
||||
NewCardOrder.newFirst => l10n.studyOptionsNewFirst,
|
||||
}),
|
||||
),
|
||||
],
|
||||
selected: {order},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (selection) =>
|
||||
_change(_options.copyWith(order: selection.single)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -160,7 +162,11 @@ class StudysetListBloc(
|
|||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetDeletions _deletions, {
|
||||
final StudysetDeletions _deletions,
|
||||
|
||||
/// The person's settings, for the new cards a studyset without study
|
||||
/// options of its own starts a day.
|
||||
final SettingsRepository _settings, {
|
||||
|
||||
/// The studyset whose children to list; the library's top level when
|
||||
/// null.
|
||||
|
|
@ -296,6 +302,14 @@ class StudysetListBloc(
|
|||
.firstOrNull;
|
||||
final notes = await _notes.countsByStudyset();
|
||||
final paths = await _paths.countsByStudyset();
|
||||
final newPerDay = (await _settings.read()).study.newPerDay;
|
||||
StudysetStudyOptions optionsOf(String id) =>
|
||||
_studysetsRead
|
||||
.where((each) => each.id == id)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.studyOptions ??
|
||||
StudysetStudyOptions(newPerDay: newPerDay);
|
||||
for (final studyset in [...studysets, ?parent]) {
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
|
|
@ -309,6 +323,7 @@ class StudysetListBloc(
|
|||
progress[studyset.id] =
|
||||
studyProgress(
|
||||
groups: groups,
|
||||
options: [for (final id in studied) optionsOf(id)],
|
||||
schedules: await _reviews.schedulesOf([
|
||||
for (final group in groups) ...group,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
// A studyset's content as its synced row stores it (see core/content/payload).
|
||||
|
|
@ -9,14 +10,22 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
// Schema 1 held the title and the description. Schema 2 added the secondary
|
||||
// title, the colour, the licence (name, holder, year) and the languages of
|
||||
// the cards' sides, each left out when not given; schema 1 reads as schema 2
|
||||
// without them.
|
||||
// without them. Schema 3 added the study options (`study`: new cards and
|
||||
// reviews a day, 0 for no limit, and the order), left out when the studyset
|
||||
// has none.
|
||||
//
|
||||
// A studyset without study options is still written in schema 2: an app that
|
||||
// knows only schema 2 skips a schema 3 payload whole, and would lose sight of
|
||||
// every studyset edited on a newer app otherwise.
|
||||
|
||||
/// The schema this app writes.
|
||||
const studysetSchemaVersion = 2;
|
||||
/// The newest schema this app writes.
|
||||
const studysetSchemaVersion = 3;
|
||||
|
||||
/// Encodes a studyset's content into its payload.
|
||||
ByteData encodeStudysetPayload(StudysetContent content) =>
|
||||
encodePayload(studysetSchemaVersion, {
|
||||
/// Encodes a studyset's content into its payload, in the oldest schema that
|
||||
/// holds it.
|
||||
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
|
||||
content.studyOptions == null ? 2 : studysetSchemaVersion,
|
||||
{
|
||||
'title': content.title,
|
||||
'description': content.description.toJson(),
|
||||
'secondaryTitle': ?content.secondaryTitle,
|
||||
|
|
@ -26,7 +35,14 @@ ByteData encodeStudysetPayload(StudysetContent content) =>
|
|||
'licenseYear': ?content.licenseYear,
|
||||
'frontLanguage': ?content.frontLanguage,
|
||||
'backLanguage': ?content.backLanguage,
|
||||
});
|
||||
if (content.studyOptions case final options?)
|
||||
'study': {
|
||||
'newPerDay': options.newPerDay,
|
||||
'reviewsPerDay': options.reviewsPerDay,
|
||||
'order': options.order.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/// Decodes a studyset's payload.
|
||||
///
|
||||
|
|
@ -48,6 +64,21 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
|
|||
licenseYear: _optional(json, 'licenseYear'),
|
||||
frontLanguage: _optional(json, 'frontLanguage'),
|
||||
backLanguage: _optional(json, 'backLanguage'),
|
||||
studyOptions: switch (_optional<Map<String, Object?>>(json, 'study')) {
|
||||
null => null,
|
||||
final study => StudysetStudyOptions(
|
||||
newPerDay:
|
||||
_optional(study, 'newPerDay') ??
|
||||
const StudysetStudyOptions().newPerDay,
|
||||
reviewsPerDay:
|
||||
_optional(study, 'reviewsPerDay') ??
|
||||
StudysetStudyOptions.unlimited,
|
||||
// An order of a newer app reads as the default.
|
||||
order:
|
||||
NewCardOrder.values.asNameMap()[study['order']] ??
|
||||
NewCardOrder.reviewsFirst,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a studyset payload.');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// What a person writes about a studyset: everything its synced payload
|
||||
/// holds. Where it sits and whether it is starred or archived are columns of
|
||||
|
|
@ -36,6 +37,9 @@ final class const StudysetContent({
|
|||
|
||||
/// The language of the cards' backs, like [frontLanguage].
|
||||
final String? backLanguage,
|
||||
|
||||
/// How it is studied; null for the defaults (see [StudysetStudyOptions]).
|
||||
final StudysetStudyOptions? studyOptions,
|
||||
}) with Equatable {
|
||||
/// Content with only a [title] and an empty description, as a new
|
||||
/// studyset starts.
|
||||
|
|
@ -53,6 +57,7 @@ final class const StudysetContent({
|
|||
licenseYear,
|
||||
frontLanguage,
|
||||
backLanguage,
|
||||
studyOptions,
|
||||
];
|
||||
|
||||
/// This content with a new [title] or [description]. The other fields can
|
||||
|
|
@ -68,5 +73,21 @@ final class const StudysetContent({
|
|||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: studyOptions,
|
||||
);
|
||||
|
||||
/// This content studied with [options]; null for the defaults.
|
||||
StudysetContent withStudyOptions(StudysetStudyOptions? options) =>
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: description,
|
||||
secondaryTitle: secondaryTitle,
|
||||
colorName: colorName,
|
||||
license: license,
|
||||
licenseHolder: licenseHolder,
|
||||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: options,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ 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/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -36,6 +38,7 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
person.paths,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
person.settings,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: const _Library(),
|
||||
);
|
||||
|
|
@ -296,6 +299,16 @@ class _GridState() extends State<_Grid> {
|
|||
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
||||
}
|
||||
},
|
||||
// The grid's context, as for deleting: it holds the list's bloc and
|
||||
// outlives the card's menu.
|
||||
onMove: () async {
|
||||
final all = await PersonScope.of(this.context).studysets.all();
|
||||
if (mounted) {
|
||||
await moveStudyset(this.context, studyset, all);
|
||||
}
|
||||
},
|
||||
onStudyOptions: () => changeStudyOptions(this.context, studyset),
|
||||
onImport: () => importFromAnki(this.context, studysetId: studyset.id),
|
||||
// Archiving is tidying up, not destroying, so it doesn't ask first:
|
||||
// it offers to undo.
|
||||
onArchive: () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// What a studyset's options do the same from the library's card and from the
|
||||
// studyset's own screen, through the StudysetListBloc above them.
|
||||
|
||||
/// Asks where [studyset] goes among [all] the person's studysets, moves it
|
||||
/// there and says where it went.
|
||||
Future<void> moveStudyset(
|
||||
BuildContext context,
|
||||
Studyset studyset,
|
||||
List<Studyset> all,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final destination = await showMoveSheet(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
);
|
||||
if (destination == null) {
|
||||
return;
|
||||
}
|
||||
bloc.add(
|
||||
StudysetListEventMoveRequested(studyset.id, parentId: destination.parentId),
|
||||
);
|
||||
final name = switch (destination.parentId) {
|
||||
null => l10n.studysetMoveTopLevel,
|
||||
final parentId =>
|
||||
all.where((each) => each.id == parentId).firstOrNull?.content.title ?? '',
|
||||
};
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.studysetMoved(name))));
|
||||
}
|
||||
|
||||
/// Shows [studyset]'s study options to change them, and keeps what the
|
||||
/// person saves, or goes back to the defaults.
|
||||
Future<void> changeStudyOptions(BuildContext context, Studyset studyset) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final settings = PersonScope.of(context).settings;
|
||||
final defaultNewPerDay = (await settings.read()).study.newPerDay;
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final current = studyset.content.studyOptions;
|
||||
final choice = await showStudysetStudyOptionsSheet(
|
||||
context,
|
||||
current: current,
|
||||
defaultNewPerDay: defaultNewPerDay,
|
||||
);
|
||||
if (choice case (:final options) when options != current) {
|
||||
bloc.add(
|
||||
StudysetListEventContentChanged(
|
||||
studyset.id,
|
||||
studyset.content.withStudyOptions(options),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,15 @@ class const StudysetCard({
|
|||
/// Edits its title, colour and the like.
|
||||
required final VoidCallback onEdit,
|
||||
|
||||
/// Moves it into another studyset or to the top level.
|
||||
required final VoidCallback onMove,
|
||||
|
||||
/// Changes how it is studied.
|
||||
required final VoidCallback onStudyOptions,
|
||||
|
||||
/// Imports an Anki deck into it.
|
||||
required final VoidCallback onImport,
|
||||
|
||||
/// Archives it.
|
||||
required final VoidCallback onArchive,
|
||||
|
||||
|
|
@ -126,6 +135,9 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
archived: archived,
|
||||
onToggleStar: widget.onToggleStar,
|
||||
onEdit: widget.onEdit,
|
||||
onMove: widget.onMove,
|
||||
onStudyOptions: widget.onStudyOptions,
|
||||
onImport: widget.onImport,
|
||||
onArchive: widget.onArchive,
|
||||
onUnarchive: widget.onUnarchive,
|
||||
onDelete: widget.onDelete,
|
||||
|
|
@ -283,6 +295,9 @@ class const _Paused() extends StatelessWidget {
|
|||
|
||||
enum _Option() {
|
||||
edit,
|
||||
move,
|
||||
studyOptions,
|
||||
import,
|
||||
archive,
|
||||
unarchive,
|
||||
delete,
|
||||
|
|
@ -295,6 +310,9 @@ class const _BottomBar({
|
|||
required final bool archived,
|
||||
required final VoidCallback onToggleStar,
|
||||
required final VoidCallback onEdit,
|
||||
required final VoidCallback onMove,
|
||||
required final VoidCallback onStudyOptions,
|
||||
required final VoidCallback onImport,
|
||||
required final VoidCallback onArchive,
|
||||
required final VoidCallback onUnarchive,
|
||||
required final VoidCallback onDelete,
|
||||
|
|
@ -331,12 +349,30 @@ class const _BottomBar({
|
|||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_Option.edit => onEdit(),
|
||||
_Option.move => onMove(),
|
||||
_Option.studyOptions => onStudyOptions(),
|
||||
_Option.import => onImport(),
|
||||
_Option.archive => onArchive(),
|
||||
_Option.unarchive => onUnarchive(),
|
||||
_Option.delete => onDelete(),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
_item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
||||
_item(
|
||||
_Option.move,
|
||||
LucideIcons.folderInput,
|
||||
l10n.studysetMenuMove,
|
||||
),
|
||||
_item(
|
||||
_Option.studyOptions,
|
||||
LucideIcons.slidersHorizontal,
|
||||
l10n.studyOptionsTitle,
|
||||
),
|
||||
_item(
|
||||
_Option.import,
|
||||
LucideIcons.fileInput,
|
||||
l10n.ankiImportMenu,
|
||||
),
|
||||
if (archived)
|
||||
_item(
|
||||
_Option.unarchive,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue