feat(app): study sessions in rounds, by card type, cramming and reversed

A session studies a round of up to the settings' cards per round. In
spaced repetition a card leaves the round once it is a day or more away,
one rated lower comes back when due; cramming walks every card once,
round after round, its reviews moving no schedule, with going back and
on. Asking the back first (a toggle in the header, saved in the
settings) asks front/back and vocabulary cards their back, on their own
schedule. Each type studies its way: a word shows its reading and
meanings, a multiple-choice card is picked and checked, a matching card
sorted by dragging or tapping and checked; then the person rates (Bad,
OK, Good, Perfect, each with when the card comes back). Progress shows
as pills, or a tally bar while cramming; a round ends with how it went.
The session settings gain the mode, cards per round, new cards per day
and the order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 19:29:21 +02:00
commit 46855ab251
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 2419 additions and 302 deletions

View file

@ -254,6 +254,55 @@
"cardTileUntitled": "Untitled",
"cardTileConflict": "Changed on two devices",
"studyClose": "Close session",
"studyBackFirstOn": "Showing the back of cards first: tap to revert",
"studyBackFirstOff": "Show the back of cards first",
"studyShowAnswer": "Show answer",
"studyCheck": "Check",
"studyRatingBad": "Bad",
"studyRatingOk": "OK",
"studyRatingGood": "Good",
"studyRatingPerfect": "Perfect",
"studyNothingTitle": "Nothing to study right now",
"studyNothingBody": "Come back later when more cards are due.",
"studyNextDue": "The next card is due in {interval}.",
"@studyNextDue": {"placeholders": {"interval": {"type": "String"}}},
"studyDone": "Done",
"studyRoundDone": "Round complete",
"studyContinue": "Continue learning",
"studyPrevious": "Previous card",
"studyNext": "Next card",
"studyReading": "Reading",
"studyMeanings": "Meanings",
"studyPlaceItems": "Drag each item onto its category, or tap an item and then a category.",
"studyMinutes": "{count} min",
"@studyMinutes": {"placeholders": {"count": {"type": "int"}}},
"studyHours": "{count} h",
"@studyHours": {"placeholders": {"count": {"type": "int"}}},
"studyDays": "{count, plural, =1{1 day} other{{count} days}}",
"@studyDays": {"placeholders": {"count": {"type": "int"}}},
"studyMonths": "{count, plural, =1{1 month} other{{count} months}}",
"@studyMonths": {"placeholders": {"count": {"type": "int"}}},
"studyYears": "{count, plural, =1{1 year} other{{count} years}}",
"@studyYears": {"placeholders": {"count": {"type": "int"}}},
"studyFilteredBy": "{count, plural, =1{Narrowed to 1 tag} other{Narrowed to {count} tags}}",
"@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.",
"studySettingsUnlimited": "Unlimited",
"studySettingsSortBy": "Sort by",
"studySettingsLearnMode": "Learn mode",
"studyModeSpaced": "Spaced repetition",
"studyModeSpacedHint": "Due cards on their schedules; ratings move them.",
"studyModeCram": "Cram",
"studyModeCramHint": "Walk every card without touching the schedules.",
"studySortOldestFirst": "Oldest first",
"studySortRandom": "Random",
"studySortNewestFirst": "Newest first",
"studyFewer": "Fewer",
"studyMore": "More",
"commonCancel": "Cancel",
"commonClose": "Close",
"commonUndo": "Undo",

View file

@ -1414,6 +1414,252 @@ abstract class AppLocalizations {
/// **'Changed on two devices'**
String get cardTileConflict;
/// No description provided for @studyClose.
///
/// In en, this message translates to:
/// **'Close session'**
String get studyClose;
/// No description provided for @studyBackFirstOn.
///
/// In en, this message translates to:
/// **'Showing the back of cards first: tap to revert'**
String get studyBackFirstOn;
/// No description provided for @studyBackFirstOff.
///
/// In en, this message translates to:
/// **'Show the back of cards first'**
String get studyBackFirstOff;
/// No description provided for @studyShowAnswer.
///
/// In en, this message translates to:
/// **'Show answer'**
String get studyShowAnswer;
/// No description provided for @studyCheck.
///
/// In en, this message translates to:
/// **'Check'**
String get studyCheck;
/// No description provided for @studyRatingBad.
///
/// In en, this message translates to:
/// **'Bad'**
String get studyRatingBad;
/// No description provided for @studyRatingOk.
///
/// In en, this message translates to:
/// **'OK'**
String get studyRatingOk;
/// No description provided for @studyRatingGood.
///
/// In en, this message translates to:
/// **'Good'**
String get studyRatingGood;
/// No description provided for @studyRatingPerfect.
///
/// In en, this message translates to:
/// **'Perfect'**
String get studyRatingPerfect;
/// No description provided for @studyNothingTitle.
///
/// In en, this message translates to:
/// **'Nothing to study right now'**
String get studyNothingTitle;
/// No description provided for @studyNothingBody.
///
/// In en, this message translates to:
/// **'Come back later when more cards are due.'**
String get studyNothingBody;
/// No description provided for @studyNextDue.
///
/// In en, this message translates to:
/// **'The next card is due in {interval}.'**
String studyNextDue(String interval);
/// No description provided for @studyDone.
///
/// In en, this message translates to:
/// **'Done'**
String get studyDone;
/// No description provided for @studyRoundDone.
///
/// In en, this message translates to:
/// **'Round complete'**
String get studyRoundDone;
/// No description provided for @studyContinue.
///
/// In en, this message translates to:
/// **'Continue learning'**
String get studyContinue;
/// No description provided for @studyPrevious.
///
/// In en, this message translates to:
/// **'Previous card'**
String get studyPrevious;
/// No description provided for @studyNext.
///
/// In en, this message translates to:
/// **'Next card'**
String get studyNext;
/// No description provided for @studyReading.
///
/// In en, this message translates to:
/// **'Reading'**
String get studyReading;
/// No description provided for @studyMeanings.
///
/// In en, this message translates to:
/// **'Meanings'**
String get studyMeanings;
/// No description provided for @studyPlaceItems.
///
/// In en, this message translates to:
/// **'Drag each item onto its category, or tap an item and then a category.'**
String get studyPlaceItems;
/// No description provided for @studyMinutes.
///
/// In en, this message translates to:
/// **'{count} min'**
String studyMinutes(int count);
/// No description provided for @studyHours.
///
/// In en, this message translates to:
/// **'{count} h'**
String studyHours(int count);
/// No description provided for @studyDays.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 day} other{{count} days}}'**
String studyDays(int count);
/// No description provided for @studyMonths.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 month} other{{count} months}}'**
String studyMonths(int count);
/// No description provided for @studyYears.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 year} other{{count} years}}'**
String studyYears(int count);
/// No description provided for @studyFilteredBy.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Narrowed to 1 tag} other{Narrowed to {count} tags}}'**
String studyFilteredBy(int count);
/// No description provided for @studySettingsCardsPerRound.
///
/// In en, this message translates to:
/// **'Cards per round'**
String get studySettingsCardsPerRound;
/// No description provided for @studySettingsNewPerDay.
///
/// In en, this message translates to:
/// **'New cards per day'**
String get studySettingsNewPerDay;
/// 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.'**
String get studySettingsNewPerDayHint;
/// No description provided for @studySettingsUnlimited.
///
/// In en, this message translates to:
/// **'Unlimited'**
String get studySettingsUnlimited;
/// No description provided for @studySettingsSortBy.
///
/// In en, this message translates to:
/// **'Sort by'**
String get studySettingsSortBy;
/// No description provided for @studySettingsLearnMode.
///
/// In en, this message translates to:
/// **'Learn mode'**
String get studySettingsLearnMode;
/// No description provided for @studyModeSpaced.
///
/// In en, this message translates to:
/// **'Spaced repetition'**
String get studyModeSpaced;
/// No description provided for @studyModeSpacedHint.
///
/// In en, this message translates to:
/// **'Due cards on their schedules; ratings move them.'**
String get studyModeSpacedHint;
/// No description provided for @studyModeCram.
///
/// In en, this message translates to:
/// **'Cram'**
String get studyModeCram;
/// No description provided for @studyModeCramHint.
///
/// In en, this message translates to:
/// **'Walk every card without touching the schedules.'**
String get studyModeCramHint;
/// No description provided for @studySortOldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get studySortOldestFirst;
/// No description provided for @studySortRandom.
///
/// In en, this message translates to:
/// **'Random'**
String get studySortRandom;
/// No description provided for @studySortNewestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get studySortNewestFirst;
/// No description provided for @studyFewer.
///
/// In en, this message translates to:
/// **'Fewer'**
String get studyFewer;
/// No description provided for @studyMore.
///
/// In en, this message translates to:
/// **'More'**
String get studyMore;
/// No description provided for @commonCancel.
///
/// In en, this message translates to:

View file

@ -776,6 +776,172 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardTileConflict => 'Changed on two devices';
@override
String get studyClose => 'Close session';
@override
String get studyBackFirstOn =>
'Showing the back of cards first: tap to revert';
@override
String get studyBackFirstOff => 'Show the back of cards first';
@override
String get studyShowAnswer => 'Show answer';
@override
String get studyCheck => 'Check';
@override
String get studyRatingBad => 'Bad';
@override
String get studyRatingOk => 'OK';
@override
String get studyRatingGood => 'Good';
@override
String get studyRatingPerfect => 'Perfect';
@override
String get studyNothingTitle => 'Nothing to study right now';
@override
String get studyNothingBody => 'Come back later when more cards are due.';
@override
String studyNextDue(String interval) {
return 'The next card is due in $interval.';
}
@override
String get studyDone => 'Done';
@override
String get studyRoundDone => 'Round complete';
@override
String get studyContinue => 'Continue learning';
@override
String get studyPrevious => 'Previous card';
@override
String get studyNext => 'Next card';
@override
String get studyReading => 'Reading';
@override
String get studyMeanings => 'Meanings';
@override
String get studyPlaceItems =>
'Drag each item onto its category, or tap an item and then a category.';
@override
String studyMinutes(int count) {
return '$count min';
}
@override
String studyHours(int count) {
return '$count h';
}
@override
String studyDays(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count days',
one: '1 day',
);
return '$_temp0';
}
@override
String studyMonths(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count months',
one: '1 month',
);
return '$_temp0';
}
@override
String studyYears(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count years',
one: '1 year',
);
return '$_temp0';
}
@override
String studyFilteredBy(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Narrowed to $count tags',
one: 'Narrowed to 1 tag',
);
return '$_temp0';
}
@override
String get studySettingsCardsPerRound => 'Cards per round';
@override
String get studySettingsNewPerDay => 'New cards per day';
@override
String get studySettingsNewPerDayHint =>
'How many never-studied cards each studyset starts a day. Due reviews are never limited.';
@override
String get studySettingsUnlimited => 'Unlimited';
@override
String get studySettingsSortBy => 'Sort by';
@override
String get studySettingsLearnMode => 'Learn mode';
@override
String get studyModeSpaced => 'Spaced repetition';
@override
String get studyModeSpacedHint =>
'Due cards on their schedules; ratings move them.';
@override
String get studyModeCram => 'Cram';
@override
String get studyModeCramHint =>
'Walk every card without touching the schedules.';
@override
String get studySortOldestFirst => 'Oldest first';
@override
String get studySortRandom => 'Random';
@override
String get studySortNewestFirst => 'Newest first';
@override
String get studyFewer => 'Fewer';
@override
String get studyMore => 'More';
@override
String get commonCancel => 'Cancel';

View file

@ -87,16 +87,24 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
builder: (context, state) {
final person = PersonScope.of(context);
final studysetId = state.pathParameters['studysetId']!;
return BlocProvider(
create: (context) => StudySessionBloc(
person.studysets,
person.cards,
person.reviews,
person.tags,
person.settings,
studysetId,
)..add(const StudySessionEventStarted()),
child: StudyScreen(studysetId: studysetId),
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
create: (context) => StudySessionBloc(
person.studysets,
person.cards,
person.reviews,
person.tags,
person.settings,
studysetId,
)..add(const StudySessionEventStarted()),
child: StudyScreen(
studysetId: studysetId,
title: studyset.content.title,
),
),
);
},
),

View file

@ -20,6 +20,15 @@ final class const Card({
/// version, oldest first.
final Map<CardField, List<Object?>> conflicts = const {},
}) with Equatable {
/// This card showing [content] instead, e.g. to study it another way.
Card withContent(CardContent content) => Card(
id: id,
position: position,
content: content,
heads: heads,
conflicts: conflicts,
);
/// Whether devices changed a field differently and the person has to
/// choose.
bool get hasConflict => conflicts.isNotEmpty;

View file

@ -1,12 +1,17 @@
import 'dart:math' as math;
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/card/model/card_content.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/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_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.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';
@ -14,38 +19,281 @@ import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// A study session over a studyset with everything inside it, but for
/// archived studysets (`studiedIn`), and of those cards the ones with any of
/// the tags the person's settings choose for the studyset: 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, and when the settings changed.
/// the tags the person's settings choose for the studyset; in rounds of the
/// settings' cards per round.
///
/// Spaced repetition takes a round from the study queue, in the order the
/// settings ask for, and shows its cards until each is a day or more away:
/// a card rated lower comes back when due in this round. Cramming walks
/// every card once, round after round, its ratings moving no schedule.
/// Cards that ask their back (settings) use their schedule back to front.
class StudySessionBloc(
final StudysetRepository _studysets,
final CardRepository _cards,
final ReviewRepository _reviews,
final TagRepository _tags,
final SettingsRepository _settings,
final String _studysetId,
) extends Bloc<StudySessionEvent, StudySessionState> {
final String _studysetId, {
/// Shuffles a random order; given for tests.
math.Random? random,
}) extends Bloc<StudySessionEvent, StudySessionState> {
this : super(const StudySessionStateLoading()) {
on<StudySessionEventStarted>((event, emit) => _showNext(emit));
on<StudySessionEventSettingsChanged>((event, emit) => _showNext(emit));
on<StudySessionEventStarted>((event, emit) => _startRound(emit));
on<StudySessionEventSettingsChanged>((event, emit) => _startRound(emit));
on<StudySessionEventContinued>((event, emit) => _startRound(emit));
on<StudySessionEventAnswerShown>(_onAnswerShown);
on<StudySessionEventRated>(_onRated);
on<StudySessionEventMoved>(_onMoved);
on<StudySessionEventBackFirstToggled>((event, emit) async {
await _settings.update(
(settings) => settings.withStudy(
settings.study.copyWith(backFirst: !settings.study.backFirst),
),
);
await _startRound(emit);
});
}
final math.Random _random = random ?? math.Random();
// The round under way.
var _options = const StudyOptions();
var _round = <String>[];
var _cardsById = <String, Card>{};
final _ratings = <String, Rating>{};
final _graduated = <String>{};
var _cramIndex = 0;
var _tagFilterCount = 0;
/// The cards crammed in earlier rounds of this session.
final _crammed = <String>{};
Future<void> _startRound(Emitter<StudySessionState> emit) async {
emit(const StudySessionStateLoading());
_options = (await _settings.read()).study;
_ratings.clear();
_graduated.clear();
_cramIndex = 0;
final groups = await _groups();
_cardsById = {
for (final group in groups)
for (final card in group) card.id: card,
};
final now = clock.now().toUtc();
if (_options.mode == LearnMode.cram) {
final all = [
for (final group in groups)
for (final card in group)
if (!_crammed.contains(card.id)) card.id,
];
_round = _ordered(all).take(_options.cardsPerRound).toList();
if (_round.isEmpty) {
emit(const StudySessionStateNothing());
return;
}
_crammed.addAll(_round);
emit(_showing(_round.first, now, schedule: null));
return;
}
final schedules = await _schedules([..._cardsById.keys]);
final queue = studyQueue(
groups: [
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,
);
_round = _ordered(queue).take(_options.cardsPerRound).toList();
if (_round.isEmpty) {
final upcoming = [
for (final schedule in schedules.values) ?schedule.due,
]..sort();
emit(StudySessionStateNothing(next: upcoming.firstOrNull));
return;
}
await _showNext(emit, now);
}
/// The cards of every studyset studied, each studyset's in order, with
/// the tags chosen for the studyset applied.
Future<List<List<Card>>> _groups() async {
final tagged = await _cardsWithChosenTags();
return [
for (final studysetId in studiedIn(_studysetId, await _studysets.all()))
[
for (final card in await _cards.cardsOf(studysetId))
if (tagged == null || tagged.contains(card.id)) card,
],
];
}
/// [ids] in the order the settings ask for.
List<String> _ordered(List<String> ids) => switch (_options.effectiveOrder) {
StudyOrder.oldestFirst => ids,
StudyOrder.newestFirst => ids.reversed.toList(),
StudyOrder.random => [...ids]..shuffle(_random),
};
/// The ids of the cards with any of the tags chosen for the studyset, or
/// null to study every card: when none are chosen, or none of those chosen
/// exists any more.
Future<Set<String>?> _cardsWithChosenTags() async {
final chosen = (await _settings.read()).studyTagsOf(_studysetId);
_tagFilterCount = 0;
if (chosen.isEmpty) {
return null;
}
final tagging = await _tags.read();
final ids = tagging.idsOf(chosen);
_tagFilterCount = ids.length;
if (ids.isEmpty) {
return null;
}
return {for (final id in ids) ...tagging.cardsWith(id)};
}
/// Whether [card] asks its back in this session.
bool _asksBack(Card card) =>
_options.backFirst &&
(card.content.type == CardType.frontBack ||
card.content.type == CardType.vocabulary);
/// How [card] is studied in this session.
StudyMode _modeOf(Card card) => _options.mode == LearnMode.cram
? StudyMode.cram
: _asksBack(card)
? StudyMode.reverse
: StudyMode.review;
/// The schedules of [ids], each in the direction its card is studied.
Future<Map<String, CardSchedule>> _schedules(List<String> ids) async {
final forward = await _reviews.schedulesOf(ids);
if (!_options.backFirst) {
return forward;
}
final backward = await _reviews.schedulesOf(ids, mode: StudyMode.reverse);
return {
for (final id in ids)
id: _asksBack(_cardsById[id]!) ? backward[id]! : forward[id]!,
};
}
/// Shows the round's next card: the first not yet seen or due again, in
/// round order; when none is, a card in its learning steps due soon; when
/// none is, the round is done.
Future<void> _showNext(Emitter<StudySessionState> emit, DateTime now) async {
final remaining = [
for (final id in _round)
if (!_graduated.contains(id)) id,
];
final schedules = await _schedules(remaining);
String? next;
for (final id in remaining) {
final due = schedules[id]!.due;
if (due == null || !due.isAfter(now)) {
next = id;
break;
}
}
if (next == null) {
final soon = [
for (final id in remaining)
if (schedules[id]!.due!.isBefore(now.add(_learnAhead))) id,
]..sort((a, b) => schedules[a]!.due!.compareTo(schedules[b]!.due!));
next = soon.firstOrNull;
}
if (next == null) {
emit(await _roundDone());
return;
}
emit(_showing(next, now, schedule: schedules[next]));
}
/// How far ahead a card in its learning steps comes early, once nothing
/// else is due in the round.
static const _learnAhead = Duration(minutes: 20);
StudySessionStateShowing _showing(
String id,
DateTime now, {
required CardSchedule? schedule,
}) {
final card = _cardsById[id]!;
return StudySessionStateShowing(
card: card,
asksBack: _asksBack(card),
schedule: schedule,
shownAt: now,
cram: _options.mode == LearnMode.cram,
pills: _pills(id),
cramTally: _tally(),
cramUnrated: _round.length - _ratings.length,
canGoBack: _options.mode == LearnMode.cram && _cramIndex > 0,
canGoForward:
_options.mode == LearnMode.cram && _cramIndex < _round.length - 1,
tagFilterCount: _tagFilterCount,
backFirst: _options.backFirst,
);
}
/// The round's cards as its progress shows them, in round order.
List<RoundPill> _pills(String active) => [
for (final id in _round)
(
state: id == active
? PillState.active
: _graduated.contains(id)
? PillState.done
: _ratings.containsKey(id)
? PillState.again
: PillState.pending,
rating: _ratings[id],
),
];
Map<Rating, int> _tally() => {
for (final rating in Rating.values)
rating: _ratings.values.where((each) => each == rating).length,
};
Future<StudySessionStateRoundDone> _roundDone() async {
final bool more;
if (_options.mode == LearnMode.cram) {
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;
}
return StudySessionStateRoundDone(tally: _tally(), more: more);
}
void _onAnswerShown(
StudySessionEventAnswerShown event,
Emitter<StudySessionState> emit,
) {
if (state case final StudySessionStateShowing showing) {
if (state case final StudySessionStateShowing showing
when !showing.answerShown) {
emit(
showing.withAnswer(
nextDueDates(
showing.withAnswer(switch (showing.schedule) {
final schedule? => nextDueDates(
showing.card.id,
showing.schedule,
schedule,
clock.now().toUtc(),
),
),
null => const {},
}),
);
}
}
@ -56,71 +304,50 @@ class StudySessionBloc(
) async {
if (state case final StudySessionStateShowing showing
when showing.answerShown) {
final card = showing.card;
final now = clock.now().toUtc();
await _reviews.record(
cardId: showing.card.id,
cardId: card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
mode: _modeOf(card),
);
await _showNext(emit);
_ratings[card.id] = event.rating;
if (_options.mode == LearnMode.cram) {
// The next card not rated yet, after this one and then from the
// start; the round is done once every card has a rating.
final next = [
for (var i = 1; i <= _round.length; i++)
(_cramIndex + i) % _round.length,
].where((i) => !_ratings.containsKey(_round[i])).firstOrNull;
if (next == null) {
emit(await _roundDone());
return;
}
_cramIndex = next;
emit(_showing(_round[next], now, schedule: null));
return;
}
final schedule = (await _schedules([card.id]))[card.id]!;
// A day or more away, the card is done for this round.
if (!schedule.due!.isBefore(now.add(const Duration(days: 1)))) {
_graduated.add(card.id);
}
await _showNext(emit, now);
}
}
Future<void> _showNext(Emitter<StudySessionState> emit) async {
final tagged = await _cardsWithChosenTags();
final groups = <List<String>>[];
final cards = <String, Card>{};
for (final studysetId in studiedIn(_studysetId, await _studysets.all())) {
final studysetCards = [
for (final card in await _cards.cardsOf(studysetId))
if (tagged == null || tagged.contains(card.id)) card,
];
groups.add([for (final card in studysetCards) card.id]);
for (final card in studysetCards) {
cards[card.id] = card;
}
}
final schedules = await _reviews.schedulesOf([...cards.keys]);
final now = clock.now().toUtc();
final queue = studyQueue(
groups: groups,
schedules: schedules,
now: now,
startOfToday: startOfLocalDay(now),
);
if (queue.isEmpty) {
final upcoming = [
for (final schedule in schedules.values) ?schedule.due,
]..sort();
emit(StudySessionStateDone(next: upcoming.firstOrNull));
void _onMoved(StudySessionEventMoved event, Emitter<StudySessionState> emit) {
if (_options.mode != LearnMode.cram || state is! StudySessionStateShowing) {
return;
}
final card = cards[queue.first]!;
emit(
StudySessionStateShowing(
card: card,
schedule: schedules[card.id]!,
shownAt: now,
remaining: queue.length,
),
);
}
/// The ids of the cards with any of the tags chosen for the studyset, or
/// null to study every card: when none are chosen, or none of those chosen
/// exists any more.
Future<Set<String>?> _cardsWithChosenTags() async {
final chosen = (await _settings.read()).studyTagsOf(_studysetId);
if (chosen.isEmpty) {
return null;
final index = _cramIndex + event.by;
if (index < 0 || index >= _round.length) {
return;
}
final tagging = await _tags.read();
final ids = tagging.idsOf(chosen);
if (ids.isEmpty) {
return null;
}
return {for (final id in ids) ...tagging.cardsWith(id)};
_cramIndex = index;
emit(_showing(_round[index], clock.now().toUtc(), schedule: null));
}
}
@ -133,10 +360,14 @@ sealed class const StudySessionEvent() with Equatable {
/// The session begins.
final class const StudySessionEventStarted() extends StudySessionEvent;
/// The person's settings changed: work out the queue anew.
/// The person's settings changed: a new round with them.
final class const StudySessionEventSettingsChanged() extends StudySessionEvent;
/// Show the answer of the card on screen.
/// The round is done and the person goes on: the next round.
final class const StudySessionEventContinued() extends StudySessionEvent;
/// Show the answer of the card on screen; for a question to choose or sort,
/// whether it was right.
final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// The person rated the card on screen.
@ -148,6 +379,36 @@ final class const StudySessionEventRated(
List<Object?> get props => [rating];
}
/// Ask the back of cards first, or the front again: a new round so.
final class const StudySessionEventBackFirstToggled() extends StudySessionEvent;
/// Cramming, go [by] cards on (back when negative), to skip or revisit.
final class const StudySessionEventMoved(
/// How many cards on.
final int by,
) extends StudySessionEvent {
@override
List<Object?> get props => [by];
}
/// How a card of the round stands, as its pill shows it.
enum PillState() {
/// Not shown yet.
pending,
/// On screen.
active,
/// Rated and coming back in this round.
again,
/// Done for this round.
done,
}
/// A card of the round in its pill: how it stands, and its last rating.
typedef RoundPill = ({PillState state, Rating? rating});
/// A study session, as the study screen shows it.
sealed class const StudySessionState() with Equatable {
@override
@ -157,34 +418,75 @@ sealed class const StudySessionState() with Equatable {
/// Working out what to study.
final class const StudySessionStateLoading() extends StudySessionState;
/// Nothing to study now.
final class const StudySessionStateNothing({
/// When the next card is due; null without any studied card.
final DateTime? next,
}) extends StudySessionState {
@override
List<Object?> get props => [next];
}
/// A card on screen.
final class const StudySessionStateShowing({
/// The card.
required final Card card,
/// Its schedule so far.
required final CardSchedule schedule,
/// Whether it asks its back, to recall the front.
required final bool asksBack,
/// Its schedule in the direction studied; null while cramming.
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 session crams.
required final bool cram,
/// The round's cards, in round order.
required final List<RoundPill> pills,
/// Cramming, how many cards of the round got each rating.
required final Map<Rating, int> cramTally,
/// Cramming, how many cards of the round have no rating yet.
required final int cramUnrated,
/// Cramming, whether there is a card before this one.
required final bool canGoBack,
/// Cramming, whether there is a card after this one.
required final bool canGoForward,
/// How many chosen tags narrow the session.
required final int tagFilterCount,
/// Whether cards ask their back first in this session.
required final bool backFirst,
/// Whether the answer shows.
final bool answerShown = false,
/// When the card would be due after each rating; filled once the answer
/// shows.
/// shows, empty while cramming.
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,
asksBack: asksBack,
schedule: schedule,
shownAt: shownAt,
remaining: remaining,
cram: cram,
pills: pills,
cramTally: cramTally,
cramUnrated: cramUnrated,
canGoBack: canGoBack,
canGoForward: canGoForward,
tagFilterCount: tagFilterCount,
backFirst: backFirst,
answerShown: true,
nextDue: nextDue,
);
@ -192,19 +494,30 @@ final class const StudySessionStateShowing({
@override
List<Object?> get props => [
card,
asksBack,
schedule,
shownAt,
remaining,
cram,
pills,
cramTally,
cramUnrated,
canGoBack,
canGoForward,
tagFilterCount,
backFirst,
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,
/// The round is done.
final class const StudySessionStateRoundDone({
/// How many cards got each rating, last ratings counted.
required final Map<Rating, int> tally,
/// Whether another round has cards.
required final bool more,
}) extends StudySessionState {
@override
List<Object?> get props => [next];
List<Object?> get props => [tally, more];
}

View file

@ -2,14 +2,16 @@ 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/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/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';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// How a studyset is studied: the tags its study session narrows to, among
/// those its cards have (with everything inside it, as studied). Changes are
/// kept until saved, into the person's settings.
/// How a studyset is studied: the person's study options, and the tags its
/// study session narrows to, among those its cards have (with everything
/// inside it, as studied). Changes are kept until saved, into the person's
/// settings.
class StudySettingsBloc(
final StudysetRepository _studysets,
final CardRepository _cards,
@ -28,6 +30,11 @@ class StudySettingsBloc(
emit(loaded.copyWith(chosen: chosen));
}
});
on<StudySettingsEventOptionsChanged>((event, emit) {
if (state case final StudySettingsStateLoaded loaded) {
emit(loaded.copyWith(options: event.options));
}
});
on<StudySettingsEventResetRequested>((event, emit) {
if (state case final StudySettingsStateLoaded loaded) {
emit(loaded.copyWith(chosen: const {}));
@ -48,9 +55,8 @@ class StudySettingsBloc(
for (final card in await _cards.cardsOf(studysetId))
for (final tag in tagging.of(card.id)) tag.id,
};
final chosen = tagging.idsOf(
(await _settings.read()).studyTagsOf(_studysetId),
);
final settings = await _settings.read();
final chosen = tagging.idsOf(settings.studyTagsOf(_studysetId));
emit(
StudySettingsStateLoaded(
// A chosen tag no card has any more stays, to be taken away.
@ -60,6 +66,8 @@ class StudySettingsBloc(
],
chosen: chosen,
saved: chosen,
options: settings.study,
savedOptions: settings.study,
),
);
} on Object {
@ -73,10 +81,12 @@ class StudySettingsBloc(
) async {
if (state case final StudySettingsStateLoaded loaded) {
await _settings.update(
(settings) => settings.withStudyTags(_studysetId, [
for (final tag in loaded.tags)
if (loaded.chosen.contains(tag.id)) tag.ref,
]),
(settings) => settings
.withStudyTags(_studysetId, [
for (final tag in loaded.tags)
if (loaded.chosen.contains(tag.id)) tag.ref,
])
.withStudy(loaded.options),
);
emit(loaded.copyWith(isDone: true));
}
@ -101,6 +111,15 @@ final class const StudySettingsEventTagToggled(
List<Object?> get props => [tagId];
}
/// Study with [options].
final class const StudySettingsEventOptionsChanged(
/// The options chosen.
final StudyOptions options,
) extends StudySettingsEvent {
@override
List<Object?> get props => [options];
}
/// Choose no tags: study every card.
final class const StudySettingsEventResetRequested() extends StudySettingsEvent;
@ -130,22 +149,42 @@ final class const StudySettingsStateLoaded({
/// The ids of the tags chosen as stored.
required final Set<String> saved,
/// The study options chosen.
final StudyOptions options = const StudyOptions(),
/// The study options as stored.
final StudyOptions savedOptions = const StudyOptions(),
/// Saved; the sheet closes then.
final bool isDone = false,
}) extends StudySettingsState {
/// Whether saving would change something.
bool get canSave =>
chosen.length != saved.length || !chosen.containsAll(saved);
chosen.length != saved.length ||
!chosen.containsAll(saved) ||
options != savedOptions;
/// This state with some fields replaced.
StudySettingsStateLoaded copyWith({Set<String>? chosen, bool? isDone}) =>
StudySettingsStateLoaded(
tags: tags,
chosen: chosen ?? this.chosen,
saved: saved,
isDone: isDone ?? this.isDone,
);
StudySettingsStateLoaded copyWith({
Set<String>? chosen,
StudyOptions? options,
bool? isDone,
}) => StudySettingsStateLoaded(
tags: tags,
chosen: chosen ?? this.chosen,
saved: saved,
options: options ?? this.options,
savedOptions: savedOptions,
isDone: isDone ?? this.isDone,
);
@override
List<Object?> get props => [tags, chosen, saved, isDone];
List<Object?> get props => [
tags,
chosen,
saved,
options,
savedOptions,
isDone,
];
}

View file

@ -0,0 +1,70 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:material_ui/material_ui.dart';
/// The four ratings, each in its colour and, in spaced repetition, with when
/// the card would come back.
class const RatingRow({
/// When the card would be due after each rating; none while cramming.
required final Map<Rating, DateTime> nextDue,
/// When the card appeared, from which the waits count.
required final DateTime shownAt,
/// Called with the rating chosen.
required final ValueChanged<Rating> onRated,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Row(
spacing: 8,
children: [
for (final rating in Rating.values)
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: ratingColor(context, rating),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () => onRated(rating),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(ratingLabel(l10n, rating)),
if (nextDue[rating] case final due?)
Text(
waitLabel(l10n, due.difference(shownAt)),
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.white,
),
),
],
),
),
),
],
);
}
}
/// [rating]'s name as the person sees it.
String ratingLabel(AppLocalizations l10n, Rating rating) => switch (rating) {
Rating.again => l10n.studyRatingBad,
Rating.hard => l10n.studyRatingOk,
Rating.good => l10n.studyRatingGood,
Rating.easy => l10n.studyRatingPerfect,
};
/// [wait] the short way: minutes, hours, days, months or years.
String waitLabel(AppLocalizations l10n, Duration wait) => switch (wait) {
< const Duration(hours: 1) => l10n.studyMinutes(wait.inMinutes.clamp(1, 59)),
< const Duration(days: 1) => l10n.studyHours(wait.inHours),
< const Duration(days: 30) => l10n.studyDays(wait.inDays),
< const Duration(days: 365) => l10n.studyMonths(wait.inDays ~/ 30),
_ => l10n.studyYears(wait.inDays ~/ 365),
};

View file

@ -0,0 +1,403 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// A front/back card: the prompt, and once shown, a line and the answer;
/// the other way round when it [asksBack].
class const FrontBackBody({
required final Card card,
required final bool asksBack,
required final bool answerShown,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final content = card.content;
final (prompt, answer) = asksBack
? (content.back, content.front)
: (content.front, content.back);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RichContentView(content: prompt),
if (answerShown) ...[
const Divider(height: 32),
RichContentView(content: answer),
],
],
);
}
}
/// A word: its expression, and once shown its reading and meanings; asking
/// its back, its meanings first.
class const VocabularyBody({
required final Card card,
required final bool asksBack,
required final bool answerShown,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = card.content;
final expression = DefaultTextStyle.merge(
style: theme.textTheme.headlineMedium,
child: RichContentView(content: content.front),
);
final reading = content.reading.trim().isEmpty
? null
: _Field(label: l10n.studyReading, text: content.reading.trim());
final meanings = _Field(
label: l10n.studyMeanings,
text: [
for (final (i, meaning)
in content.meanings
.where((meaning) => meaning.trim().isNotEmpty)
.indexed)
'${i + 1}. ${meaning.trim()}',
].join('\n'),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: asksBack
? [
meanings,
if (answerShown) ...[
const Divider(height: 32),
expression,
?reading,
],
]
: [
expression,
if (answerShown) ...[
const Divider(height: 32),
?reading,
meanings,
],
],
);
}
}
class const _Field({required final String label, required final String text})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(text, style: theme.textTheme.titleMedium),
],
),
);
}
}
/// A multiple-choice card: the question, its answers shuffled, picked by
/// tapping (several when several are right), and Check; once checked, the
/// right answers green and wrong picks red. The person rates themselves.
class const MultipleChoiceBody({
required final Card card,
required final bool checked,
required final VoidCallback onCheck,
super.key,
}) extends StatefulWidget {
@override
State<MultipleChoiceBody> createState() => _MultipleChoiceBodyState();
}
class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
late final List<int> _order = [
for (var i = 0; i < widget.card.content.answers.length; i++) i,
]..shuffle(math.Random());
final _picked = <int>{};
bool get _several =>
widget.card.content.answers.where((answer) => answer.correct).length > 1;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final answers = widget.card.content.answers;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RichContentView(content: widget.card.content.front),
const SizedBox(height: 20),
for (final i in _order)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _Option(
answer: answers[i],
picked: _picked.contains(i),
checked: widget.checked,
onTap: widget.checked
? null
: () => setState(() {
if (!_picked.remove(i)) {
if (!_several) {
_picked.clear();
}
_picked.add(i);
}
}),
),
),
if (!widget.checked) ...[
const SizedBox(height: 12),
FilledButton(
onPressed: _picked.isEmpty ? null : widget.onCheck,
child: Text(l10n.studyCheck),
),
],
],
);
}
}
class const _Option({
required final CardAnswer answer,
required final bool picked,
required final bool checked,
required final VoidCallback? onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final right = theme.extras.success;
final (Color? accent, IconData? icon) = switch ((checked, picked)) {
(true, _) when answer.correct => (right, LucideIcons.circleCheck),
(true, true) => (colors.error, LucideIcons.circleX),
(false, true) => (colors.primary, null),
_ => (null, null),
};
return Material(
color: accent?.withValues(alpha: 0.1) ?? colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: accent ?? colors.outlineVariant,
width: accent == null ? 1 : 2,
),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// The text lets taps through to the option: the editor
// showing it would take them for selecting text.
Expanded(
child: IgnorePointer(
child: RichContentView(content: answer.content),
),
),
if (icon != null) Icon(icon, color: accent),
],
),
),
),
);
}
}
/// A matching card: what to sort, the items to place and the categories to
/// place them in, by dragging or by tapping an item and then a category;
/// Check once all are placed, then each placement right or wrong.
class const MatchingBody({
required final Card card,
required final bool checked,
required final VoidCallback onCheck,
super.key,
}) extends StatefulWidget {
@override
State<MatchingBody> createState() => _MatchingBodyState();
}
class _MatchingBodyState() extends State<MatchingBody> {
late final List<int> _order = [
for (var i = 0; i < widget.card.content.matching.items.length; i++) i,
]..shuffle(math.Random());
/// Where each placed item went, by item index.
final _placed = <int, int>{};
/// The item tapped, to place by tapping a category.
int? _held;
void _place(int item, int target) => setState(() {
_placed[item] = target;
_held = null;
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final matching = widget.card.content.matching;
final loose = [
for (final i in _order)
if (!_placed.containsKey(i)) i,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.card.content.front.plainText.trim().isNotEmpty) ...[
RichContentView(content: widget.card.content.front),
const SizedBox(height: 12),
],
if (!widget.checked)
Text(
l10n.studyPlaceItems,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final i in loose)
_Item(
text: matching.items[i].text,
index: i,
held: _held == i,
onTap: () => setState(() => _held = _held == i ? null : i),
),
],
),
const SizedBox(height: 16),
for (final (t, target) in matching.targets.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DragTarget<int>(
onAcceptWithDetails: (details) => _place(details.data, t),
builder: (context, candidates, _) => InkWell(
onTap: _held == null || widget.checked
? null
: () => _place(_held!, t),
borderRadius: BorderRadius.circular(12),
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: candidates.isNotEmpty || _held != null
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
width: candidates.isNotEmpty ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(target, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final MapEntry(key: item, value: placedIn)
in _placed.entries)
if (placedIn == t)
_Item(
text: matching.items[item].text,
index: item,
right: widget.checked
? matching.items[item].target == t
: null,
// Back to the loose items, to place anew.
onTap: widget.checked
? null
: () => setState(
() => _placed.remove(item),
),
),
],
),
],
),
),
),
),
),
if (!widget.checked) ...[
const SizedBox(height: 12),
FilledButton(
onPressed: loose.isEmpty ? widget.onCheck : null,
child: Text(l10n.studyCheck),
),
],
],
);
}
}
/// An item to sort: draggable, and tappable to hold or to take back; once
/// checked, green or red.
class const _Item({
required final String text,
required final int index,
required final VoidCallback? onTap,
final bool held = false,
final bool? right,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final accent = switch (right) {
true => theme.extras.success,
false => colors.error,
null => held ? colors.primary : null,
};
final chip = Material(
color: accent?.withValues(alpha: 0.12) ?? colors.surfaceContainerHigh,
shape: StadiumBorder(
side: BorderSide(color: accent ?? colors.outlineVariant),
),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Text(text),
),
),
);
if (right != null) {
return chip;
}
return Draggable<int>(
data: index,
feedback: Material(color: Colors.transparent, child: chip),
childWhenDragging: Opacity(opacity: 0.3, child: chip),
child: chip,
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:material_ui/material_ui.dart';
/// The colour of [rating], as buttons and progress show it.
Color ratingColor(BuildContext context, Rating rating) {
final extras = Theme.of(context).extras;
return switch (rating) {
Rating.again => extras.fsrsAgain,
Rating.hard => extras.fsrsHard,
Rating.good => extras.fsrsGood,
Rating.easy => extras.fsrsEasy,
};
}
/// The round's progress in spaced repetition: a pill per card, in round
/// order. Done cards are filled with their rating's colour, cards coming
/// back in a muted one, the card on screen outlined, the rest grey.
class const PillProgress({required final List<RoundPill> pills, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Row(
spacing: 4,
children: [
for (final pill in pills)
Expanded(
child: Container(
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: switch (pill) {
(state: PillState.done, rating: final rating?) => ratingColor(
context,
rating,
),
(state: PillState.again, rating: final rating?) =>
Color.alphaBlend(
ratingColor(context, rating).withValues(alpha: 0.4),
colors.surface,
),
(state: PillState.active, rating: _) => Colors.transparent,
_ => colors.outline,
},
border: switch (pill.state) {
PillState.active => Border.all(
color: colors.onSurface,
width: 2,
),
PillState.done => Border.all(color: colors.onSurfaceVariant),
_ => null,
},
),
),
),
],
);
}
}
/// The round's progress while cramming: one bar split by how many cards got
/// each rating, the cards still to rate grey at its end.
class const CramProgress({
required final Map<Rating, int> tally,
required final int unrated,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: 8,
child: Row(
children: [
for (final rating in Rating.values)
if ((tally[rating] ?? 0) > 0)
Expanded(
flex: tally[rating]!,
child: ColoredBox(color: ratingColor(context, rating)),
),
if (unrated > 0)
Expanded(
flex: unrated,
child: ColoredBox(color: colors.outlineVariant),
),
],
),
),
);
}
}

View file

@ -1,130 +1,370 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.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/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.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. Its settings (the tags
/// to study by) are a tap away.
/// Studies a studyset, a round at a time: a header (close, the title,
/// asking the back first, the session's settings), the round's progress, the
/// card as its type asks it, and the ratings once it is answered; at the end
/// of a round, how it went.
class const StudyScreen({
/// The studyset studied.
required final String studysetId,
/// Its title.
required final String title,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Study'),
actions: [
IconButton(
tooltip: AppLocalizations.of(context).studySettingsTitle,
icon: const Icon(LucideIcons.settings2),
onPressed: () async {
final bloc = context.read<StudySessionBloc>();
if (await showStudySettingsSheet(context, studysetId: studysetId)) {
bloc.add(const StudySessionEventSettingsChanged());
}
},
),
],
),
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()))}.',
body: SafeArea(
child: BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(studysetId: studysetId, title: title, state: state),
Expanded(
child: switch (state) {
StudySessionStateLoading() => const Center(
child: AppLoadingIndicator(),
),
],
),
StudySessionStateNothing(:final next) => _Nothing(next: next),
final StudySessionStateShowing showing => _Showing(showing),
final StudySessionStateRoundDone done => _RoundDone(done),
},
),
],
),
final StudySessionStateShowing showing => _Showing(showing),
},
),
),
);
}
/// Close on the left, the title in the middle, asking the back first and
/// the settings on the right; the settings show how many tags narrow the
/// session.
class const _Header({
required final String studysetId,
required final String title,
required final StudySessionState state,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final showing = switch (state) {
final StudySessionStateShowing showing => showing,
_ => null,
};
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: Row(
children: [
IconButton(
tooltip: l10n.studyClose,
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
),
if (showing != null)
IconButton(
tooltip: showing.backFirst
? l10n.studyBackFirstOn
: l10n.studyBackFirstOff,
isSelected: showing.backFirst,
icon: const Icon(LucideIcons.copyCheck),
style: showing.backFirst
? IconButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
)
: null,
onPressed: () => context.read<StudySessionBloc>().add(
const StudySessionEventBackFirstToggled(),
),
),
Badge(
isLabelVisible: (showing?.tagFilterCount ?? 0) > 0,
label: Text('${showing?.tagFilterCount ?? 0}'),
child: IconButton(
tooltip: l10n.studySettingsTitle,
icon: const Icon(LucideIcons.settings2),
onPressed: () async {
final bloc = context.read<StudySessionBloc>();
if (await showStudySettingsSheet(
context,
studysetId: studysetId,
)) {
bloc.add(const StudySessionEventSettingsChanged());
}
},
),
),
],
),
);
}
}
/// A card on screen: the round's progress, the card, and below it showing
/// the answer or rating it; cramming, going back and on beside the ratings.
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,
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final card = state.card;
final key = ValueKey((card.id, state.shownAt));
void check() => _send(context, const StudySessionEventAnswerShown());
final body = switch (card.content.type) {
CardType.multipleChoice when card.content.map == null =>
MultipleChoiceBody(
key: key,
card: card,
checked: state.answerShown,
onCheck: check,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
CardType.matching => MatchingBody(
key: key,
card: card,
checked: state.answerShown,
onCheck: check,
),
CardType.vocabulary => VocabularyBody(
key: key,
card: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
),
// A question on a map shows its answer like a back, until maps come.
CardType.frontBack || CardType.multipleChoice => FrontBackBody(
key: key,
card: card.content.type == CardType.multipleChoice
? card.withContent(
card.content.copyWith(
back: card.content.answers.firstOrNull?.content,
),
)
: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
),
};
final revealsByButton = switch (card.content.type) {
CardType.multipleChoice => card.content.map != null,
CardType.matching => false,
CardType.frontBack || CardType.vocabulary => true,
};
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
),
],
),
if (state.cram)
CramProgress(tally: state.cramTally, unrated: state.cramUnrated)
else
PillProgress(pills: state.pills),
const SizedBox(height: 16),
Expanded(
child: SectionCard(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(child: body),
),
),
const SizedBox(height: 16),
Row(
children: [
if (state.cram)
IconButton(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(context, const StudySessionEventMoved(-1))
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
Expanded(
child: state.answerShown
? RatingRow(
nextDue: state.nextDue,
shownAt: state.shownAt,
onRated: (rating) =>
_send(context, StudySessionEventRated(rating)),
)
: revealsByButton
? FilledButton(
onPressed: check,
child: Text(l10n.studyShowAnswer),
)
: const SizedBox.shrink(),
),
if (state.cram)
IconButton(
tooltip: l10n.studyNext,
onPressed: state.canGoForward
? () => _send(context, const StudySessionEventMoved(1))
: null,
icon: const Icon(LucideIcons.chevronRight),
),
],
),
],
),
],
],
);
),
);
}
}
/// [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',
};
/// Nothing to study now, and when the next card is due.
class const _Nothing({required final DateTime? next}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.partyPopper,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(l10n.studyNothingTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
l10n.studyNothingBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
if (next != null) ...[
const SizedBox(height: 4),
Text(
l10n.studyNextDue(
waitLabel(l10n, next!.difference(clock.now().toUtc())),
),
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.studyDone),
),
],
),
),
);
}
}
/// A round done: how many cards got each rating, going on when another
/// round has cards, and done.
class const _RoundDone(final StudySessionStateRoundDone state)
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.studyRoundDone,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 24),
Row(
spacing: 8,
children: [
for (final rating in Rating.values)
Expanded(
child: _Stat(
count: state.tally[rating] ?? 0,
label: ratingLabel(l10n, rating),
color: ratingColor(context, rating),
),
),
],
),
const SizedBox(height: 24),
if (state.more) ...[
FilledButton(
onPressed: () => context.read<StudySessionBloc>().add(
const StudySessionEventContinued(),
),
child: Text(l10n.studyContinue),
),
const SizedBox(height: 8),
],
OutlinedButton(
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.studyDone),
),
],
),
),
),
);
}
}
class const _Stat({
required final int count,
required final String label,
required final Color color,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'$count',
style: theme.textTheme.headlineSmall?.copyWith(color: color),
),
Text(label, style: theme.textTheme.labelMedium),
],
),
);
}
}
/// Sends [event] to the session.
void _send(BuildContext context, StudySessionEvent event) =>
context.read<StudySessionBloc>().add(event);

View file

@ -4,7 +4,9 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
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/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
@ -63,6 +65,8 @@ class const _StudySettings() extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
_Options(options: loaded.options),
const SizedBox(height: 20),
Row(
children: [
Expanded(
@ -112,3 +116,152 @@ class const _StudySettings() extends StatelessWidget {
);
}
}
/// The study options: how many cards a round holds, how many new cards a
/// day (spaced repetition only), the order, and spaced repetition or
/// cramming.
class const _Options({required final StudyOptions options})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
void change(StudyOptions changed) => context.read<StudySettingsBloc>().add(
StudySettingsEventOptionsChanged(changed),
);
final heading = theme.textTheme.titleSmall;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.studySettingsCardsPerRound, style: heading),
const SizedBox(height: 8),
_Stepper(
value: '${options.cardsPerRound}',
onFewer: options.cardsPerRound > StudyOptions.minCardsPerRound
? () => change(
options.copyWith(cardsPerRound: options.cardsPerRound - 1),
)
: null,
onMore: options.cardsPerRound < StudyOptions.maxCardsPerRound
? () => change(
options.copyWith(cardsPerRound: options.cardsPerRound + 1),
)
: null,
),
if (options.mode == LearnMode.spaced) ...[
const SizedBox(height: 20),
Text(l10n.studySettingsNewPerDay, style: heading),
const SizedBox(height: 4),
Text(
l10n.studySettingsNewPerDayHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
_Stepper(
value: options.newPerDay == StudyOptions.unlimitedNewPerDay
? l10n.studySettingsUnlimited
: '${options.newPerDay}',
onFewer: options.newPerDay > StudyOptions.unlimitedNewPerDay
? () => change(
options.copyWith(newPerDay: options.newPerDay - _newStep),
)
: null,
onMore: options.newPerDay < StudyOptions.maxNewPerDay
? () => change(
options.copyWith(newPerDay: options.newPerDay + _newStep),
)
: null,
),
],
const SizedBox(height: 20),
Text(l10n.studySettingsSortBy, style: heading),
const SizedBox(height: 8),
DropdownButtonFormField<StudyOrder>(
initialValue: options.effectiveOrder,
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
items: [
for (final order in StudyOrder.values)
// Newest first is for cramming.
if (order != StudyOrder.newestFirst ||
options.mode == LearnMode.cram)
DropdownMenuItem(
value: order,
child: Text(switch (order) {
StudyOrder.oldestFirst => l10n.studySortOldestFirst,
StudyOrder.random => l10n.studySortRandom,
StudyOrder.newestFirst => l10n.studySortNewestFirst,
}),
),
],
onChanged: (order) => change(options.copyWith(order: order)),
),
const SizedBox(height: 20),
Text(l10n.studySettingsLearnMode, style: heading),
RadioGroup<LearnMode>(
groupValue: options.mode,
onChanged: (mode) => change(options.copyWith(mode: mode)),
child: Column(
children: [
for (final mode in LearnMode.values)
RadioListTile<LearnMode>(
value: mode,
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(switch (mode) {
LearnMode.spaced => l10n.studyModeSpaced,
LearnMode.cram => l10n.studyModeCram,
}),
subtitle: Text(switch (mode) {
LearnMode.spaced => l10n.studyModeSpacedHint,
LearnMode.cram => l10n.studyModeCramHint,
}),
),
],
),
),
],
);
}
}
/// 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),
),
],
);
}
}

View file

@ -104,7 +104,7 @@ void main() {
expect(find.text('Show answer'), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
await tester.pageBack();
await tester.tap(find.byTooltip('Close session'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -7,6 +9,8 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
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:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -18,12 +22,12 @@ import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
CardContent card(String front) => CardContent(
front: RichContent({
'document': {'type': 'page', 'note': front},
}),
back: RichContent.empty(),
);
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front) =>
CardContent(front: text(front), back: text('$front back'));
void main() {
late StudysetRepository studysets;
@ -63,14 +67,33 @@ void main() {
tags,
settings,
'japanese',
random: math.Random(1),
)..add(const StudySessionEventStarted());
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);
Future<void> study(StudyOptions options) =>
settings.update((s) => s.withStudy(options));
/// The next state (or the current one) [test] holds for.
Future<StudySessionState> until(
StudySessionBloc bloc,
bool Function(StudySessionState) test,
) => test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
/// The next card shown with [front].
Future<StudySessionStateShowing> showing(
StudySessionBloc bloc,
String front,
) async => await until(
bloc,
(s) => s is StudySessionStateShowing && s.card.content.front == text(front),
) as StudySessionStateShowing;
/// Shows the answer and rates the card on screen.
void rate(StudySessionBloc bloc, Rating rating) => bloc
..add(const StudySessionEventAnswerShown())
..add(StudySessionEventRated(rating));
test('shows the due cards one by one; a rating records how long answering '
'took', () async {
@ -80,54 +103,254 @@ void main() {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(showing('猫')));
final neko = await showing(bloc, '猫');
expect(neko.pills.map((pill) => pill.state), [
PillState.active,
PillState.pending,
]);
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,
),
),
);
final answered = await until(
bloc,
(s) => s is StudySessionStateShowing && s.answerShown,
) as StudySessionStateShowing;
expect(answered.nextDue.keys, 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);
await showing(bloc, '犬');
});
final review = reviewGateway.rows.single;
expect(review.durationMs, 7000);
expect(review.mode, 'review');
});
test('when nothing is left, says when the next card is due', () async {
await cards.create('japanese', card('猫'));
test('a round holds at most the cards per round', () async {
for (var i = 0; i < 8; i++) {
await cards.create('japanese', card('card $i'));
}
await study(const StudyOptions(cardsPerRound: 5));
await withClock(Clock(() => now), () async {
final bloc = session();
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,
),
),
);
expect((await showing(bloc, 'card 0')).pills, hasLength(5));
});
});
group('with tags chosen to study by', () {
test('a card a day or more away is done for the round; one rated Bad '
'comes back in it', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
rate(bloc, Rating.again);
final dog = await showing(bloc, '犬');
expect(dog.pills.first.state, PillState.again);
rate(bloc, Rating.easy);
// Nothing else due: the card in its learning steps comes early.
final again = await showing(bloc, '猫');
expect(again.pills.last.state, PillState.done);
rate(bloc, Rating.easy);
final done = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(done.tally[Rating.easy], 2);
expect(done.tally[Rating.again], 0);
expect(done.more, isFalse);
});
});
test('a round done with more due goes on with the next', () async {
for (var i = 0; i < 6; i++) {
await cards.create('japanese', card('card $i'));
}
await study(const StudyOptions(cardsPerRound: 5));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
for (var i = 0; i < 5; i++) {
await showing(bloc, 'card $i');
rate(bloc, Rating.easy);
}
final done = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(done.more, isTrue);
bloc.add(const StudySessionEventContinued());
expect((await showing(bloc, 'card 5')).pills, hasLength(1));
});
});
test('nothing to study says when the next card is due', () async {
await cards.create('japanese', card('猫'));
final neko = (await cards.cardsOf('japanese')).single;
await reviews.record(
cardId: neko.id,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final nothing = await until(
bloc,
(s) => s is StudySessionStateNothing,
) as StudySessionStateNothing;
expect(nothing.next!.isAfter(now.add(const Duration(days: 1))), isTrue);
});
});
group('With the back first', () {
test('front/back cards ask their back, on their own schedule; a '
'multiple-choice card asks as ever', () async {
await cards.create('japanese', card('猫'));
await cards.create(
'japanese',
CardContent(
type: CardType.multipleChoice,
front: text('Capital?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
),
);
await study(const StudyOptions(backFirst: true));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final neko = await showing(bloc, '猫');
expect(neko.asksBack, isTrue);
rate(bloc, Rating.easy);
final question = await showing(bloc, 'Capital?');
expect(question.asksBack, isFalse);
});
expect(reviewGateway.rows.single.mode, 'reverse');
final id = (await cards.cardsOf('japanese')).first.id;
expect((await reviews.schedulesOf([id]))[id]!.isNew, isTrue);
});
test('toggling it is saved, and starts the round anew', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
expect((await showing(bloc, '猫')).asksBack, isFalse);
bloc.add(const StudySessionEventBackFirstToggled());
await until(
bloc,
(s) => s is StudySessionStateShowing && s.asksBack,
);
});
expect((await settings.read()).study.backFirst, isTrue);
});
});
group('Cramming', () {
test('walks every card once, moving no schedule, and says how it '
'went', () async {
final neko = await cards.create('japanese', card('猫'));
await reviews.record(
cardId: neko,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
await cards.create('japanese', card('犬'));
await study(const StudyOptions(mode: LearnMode.cram));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
// Not due, but crammed all the same.
final first = await showing(bloc, '猫');
expect(first.cram, isTrue);
bloc.add(const StudySessionEventAnswerShown());
final answered = await until(
bloc,
(s) => s is StudySessionStateShowing && s.answerShown,
) as StudySessionStateShowing;
expect(answered.nextDue, isEmpty);
bloc.add(const StudySessionEventRated(Rating.hard));
await showing(bloc, '犬');
rate(bloc, Rating.good);
final done = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(done.tally[Rating.hard], 1);
expect(done.tally[Rating.good], 1);
expect(done.more, isFalse);
});
expect(reviewGateway.rows.skip(1).map((row) => row.mode), [
'cram',
'cram',
]);
expect((await reviews.schedulesOf([neko]))[neko]!.reviewCount, 1);
});
test('goes back and on, to skip or revisit cards', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await study(const StudyOptions(mode: LearnMode.cram));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final first = await showing(bloc, '猫');
expect(first.canGoBack, isFalse);
expect(first.canGoForward, isTrue);
bloc.add(const StudySessionEventMoved(1));
final second = await showing(bloc, '犬');
expect(second.canGoBack, isTrue);
bloc.add(const StudySessionEventMoved(-1));
await showing(bloc, '猫');
});
});
test('newest first takes the studyset backwards', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await study(
const StudyOptions(mode: LearnMode.cram, order: StudyOrder.newestFirst),
);
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final first = await until(
bloc,
(s) => s is StudySessionStateShowing,
) as StudySessionStateShowing;
expect(first.card.content.front, text('犬'));
});
});
});
group('With tags chosen to study by', () {
late String verb;
setUp(() async {
@ -139,7 +362,8 @@ void main() {
await tags.setTagsOf(nomu, {verb});
});
test('studies only the cards with any of them', () async {
test('studies only the cards with any of them, and says how many '
'narrow it', () async {
await settings.update(
(s) => s.withStudyTags('japanese', [(id: verb, name: 'verb')]),
);
@ -147,17 +371,10 @@ void main() {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final first = await showing(bloc, '食べる');
await expectLater(
bloc.stream,
emits(
isA<StudySessionStateShowing>().having(
(s) => s.remaining,
'remaining',
2,
),
),
);
expect(first.pills, hasLength(2));
expect(first.tagFilterCount, 1);
});
});
@ -170,63 +387,48 @@ void main() {
final bloc = session();
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(
isA<StudySessionStateShowing>().having(
(s) => s.remaining,
'remaining',
3,
),
),
);
expect((await showing(bloc, '食べる')).pills, hasLength(3));
});
});
test('works out the queue anew when the settings changed', () async {
test('works out a round anew when the settings changed', () async {
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(
isA<StudySessionStateShowing>().having(
(s) => s.remaining,
'remaining',
3,
),
),
);
expect((await showing(bloc, '食べる')).pills, hasLength(3));
await settings.update(
(s) => s.withStudyTags('japanese', [(id: verb, name: 'verb')]),
);
bloc.add(const StudySessionEventSettingsChanged());
await expectLater(
bloc.stream,
emits(
isA<StudySessionStateShowing>()
.having((s) => s.remaining, 'remaining', 2)
.having((s) => s.card.content, 'card', card('食べる')),
),
await until(
bloc,
(s) => s is StudySessionStateShowing && s.pills.length == 2,
);
});
});
});
test('a card rated again comes back within the session', () async {
await cards.create('japanese', card('猫'));
test('a reset card is new again', () async {
final neko = await cards.create('japanese', card('猫'));
await reviews.record(
cardId: neko,
rating: Rating.easy,
reviewedAt: now.subtract(const Duration(hours: 1)),
duration: Duration.zero,
);
await reviews.reset([neko], now.subtract(const Duration(minutes: 1)));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(showing('猫')));
bloc
..add(const StudySessionEventAnswerShown())
..add(const StudySessionEventRated(Rating.again));
await expectLater(bloc.stream, emitsThrough(showing('猫')));
await showing(bloc, '猫');
});
expect(
reviewGateway.rows.map((row) => row.mode),
[StudyMode.review.value, StudyMode.reset.value],
);
});
}

View file

@ -4,6 +4,7 @@ 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/settings/data/settings_repository.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/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -106,4 +107,24 @@ void main() {
expect((await settings.read()).studyTags, isEmpty);
});
test('saves the study options chosen, with the tags', () async {
await loaded();
bloc
..add(
const StudySettingsEventOptionsChanged(
StudyOptions(mode: LearnMode.cram, cardsPerRound: 10),
),
)
..add(const StudySettingsEventSaveRequested());
await bloc.stream.firstWhere(
(s) => s is StudySettingsStateLoaded && s.isDone,
);
expect(
(await settings.read()).study,
const StudyOptions(mode: LearnMode.cram, cardsPerRound: 10),
);
});
}

View file

@ -3,9 +3,11 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
@ -88,19 +90,21 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('cat', findRichText: true), findsOneWidget);
for (final label in ['Again', 'Hard', 'Good', 'Easy']) {
for (final label in ['Bad', 'OK', 'Good', 'Perfect']) {
expect(find.text(label), findsOneWidget);
}
expect(find.text('1m'), findsOneWidget);
expect(find.text('1 min'), findsOneWidget);
});
testWidgets('says when there is nothing to study', (tester) async {
await study(tester);
expect(find.text('Nothing to study right now.'), findsOneWidget);
expect(find.text('Nothing to study right now'), findsOneWidget);
});
testWidgets('rating the last card ends the session', (tester) async {
testWidgets('rating the last card ends the round, with how it went', (
tester,
) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
@ -109,11 +113,106 @@ void main() {
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Easy'));
await tester.tap(find.text('Perfect'));
await tester.pumpAndSettle();
expect(find.text('Nothing to study right now.'), findsOneWidget);
expect(find.textContaining('Next card due in'), findsOneWidget);
expect(find.text('Round complete'), findsOneWidget);
expect(find.text('Continue learning'), findsNothing);
expect(find.text('Done'), findsOneWidget);
});
testWidgets('a multiple-choice card is checked, then rated', (tester) async {
await cards.create(
'japanese',
CardContent(
type: CardType.multipleChoice,
front: paragraph('Capital of Japan?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: paragraph('Tokyo'), correct: true),
CardAnswer(content: paragraph('Kyoto')),
],
),
);
await study(tester);
Finder check() => find.widgetWithText(FilledButton, 'Check');
expect(tester.widget<FilledButton>(check()).onPressed, isNull);
await tester.tap(find.textContaining('Kyoto', findRichText: true));
await tester.pump();
await tester.tap(check());
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.circleX), findsOneWidget);
expect(find.byIcon(LucideIcons.circleCheck), findsOneWidget);
expect(find.text('Bad'), findsOneWidget);
});
testWidgets('a matching card places its items by tapping, then is checked', (
tester,
) async {
await cards.create(
'japanese',
CardContent(
type: CardType.matching,
front: RichContent.empty(),
back: RichContent.empty(),
matching: const Matching(
targets: ['mammal', 'bird'],
items: [
MatchingItem(text: 'cat', target: 0),
MatchingItem(text: 'crow', target: 1),
],
),
),
);
await study(tester);
await tester.tap(find.text('cat'));
await tester.pump();
await tester.tap(find.text('mammal'));
await tester.pump();
await tester.tap(find.text('crow'));
await tester.pump();
await tester.tap(find.text('mammal'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Check'));
await tester.pumpAndSettle();
// The crow is no mammal.
expect(find.text('Perfect'), findsOneWidget);
final crow = tester.widget<Material>(
find
.ancestor(of: find.text('crow'), matching: find.byType(Material))
.first,
);
expect(
(crow.shape! as StadiumBorder).side.color,
isNot(Colors.transparent),
);
});
testWidgets('a word shows its reading and meanings with the answer', (
tester,
) async {
await cards.create(
'japanese',
CardContent(
type: CardType.vocabulary,
front: paragraph('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat'],
),
);
await study(tester);
expect(find.text('たべる'), findsNothing);
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
expect(find.text('たべる'), findsOneWidget);
expect(find.text('1. to eat'), findsOneWidget);
});
testWidgets('the session settings narrow the cards studied to the tags '
@ -129,16 +228,20 @@ void main() {
}
}
await study(tester);
expect(find.text('2 to study'), findsOneWidget);
int pills() =>
tester.widget<PillProgress>(find.byType(PillProgress)).pills.length;
expect(pills(), 2);
await tester.tap(find.byTooltip('Session settings'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.widgetWithText(FilterChip, 'verb'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilterChip, 'verb'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('1 to study'), findsOneWidget);
expect(pills(), 1);
expect(find.textContaining('食べる', findRichText: true), findsOneWidget);
});
}

View file

@ -396,9 +396,9 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Easy'));
await tester.tap(find.text('Perfect'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.byTooltip('Add content'), findsOneWidget);