feat(app): study with FSRS

A studyset's card list gains "Study". The study screen shows the front, then the answer and the four ratings, each labelled with when the card would come back; every rating is recorded with how long answering took, and the queue is worked out anew after each.

Schedules are never stored: scheduleOf replays a card's reviews in time order with FSRS-6 (fsrs v2.0.1, pinned exactly so every device on one app version computes the same). The package's fuzz can only be seeded through an API meant for tests, and due dates don't feed back into a card's state, so the replay runs without fuzz and fuzzedDays spreads the final due date over FSRS's range, picked by a stable FNV-1a fraction of the card's id and review count. studyQueue takes due cards earliest first, then new cards in studyset order, 20 per studyset and local day, and brings a learning card due within 20 minutes early when nothing else is due. Deleting a card deletes its reviews.

Tests: the replay (learning, growing intervals, the same schedule whatever order reviews arrive in, previews matching what a rating yields), the fuzz range, the queue (order, the daily limit counted per local day, learn-ahead), the repository, the session bloc under a controlled clock, the screen, and a sync test for ratings on two devices. AGENTS.md records the review log and scheduling, and how plugin lint rules are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 16:59:07 +02:00
commit f6390120f2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 1263 additions and 5 deletions

View file

@ -34,7 +34,7 @@ Only when the server cannot be started at all, fall back to the CLI in the serve
Tests need no Docker. `config/test.yaml` sets `database.dataPath`, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run `dart test` in the server package.
Two tooling traps: `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; and never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does.
Tooling traps: rules of `leancode_lint`'s analyzer plugin are ignored with its prefix (`// ignore: leancode_lint/missing_equatable_props`); `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; and never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does.
Checklist after doing changes, in this order:
@ -118,6 +118,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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.
- **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. 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. The study queue (`study_queue.dart`): due cards earliest first, then new cards in studyset order, 20 per studyset and local day. 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.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.

View file

@ -11,6 +11,8 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gatew
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:path/path.dart' as p;
@ -97,6 +99,7 @@ Future<PersonDependencies> _openPerson(Client client, SignedIn person) async {
return PersonDependencies(
studysets: studysets,
cards: cards,
reviews: ReviewRepository(SyncReviewGateway(db, sync)),
onDispose: () async {
await studysets.dispose();
await cards.dispose();

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// Everything that belongs to one signed-in person: their local database, its
@ -12,6 +13,9 @@ class PersonDependencies({
/// The person's flashcards.
required final CardRepository cards,
/// The person's reviews.
required final ReviewRepository reviews,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -40,7 +40,7 @@ abstract interface class CardGateway() {
/// Blanks the payload of every revision of the card [cardId].
Future<void> blankRevisions(String cardId);
/// Deletes the card [cardId] and its revisions.
/// Deletes the card [cardId], its revisions and its reviews.
Future<void> deleteCard(String cardId);
/// Fires when a sync brought changes from the person's other devices.

View file

@ -127,6 +127,12 @@ class SyncCardGateway(
@override
Future<void> deleteCard(String cardId) async {
final id = protocol.UuidValue.withValidation(cardId);
// Its reviews go too: no content, but personal study data that would
// otherwise stay on the server as hidden rows.
await protocol.ReviewLog.db.deleteWhere(
_db,
where: (t) => t.cardId.equals(id),
);
await protocol.CardRevision.db.deleteWhere(
_db,
where: (t) => t.cardId.equals(id),

View file

@ -3,6 +3,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart'
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
@ -12,6 +14,9 @@ class const CardList({
/// The person's cards; the screens this list opens need them too.
required final CardRepository cards,
/// The person's reviews, for studying the cards.
required final ReviewRepository reviews,
/// The studyset whose cards these are.
required final String studysetId,
super.key,
@ -31,6 +36,13 @@ class const CardList({
style: Theme.of(context).textTheme.titleSmall,
),
),
TextButton.icon(
icon: const Icon(Icons.school_outlined),
label: const Text('Study'),
onPressed: () => Navigator.of(
context,
).push(StudyScreen.route(cards, reviews, studysetId)),
),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add card'),

View file

@ -0,0 +1,180 @@
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
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/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
/// A study session over a studyset's cards: shows the next card of the study
/// queue, its answer on request, and records each rating with how long
/// answering took; the queue is worked out anew after every rating.
class StudySessionBloc(
final CardRepository _cards,
final ReviewRepository _reviews,
final String _studysetId,
) extends Bloc<StudySessionEvent, StudySessionState> {
this : super(const StudySessionStateLoading()) {
on<StudySessionEventStarted>((event, emit) => _showNext(emit));
on<StudySessionEventAnswerShown>(_onAnswerShown);
on<StudySessionEventRated>(_onRated);
}
void _onAnswerShown(
StudySessionEventAnswerShown event,
Emitter<StudySessionState> emit,
) {
if (state case final StudySessionStateShowing showing) {
emit(
showing.withAnswer(
nextDueDates(
showing.card.id,
showing.schedule,
clock.now().toUtc(),
),
),
);
}
}
Future<void> _onRated(
StudySessionEventRated event,
Emitter<StudySessionState> emit,
) async {
if (state case final StudySessionStateShowing showing
when showing.answerShown) {
final now = clock.now().toUtc();
await _reviews.record(
cardId: showing.card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
);
await _showNext(emit);
}
}
Future<void> _showNext(Emitter<StudySessionState> emit) async {
final cards = await _cards.cardsOf(_studysetId);
final reviewsByCard = <String, List<Review>>{};
for (final review in await _reviews.reviewsOf({
for (final card in cards) card.id,
})) {
(reviewsByCard[review.cardId] ??= []).add(review);
}
final schedules = {
for (final card in cards)
card.id: scheduleOf(card.id, reviewsByCard[card.id] ?? const []),
};
final now = clock.now().toUtc();
// "Today" is the learner's local calendar day (AGENTS.md).
final local = now.toLocal();
final queue = studyQueue(
cardOrder: [for (final card in cards) card.id],
schedules: schedules,
now: now,
startOfToday: DateTime(local.year, local.month, local.day).toUtc(),
);
if (queue.isEmpty) {
final upcoming = [
for (final schedule in schedules.values) ?schedule.due,
]..sort();
emit(StudySessionStateDone(next: upcoming.firstOrNull));
return;
}
final card = cards.firstWhere((card) => card.id == queue.first);
emit(
StudySessionStateShowing(
card: card,
schedule: schedules[card.id]!,
shownAt: now,
remaining: queue.length,
),
);
}
}
/// What happens during a study session.
sealed class const StudySessionEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The session begins.
final class const StudySessionEventStarted() extends StudySessionEvent;
/// Show the answer of the card on screen.
final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// The person rated the card on screen.
final class const StudySessionEventRated(
/// How well they recalled it.
final Rating rating,
) extends StudySessionEvent {
@override
List<Object?> get props => [rating];
}
/// A study session, as the study screen shows it.
sealed class const StudySessionState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Working out what to study.
final class const StudySessionStateLoading() extends StudySessionState;
/// A card on screen.
final class const StudySessionStateShowing({
/// The card.
required final Card card,
/// Its schedule so far.
required final CardSchedule schedule,
/// When it appeared, for how long answering took.
required final DateTime shownAt,
/// How many cards are left in the queue, this one included.
required final int remaining,
/// Whether the answer shows.
final bool answerShown = false,
/// When the card would be due after each rating; filled once the answer
/// shows.
final Map<Rating, DateTime> nextDue = const {},
}) extends StudySessionState {
/// This state with the answer shown and [nextDue].
StudySessionStateShowing withAnswer(Map<Rating, DateTime> nextDue) =>
StudySessionStateShowing(
card: card,
schedule: schedule,
shownAt: shownAt,
remaining: remaining,
answerShown: true,
nextDue: nextDue,
);
@override
List<Object?> get props => [
card,
schedule,
shownAt,
remaining,
answerShown,
nextDue,
];
}
/// Nothing left to study now.
final class const StudySessionStateDone({
/// When the next card is due; null without any studied card.
final DateTime? next,
}) extends StudySessionState {
@override
List<Object?> get props => [next];
}

View file

@ -0,0 +1,26 @@
/// A review's row as the device's database stores it.
typedef ReviewRow = ({
String id,
String cardId,
int rating,
DateTime reviewedAt,
int durationMs,
});
/// The boundary to the reviews in this person's local, synced database. Tests
/// replace it with a fake.
abstract interface class ReviewGateway() {
/// The reviews of the cards [cardIds], unordered.
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds);
/// Appends a review; reviews are never changed.
Future<void> insert({
required String cardId,
required int rating,
required DateTime reviewedAt,
required int durationMs,
});
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,45 @@
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
/// The person's reviews: an append-only log from which every device derives
/// the same schedules (decided 2026-09-30).
class ReviewRepository(final ReviewGateway _gateway) {
/// The reviews of the cards [cardIds] on this device.
Future<List<Review>> reviewsOf(Set<String> cardIds) async => [
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
];
/// Records that [cardId] was rated [rating] at [reviewedAt], after
/// [duration] of answering.
Future<void> record({
required String cardId,
required Rating rating,
required DateTime reviewedAt,
required Duration duration,
}) => _gateway.insert(
cardId: cardId,
rating: rating.value,
reviewedAt: reviewedAt.toUtc(),
durationMs: duration.inMilliseconds,
);
/// The review in [row], or null for a rating this app doesn't know.
Review? _decode(ReviewRow row) {
for (final rating in Rating.values) {
if (rating.value == row.rating) {
return Review(
id: row.id,
cardId: row.cardId,
rating: rating,
reviewedAt: row.reviewedAt.toUtc(),
duration: Duration(milliseconds: row.durationMs),
);
}
}
log('Skipped review ${row.id}: rating ${row.rating}', name: 'Reviews');
return null;
}
}

View file

@ -0,0 +1,55 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ReviewGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncReviewGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ReviewGateway {
@override
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) async {
if (cardIds.isEmpty) {
return const [];
}
return [
for (final review in await protocol.ReviewLog.db.find(
_db,
where: (t) => t.cardId.inSet({
for (final id in cardIds) protocol.UuidValue.withValidation(id),
}),
))
(
id: review.id!.uuid,
cardId: review.cardId.uuid,
rating: review.rating,
reviewedAt: review.reviewedAt,
durationMs: review.durationMs,
),
];
}
@override
Future<void> insert({
required String cardId,
required int rating,
required DateTime reviewedAt,
required int durationMs,
}) async {
await protocol.ReviewLog.db.insertRow(
_db,
protocol.ReviewLog(
cardId: protocol.UuidValue.withValidation(cardId),
rating: rating,
reviewedAt: reviewedAt,
durationMs: durationMs,
),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
import 'package:fsrs/fsrs.dart' as fsrs;
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
// Scheduling with FSRS-6 and the package's default parameters (decided
// 2026-09-30). A card's schedule isn't stored: every device replays the card's
// reviews in time order, so all devices compute the same.
//
// The package's own fuzz is off: it can only be seeded through an API meant
// for tests. Due dates don't feed back into a card's state (stability and
// difficulty depend on when reviews happened, not on when they were due), so
// fuzzing only the final due date, from the card's id and review count,
// spreads cards the same way on every device.
final _scheduler = fsrs.Scheduler(enableFuzzing: false);
/// A card's schedule after its reviews.
final class const CardSchedule({
/// How many reviews it replays.
required final int reviewCount,
/// When the card is due next; null for a card never reviewed.
final DateTime? due,
/// When the card was first reviewed; null for a card never reviewed.
final DateTime? firstReviewedAt,
/// Whether the card is in its learning or relearning steps.
final bool isLearning = false,
/// FSRS's state after the last review.
final fsrs.Card? _state,
}) with Equatable {
/// Whether the card was never reviewed.
bool get isNew => reviewCount == 0;
@override
// FSRS's state compares by identity; these fields are what makes two
// schedules the same.
// ignore: leancode_lint/missing_equatable_props
List<Object?> get props => [reviewCount, due, firstReviewedAt, isLearning];
}
/// The schedule of [cardId] after [reviews], replayed in time order whatever
/// order they arrived in.
CardSchedule scheduleOf(String cardId, Iterable<Review> reviews) {
final ordered = [...reviews]
..sort(
(a, b) => switch (a.reviewedAt.compareTo(b.reviewedAt)) {
0 => a.id.compareTo(b.id),
final order => order,
},
);
fsrs.Card? state;
for (final review in ordered) {
state = _review(state, review.rating, review.reviewedAt);
}
if (state == null) {
return const CardSchedule(reviewCount: 0);
}
return CardSchedule(
reviewCount: ordered.length,
due: _fuzzedDue(cardId, ordered.length, state),
firstReviewedAt: ordered.first.reviewedAt.toUtc(),
isLearning: state.state != fsrs.State.review,
state: state,
);
}
/// When [cardId], scheduled as [schedule], would be due after each rating
/// given at [now]; the study screen shows them on the rating buttons.
Map<Rating, DateTime> nextDueDates(
String cardId,
CardSchedule schedule,
DateTime now,
) => {
for (final rating in Rating.values)
rating: _fuzzedDue(
cardId,
schedule.reviewCount + 1,
_review(schedule._state, rating, now),
),
};
/// FSRS's fuzz: [days] spread over a range that narrows as intervals grow,
/// picked by [fraction] (0 to 1) and at most [maximum]. Intervals under 2.5
/// days stay exact.
int fuzzedDays(int days, double fraction, {int maximum = 36500}) {
if (days < 2.5) {
return days;
}
var delta = 1.0;
for (final (start, end, factor) in const [
(2.5, 7.0, 0.15),
(7.0, 20.0, 0.1),
(20.0, double.infinity, 0.05),
]) {
delta += factor * math.max(math.min(days.toDouble(), end) - start, 0);
}
final highest = math.min((days + delta).round(), maximum);
final lowest = math.min(math.max(2, (days - delta).round()), highest);
return math.min(
lowest + (fraction * (highest - lowest + 1)).floor(),
highest,
);
}
fsrs.Card _review(fsrs.Card? state, Rating rating, DateTime at) {
final utc = at.toUtc();
return _scheduler
.reviewCard(
state ?? fsrs.Card(cardId: 0, due: utc),
fsrs.Rating.fromValue(rating.value),
reviewDateTime: utc,
)
.card;
}
DateTime _fuzzedDue(String cardId, int reviewCount, fsrs.Card state) {
final lastReview = state.lastReview;
if (state.state != fsrs.State.review || lastReview == null) {
return state.due;
}
final days = state.due.difference(lastReview).inDays;
return lastReview.add(
Duration(days: fuzzedDays(days, _fraction(cardId, reviewCount))),
);
}
/// A number from 0 to 1 that depends only on [cardId] and [reviewCount]
/// (FNV-1a), the same on every device and Dart version.
double _fraction(String cardId, int reviewCount) {
var hash = 0x811c9dc5;
for (final byte in utf8.encode('$cardId#$reviewCount')) {
hash = ((hash ^ byte) * 0x01000193) & 0xffffffff;
}
return hash / 0x100000000;
}

View file

@ -0,0 +1,17 @@
/// How well a card was recalled: FSRS's four ratings.
enum Rating(
/// The number FSRS and the review log store.
final int value,
) {
/// Forgotten.
again(1),
/// Recalled with serious difficulty.
hard(2),
/// Recalled after some hesitation.
good(3),
/// Recalled at once.
easy(4),
}

View file

@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
/// One rating of a card, never changed once recorded; a card's schedule is
/// derived from all of them (decided 2026-09-30).
final class const Review({
/// A UUID v7, made on the device that recorded it.
required final String id,
/// The card reviewed.
required final String cardId,
/// How well it was recalled.
required final Rating rating,
/// When, in UTC.
required final DateTime reviewedAt,
/// How long answering took, from showing the card to rating it.
required final Duration duration,
}) with Equatable {
@override
List<Object?> get props => [id, cardId, rating, reviewedAt, duration];
}

View file

@ -0,0 +1,47 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
/// The cards of a studyset to study at [now], in order (decided 2026-09-30).
///
/// First the cards due for review or in their learning steps, earliest first;
/// then new cards in the studyset's order ([cardOrder]), at most [newPerDay]
/// started per local day, the day that began at [startOfToday]. When nothing
/// else is due, a learning card due within [learnAhead] comes early.
List<String> studyQueue({
required List<String> cardOrder,
required Map<String, CardSchedule> schedules,
required DateTime now,
required DateTime startOfToday,
int newPerDay = 20,
Duration learnAhead = const Duration(minutes: 20),
}) {
DateTime? dueOf(String id) => schedules[id]?.due;
int byDue(String a, String b) => dueOf(a)!.compareTo(dueOf(b)!);
final due = [
for (final id in cardOrder)
if (dueOf(id) case final due? when !due.isAfter(now)) id,
]..sort(byDue);
final startedToday = schedules.values
.where(
(schedule) => schedule.firstReviewedAt?.isBefore(startOfToday) == false,
)
.length;
final fresh = [
for (final id in cardOrder)
if (schedules[id]?.isNew ?? true) id,
].take(math.max(0, newPerDay - startedToday));
final queue = [...due, ...fresh];
if (queue.isNotEmpty) {
return queue;
}
final soon = [
for (final id in cardOrder)
if (schedules[id] case final schedule?
when schedule.isLearning &&
!schedule.due!.isAfter(now.add(learnAhead)))
id,
]..sort(byDue);
return soon.take(1).toList();
}

View file

@ -0,0 +1,125 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:material_ui/material_ui.dart';
/// Studies a studyset's due cards: the front, then the answer and the four
/// ratings, each with when the card would come back.
class const StudyScreen({super.key}) extends StatelessWidget {
/// The route to studying [studysetId]. It is pushed from screens above the
/// person's scope, so the person's [cards] and [reviews] are handed over.
static Route<void> route(
CardRepository cards,
ReviewRepository reviews,
String studysetId,
) => MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) =>
StudySessionBloc(cards, reviews, studysetId)
..add(const StudySessionEventStarted()),
child: const StudyScreen(),
),
);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Study')),
body: BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) => switch (state) {
StudySessionStateLoading() => const Center(
child: CircularProgressIndicator(),
),
StudySessionStateDone(:final next) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Nothing to study right now.'),
if (next != null)
Text(
'Next card due in '
'${_describe(next.difference(clock.now()))}.',
),
],
),
),
final StudySessionStateShowing showing => _Showing(showing),
},
),
);
}
class const _Showing(final StudySessionStateShowing state)
extends StatelessWidget {
@override
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'${state.remaining} to study',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 8),
RichContentView(
key: ValueKey(('front', state.card.id, state.shownAt)),
content: state.card.content.front,
),
const SizedBox(height: 16),
if (!state.answerShown)
FilledButton(
onPressed: () => context.read<StudySessionBloc>().add(
const StudySessionEventAnswerShown(),
),
child: const Text('Show answer'),
)
else ...[
const Divider(),
RichContentView(
key: ValueKey(('back', state.card.id, state.shownAt)),
content: state.card.content.back,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final rating in Rating.values)
OutlinedButton(
onPressed: () => context.read<StudySessionBloc>().add(
StudySessionEventRated(rating),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(switch (rating) {
Rating.again => 'Again',
Rating.hard => 'Hard',
Rating.good => 'Good',
Rating.easy => 'Easy',
}),
if (state.nextDue[rating] case final due?)
Text(
_describe(due.difference(state.shownAt)),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
],
],
);
}
/// [interval] the short way: 1m, 10m, 5h, 3d, 2mo, 1y.
String _describe(Duration interval) => switch (interval) {
< const Duration(hours: 1) => '${interval.inMinutes.clamp(1, 59)}m',
< const Duration(days: 1) => '${interval.inHours}h',
< const Duration(days: 30) => '${interval.inDays}d',
< const Duration(days: 365) => '${interval.inDays ~/ 30}mo',
_ => '${interval.inDays ~/ 365}y',
};

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:material_ui/material_ui.dart';
@ -15,6 +16,9 @@ class const StudysetScreen({
/// The person's cards, for the card list and the screens it opens.
required final CardRepository cards,
/// The person's reviews, for studying the cards.
required final ReviewRepository reviews,
super.key,
}) extends StatelessWidget {
/// The route to [studyset]'s screen. Routes live above the person's scope,
@ -23,10 +27,15 @@ class const StudysetScreen({
final person = PersonScope.of(context);
final studysets = person.studysets;
final cards = person.cards;
final reviews = person.reviews;
return MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => StudysetEditBloc(studysets, cards, studyset),
child: StudysetScreen(studyset: studyset, cards: cards),
child: StudysetScreen(
studyset: studyset,
cards: cards,
reviews: reviews,
),
),
);
}
@ -89,7 +98,11 @@ class const StudysetScreen({
),
),
const SizedBox(height: 24),
CardList(cards: cards, studysetId: studyset.id),
CardList(
cards: cards,
reviews: reviews,
studysetId: studyset.id,
),
],
),
),

View file

@ -30,12 +30,16 @@ dependencies:
path: ../../appflowy-editor
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
clock: ^1.1.3
cupertino_icons: ^1.0.5
equatable: ^3.0.0
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
fractional_indexing_dart: ^1.0.7
# Pinned exactly: every device on one app version must compute the same
# schedules.
fsrs: 2.0.1
halkyon_learn_client:
path: ../halkyon_learn_client
material_ui: ^1.5.0

View file

@ -4,10 +4,12 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/test_dependencies.dart';
@ -37,6 +39,7 @@ void main() {
() async => PersonDependencies(
studysets: StudysetRepository(FakeStudysetGateway()),
cards: CardRepository(FakeCardGateway()),
reviews: ReviewRepository(FakeReviewGateway()),
),
);
@ -67,6 +70,7 @@ void main() {
return PersonDependencies(
studysets: StudysetRepository(FakeStudysetGateway()),
cards: CardRepository(FakeCardGateway()),
reviews: ReviewRepository(FakeReviewGateway()),
);
});
expect(find.text("Couldn't open this device's data."), findsOneWidget);
@ -85,6 +89,7 @@ void main() {
() async => PersonDependencies(
studysets: StudysetRepository(FakeStudysetGateway()),
cards: CardRepository(FakeCardGateway()),
reviews: ReviewRepository(FakeReviewGateway()),
onDispose: () async => closed = true,
),
);

View file

@ -4,9 +4,11 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
/// Rich content with one paragraph of [text].
RichContent paragraph(String text) => RichContent({
@ -47,7 +49,13 @@ void main() {
home: Scaffold(
body: ListView(
padding: const EdgeInsets.all(16),
children: [CardList(cards: cards, studysetId: 'japanese')],
children: [
CardList(
cards: cards,
reviews: ReviewRepository(FakeReviewGateway()),
studysetId: 'japanese',
),
],
),
),
),

View file

@ -0,0 +1,113 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
CardContent card(String front) => CardContent(
front: RichContent({
'document': {'type': 'page', 'note': front},
}),
back: RichContent.empty(),
);
void main() {
late CardRepository cards;
late FakeReviewGateway reviewGateway;
late ReviewRepository reviews;
late DateTime now;
setUp(() {
cards = CardRepository(FakeCardGateway());
reviewGateway = FakeReviewGateway();
reviews = ReviewRepository(reviewGateway);
now = DateTime.utc(2026, 10, 5, 9);
});
tearDown(() => cards.dispose());
TypeMatcher<StudySessionStateShowing> showing(
String front, {
bool answerShown = false,
}) => isA<StudySessionStateShowing>()
.having((s) => s.card.content, 'card', card(front))
.having((s) => s.answerShown, 'answer shown', answerShown);
test('shows the due cards one by one; a rating records how long answering '
'took', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = StudySessionBloc(cards, reviews, 'japanese')
..add(const StudySessionEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(showing('猫')));
now = now.add(const Duration(seconds: 7));
bloc.add(const StudySessionEventAnswerShown());
await expectLater(
bloc.stream,
emits(
showing('猫', answerShown: true).having(
(s) => s.nextDue.keys,
'next due for every rating',
Rating.values,
),
),
);
bloc.add(const StudySessionEventRated(Rating.easy));
await expectLater(bloc.stream, emits(showing('犬')));
final recorded = reviewGateway.rows.single;
expect(recorded.rating, Rating.easy.value);
expect(recorded.durationMs, 7000);
});
});
test('when nothing is left, says when the next card is due', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = StudySessionBloc(cards, reviews, 'japanese')
..add(const StudySessionEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(showing('猫')));
bloc
..add(const StudySessionEventAnswerShown())
..add(const StudySessionEventRated(Rating.easy));
await expectLater(
bloc.stream,
emitsThrough(
isA<StudySessionStateDone>().having(
(s) => s.next?.isAfter(now.add(const Duration(days: 1))),
'next due after a day',
isTrue,
),
),
);
});
});
test('a card rated again comes back within the session', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = StudySessionBloc(cards, reviews, 'japanese')
..add(const StudySessionEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(showing('猫')));
bloc
..add(const StudySessionEventAnswerShown())
..add(const StudySessionEventRated(Rating.again));
await expectLater(bloc.stream, emitsThrough(showing('猫')));
});
});
}

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