feat(app): ask what to study in a studyset with sub-studysets, and say when studying fails

- Studying a studyset that has sub-studysets and cards of its own
  first asks: everything (suggested) or this studyset only. Without
  either, it starts straight away. Alone, the back-first setting
  applies as usual and a turn of it is kept (a study button's focus
  may now leave the side to the settings).
- A session that can't be read says so, with Retry; a rating that
  can't be kept says so below the card, which stays to be rated
  again.

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-07 21:00:42 +02:00
commit 9f1cfc1cdd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 547 additions and 25 deletions

View file

@ -440,6 +440,15 @@
"cardTileConflict": "Auf zwei Geräten geändert",
"studyClose": "Sitzung schließen",
"studyLoadFailed": "Diese Sitzung konnte nicht gelesen werden.",
"studyRatingFailed": "Deine Bewertung konnte nicht gespeichert werden. Bewerte die Karte noch einmal.",
"studyRetry": "Erneut versuchen",
"studyScopeTitle": "Lernumfang",
"studyScopeDescription": "Dieses Lernset hat Unter-Lernsets. Wähle, welche Karten wiederholt werden sollen.",
"studyScopeEverythingTitle": "Alles",
"studyScopeEverythingSubtitle": "Dieses Lernset und alle Unter-Lernsets",
"studyScopeSelfTitle": "Nur dieses Lernset",
"studyScopeSelfSubtitle": "Karten in Unter-Lernsets überspringen",
"studyBackFirstOn": "Rückseite der Karten wird zuerst gezeigt: tippe zum Zurückschalten",
"studyBackFirstOff": "Rückseite der Karten zuerst zeigen",
"studyShowAnswer": "Antwort anzeigen",

View file

@ -487,6 +487,15 @@
"cardTileConflict": "Changed on two devices",
"studyClose": "Close session",
"studyLoadFailed": "Couldn't read this session.",
"studyRatingFailed": "Couldn't keep your rating. Rate the card again.",
"studyRetry": "Retry",
"studyScopeTitle": "Study scope",
"studyScopeDescription": "This studyset has sub-studysets. Pick which cards to review.",
"studyScopeEverythingTitle": "Everything",
"studyScopeEverythingSubtitle": "This studyset and all sub-studysets",
"studyScopeSelfTitle": "This studyset only",
"studyScopeSelfSubtitle": "Skip cards in sub-studysets",
"studyBackFirstOn": "Showing the back of cards first: tap to revert",
"studyBackFirstOff": "Show the back of cards first",
"studyShowAnswer": "Show answer",

View file

@ -2656,6 +2656,60 @@ abstract class AppLocalizations {
/// **'Close session'**
String get studyClose;
/// No description provided for @studyLoadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read this session.'**
String get studyLoadFailed;
/// No description provided for @studyRatingFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t keep your rating. Rate the card again.'**
String get studyRatingFailed;
/// No description provided for @studyRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get studyRetry;
/// No description provided for @studyScopeTitle.
///
/// In en, this message translates to:
/// **'Study scope'**
String get studyScopeTitle;
/// No description provided for @studyScopeDescription.
///
/// In en, this message translates to:
/// **'This studyset has sub-studysets. Pick which cards to review.'**
String get studyScopeDescription;
/// No description provided for @studyScopeEverythingTitle.
///
/// In en, this message translates to:
/// **'Everything'**
String get studyScopeEverythingTitle;
/// No description provided for @studyScopeEverythingSubtitle.
///
/// In en, this message translates to:
/// **'This studyset and all sub-studysets'**
String get studyScopeEverythingSubtitle;
/// No description provided for @studyScopeSelfTitle.
///
/// In en, this message translates to:
/// **'This studyset only'**
String get studyScopeSelfTitle;
/// No description provided for @studyScopeSelfSubtitle.
///
/// In en, this message translates to:
/// **'Skip cards in sub-studysets'**
String get studyScopeSelfSubtitle;
/// No description provided for @studyBackFirstOn.
///
/// In en, this message translates to:

View file

@ -1539,6 +1539,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyClose => 'Sitzung schließen';
@override
String get studyLoadFailed => 'Diese Sitzung konnte nicht gelesen werden.';
@override
String get studyRatingFailed =>
'Deine Bewertung konnte nicht gespeichert werden. Bewerte die Karte noch einmal.';
@override
String get studyRetry => 'Erneut versuchen';
@override
String get studyScopeTitle => 'Lernumfang';
@override
String get studyScopeDescription =>
'Dieses Lernset hat Unter-Lernsets. Wähle, welche Karten wiederholt werden sollen.';
@override
String get studyScopeEverythingTitle => 'Alles';
@override
String get studyScopeEverythingSubtitle =>
'Dieses Lernset und alle Unter-Lernsets';
@override
String get studyScopeSelfTitle => 'Nur dieses Lernset';
@override
String get studyScopeSelfSubtitle => 'Karten in Unter-Lernsets überspringen';
@override
String get studyBackFirstOn =>
'Rückseite der Karten wird zuerst gezeigt: tippe zum Zurückschalten';

View file

@ -1528,6 +1528,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyClose => 'Close session';
@override
String get studyLoadFailed => 'Couldn\'t read this session.';
@override
String get studyRatingFailed =>
'Couldn\'t keep your rating. Rate the card again.';
@override
String get studyRetry => 'Retry';
@override
String get studyScopeTitle => 'Study scope';
@override
String get studyScopeDescription =>
'This studyset has sub-studysets. Pick which cards to review.';
@override
String get studyScopeEverythingTitle => 'Everything';
@override
String get studyScopeEverythingSubtitle =>
'This studyset and all sub-studysets';
@override
String get studyScopeSelfTitle => 'This studyset only';
@override
String get studyScopeSelfSubtitle => 'Skip cards in sub-studysets';
@override
String get studyBackFirstOn =>
'Showing the back of cards first: tap to revert';

View file

@ -1,3 +1,4 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:clock/clock.dart';
@ -49,6 +50,7 @@ class StudySessionBloc(
on<StudySessionEventStarted>((event, emit) => _startSession(emit));
on<StudySessionEventSettingsChanged>((event, emit) => _startSession(emit));
on<StudySessionEventContinued>((event, emit) => _startRound(emit));
on<StudySessionEventRetried>((event, emit) => _startSession(emit));
on<StudySessionEventAnswerShown>(
(event, emit) => _showAnswer(emit, shown: true),
);
@ -61,8 +63,9 @@ class StudySessionBloc(
on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventBackFirstToggled>((event, emit) async {
// A focused session turns for itself; the settings stay as they are.
if (_focus case final focus?) {
// A session focused on a side turns for itself; the settings stay as
// they are.
if (_focus case final focus? when focus.backFirst != null) {
_focus = focus.turned();
} else {
await _settings.update(
@ -117,7 +120,17 @@ class StudySessionBloc(
return _startRound(emit);
}
/// Starts a round, or says that it couldn't be read.
Future<void> _startRound(Emitter<StudySessionState> emit) async {
try {
await _readRound(emit);
} on Exception catch (error) {
log('Starting a round failed', name: 'Study', error: error);
emit(const StudySessionStateFailed());
}
}
Future<void> _readRound(Emitter<StudySessionState> emit) async {
emit(StudySessionStateLoading(backFirst: _options.backFirst));
_options = (await _settings.read()).study;
if (_focus case final focus?) {
@ -408,13 +421,20 @@ class StudySessionBloc(
if (state case final StudySessionStateShowing showing) {
final card = showing.card;
final now = clock.now().toUtc();
await _reviews.record(
cardId: card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
mode: _modeOf(card),
);
try {
await _reviews.record(
cardId: card.id,
rating: event.rating,
reviewedAt: now,
duration: now.difference(showing.shownAt),
mode: _modeOf(card),
);
} on Exception catch (error) {
// The card stays, to be rated again.
log('Keeping a rating failed', name: 'Study', error: error);
emit(showing.withRatingFailed());
return;
}
_ratings[card.id] = event.rating;
_given.add(event.rating);
if (_options.mode == LearnMode.cram) {
@ -540,6 +560,9 @@ final class const StudySessionEventStarted() extends StudySessionEvent;
/// The person's settings changed: a new round with them.
final class const StudySessionEventSettingsChanged() extends StudySessionEvent;
/// The session couldn't be read: try again, from the start.
final class const StudySessionEventRetried() extends StudySessionEvent;
/// The round is done and the person goes on: the next round.
final class const StudySessionEventContinued() extends StudySessionEvent;
@ -621,6 +644,9 @@ final class const StudySessionStateLoading({
List<Object?> get props => [backFirst];
}
/// The session couldn't be read.
final class const StudySessionStateFailed() extends StudySessionState;
/// Nothing to study now.
final class const StudySessionStateNothing({
/// When the next card is due; null without any studied card.
@ -682,9 +708,18 @@ final class const StudySessionStateShowing({
/// When the card would be due after each rating; empty while cramming.
final Map<Rating, DateTime> nextDue = const {},
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
}) extends StudySessionState {
/// This state with the answer [shown] or hidden.
StudySessionStateShowing withAnswerShown(bool shown) =>
_copy(answerShown: shown);
/// This state saying that the card's rating couldn't be kept.
StudySessionStateShowing withRatingFailed() => _copy(ratingFailed: true);
StudySessionStateShowing _copy({bool? answerShown, bool? ratingFailed}) =>
StudySessionStateShowing(
card: card,
studysetId: studysetId,
@ -700,8 +735,9 @@ final class const StudySessionStateShowing({
canGoForward: canGoForward,
tagFilterCount: tagFilterCount,
backFirst: backFirst,
answerShown: shown,
answerShown: answerShown ?? this.answerShown,
nextDue: nextDue,
ratingFailed: ratingFailed ?? this.ratingFailed,
);
@override
@ -722,6 +758,7 @@ final class const StudySessionStateShowing({
backFirst,
answerShown,
nextDue,
ratingFailed,
];
}

View file

@ -12,8 +12,9 @@ final class const StudyFocus({
/// Whether the studyset's topics are studied too.
final bool topics = true,
/// Whether front/back and vocabulary cards ask their back.
final bool backFirst = false,
/// Whether front/back and vocabulary cards ask their back; null for as
/// the settings have it.
final bool? backFirst,
}) with Equatable {
/// The focus [toQuery] wrote; null for a query without one, a session as
/// the settings have it.
@ -26,7 +27,11 @@ final class const StudyFocus({
return StudyFocus(
tag: tagId == null || tagName == null ? null : (id: tagId, name: tagName),
topics: query[_topicsKey] != '0',
backFirst: query[_backFirstKey] == '1',
backFirst: switch (query[_backFirstKey]) {
'1' => true,
'0' => false,
_ => null,
},
);
}
@ -35,12 +40,12 @@ final class const StudyFocus({
_focusKey: '1',
if (tag case final tag?) ...{_tagKey: tag.id, _tagNameKey: tag.name},
_topicsKey: topics ? '1' : '0',
_backFirstKey: backFirst ? '1' : '0',
if (backFirst case final backFirst?) _backFirstKey: backFirst ? '1' : '0',
};
/// This focus asking the other side first.
StudyFocus turned() =>
StudyFocus(tag: tag, topics: topics, backFirst: !backFirst);
StudyFocus(tag: tag, topics: topics, backFirst: !(backFirst ?? false));
@override
List<Object?> get props => [tag, topics, backFirst];

View file

@ -0,0 +1,160 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Starts studying the studyset [studysetId], and completes once the
/// session is closed. A studyset with sub-studysets and cards of its own
/// first asks whether to study everything or it alone; one without either
/// goes straight to everything there is. Nothing starts when the person
/// closes the question.
Future<void> startStudying(BuildContext context, String studysetId) async {
final person = PersonScope.of(context);
final router = GoRouter.of(context);
final everything = RoutePaths.study(studysetId);
final withSubStudysets =
studiedIn(studysetId, await person.studysets.all()).length > 1;
final asks =
withSubStudysets && (await person.cards.cardsOf(studysetId)).isNotEmpty;
if (!asks) {
await router.push(everything);
return;
}
if (!context.mounted) {
return;
}
final everythingChosen = await showAdaptiveSheet<bool>(
context,
builder: (context) => const _ScopeSheet(),
);
switch (everythingChosen) {
case true:
await router.push(everything);
case false:
await router.push(
RoutePaths.study(
studysetId,
query: const StudyFocus(topics: false).toQuery(),
),
);
case null:
return;
}
}
/// Asks whether to study the studyset with its sub-studysets, the choice
/// suggested, or alone: true for everything.
class const _ScopeSheet() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.studyScopeTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
l10n.studyScopeDescription,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
_ScopeCard(
icon: LucideIcons.layers2,
title: l10n.studyScopeEverythingTitle,
subtitle: l10n.studyScopeEverythingSubtitle,
suggested: true,
onTap: () => Navigator.of(context).pop(true),
),
const SizedBox(height: 8),
_ScopeCard(
icon: LucideIcons.file,
title: l10n.studyScopeSelfTitle,
subtitle: l10n.studyScopeSelfSubtitle,
suggested: false,
onTap: () => Navigator.of(context).pop(false),
),
],
),
);
}
}
/// One choice: an icon, its title and what it means, and a chevron; the
/// [suggested] one tinted in the primary colour.
class const _ScopeCard({
required final IconData icon,
required final String title,
required final String subtitle,
required final bool suggested,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final foreground = suggested ? colors.onPrimaryContainer : colors.onSurface;
return Material(
color: suggested ? colors.primaryContainer : colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: suggested
? colors.primary.withValues(alpha: 0.4)
: colors.outlineVariant,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(icon, size: 24, color: foreground),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: foreground.withValues(alpha: 0.8),
),
),
],
),
),
const SizedBox(width: 8),
Icon(
LucideIcons.chevronRight,
size: 20,
color: foreground.withValues(alpha: 0.7),
),
],
),
),
),
);
}
}

View file

@ -198,7 +198,8 @@ class _StudyButtonState()
if (focus.tag case final tag?)
l10n.studyButtonWithTag(tag.name),
if (!focus.topics) l10n.studyButtonAlone,
if (focus.backFirst) l10n.studyButtonBackFirst,
if (focus.backFirst ?? false)
l10n.studyButtonBackFirst,
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,

View file

@ -361,15 +361,29 @@ class const _Meanings({
}
/// Below a card answered by picking or sorting: Check, full width, once
/// [canCheck]; once [checked], the [ratings] in its place.
/// [canCheck]; once [checked], the [ratings] in its place, beneath a word
/// that the last rating wasn't kept when [ratingFailed].
class const _CheckOrRate({
required final bool checked,
required final bool canCheck,
required final VoidCallback onCheck,
required final Widget ratings,
required final bool ratingFailed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => checked
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (ratingFailed) ...[
const RatingFailedBanner(),
const SizedBox(height: 12),
],
_button(context),
],
);
Widget _button(BuildContext context) => checked
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
@ -396,6 +410,9 @@ class const MultipleChoiceBody({
/// The ratings, shown once checked.
required final Widget ratings,
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
super.key,
}) extends StatefulWidget {
@override
@ -465,6 +482,7 @@ class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
canCheck: _picked.isNotEmpty,
onCheck: widget.onCheck,
ratings: widget.ratings,
ratingFailed: widget.ratingFailed,
),
],
);
@ -581,6 +599,9 @@ class const MatchingBody({
/// The ratings, shown once checked.
required final Widget ratings,
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
super.key,
}) extends StatefulWidget {
@override
@ -754,6 +775,7 @@ class _MatchingBodyState() extends State<MatchingBody> {
canCheck: loose.isEmpty,
onCheck: widget.onCheck,
ratings: widget.ratings,
ratingFailed: widget.ratingFailed,
),
],
);
@ -831,6 +853,9 @@ class const MapBody({
/// The ratings, shown once checked.
required final Widget ratings,
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
super.key,
}) extends StatefulWidget {
@override
@ -907,6 +932,7 @@ class _MapBodyState() extends State<MapBody> {
canCheck: _picked != null,
onCheck: widget.onCheck,
ratings: widget.ratings,
ratingFailed: widget.ratingFailed,
),
],
);
@ -954,3 +980,32 @@ class const _MapCaption({
return Text(text, textAlign: TextAlign.center, style: style);
}
}
/// That the card's rating couldn't be kept, on the error colours.
class const RatingFailedBanner({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(LucideIcons.circleAlert, color: colors.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).studyRatingFailed,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer,
),
),
),
],
),
);
}
}

View file

@ -66,6 +66,7 @@ class const StudyScreen({
final StudySessionStateRoundDone done => _RoundDone(
done,
),
StudySessionStateFailed() => const _Failed(),
_ => const Center(child: AppLoadingIndicator()),
},
),
@ -129,6 +130,7 @@ class const _Header({
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final backFirst = switch (state) {
StudySessionStateFailed() => false,
StudySessionStateLoading(:final backFirst) ||
StudySessionStateNothing(:final backFirst) ||
StudySessionStateShowing(:final backFirst) ||
@ -261,6 +263,7 @@ class const _Showing(
checked: state.answerShown,
onCheck: show,
ratings: ratings,
ratingFailed: state.ratingFailed,
),
CardType.multipleChoice => MultipleChoiceBody(
key: key,
@ -269,6 +272,7 @@ class const _Showing(
checked: state.answerShown,
onCheck: show,
ratings: ratings,
ratingFailed: state.ratingFailed,
),
CardType.matching => MatchingBody(
key: key,
@ -276,6 +280,7 @@ class const _Showing(
checked: state.answerShown,
onCheck: show,
ratings: ratings,
ratingFailed: state.ratingFailed,
),
CardType.vocabulary => SwipeToRate(
key: key,
@ -295,6 +300,10 @@ class const _Showing(
),
),
const SizedBox(height: 16),
if (state.ratingFailed) ...[
const RatingFailedBanner(),
const SizedBox(height: 12),
],
],
),
),
@ -316,6 +325,10 @@ class const _Showing(
),
),
const SizedBox(height: 16),
if (state.ratingFailed) ...[
const RatingFailedBanner(),
const SizedBox(height: 12),
],
],
),
),
@ -413,6 +426,32 @@ class const _Showing(
}
}
/// The session couldn't be read, and a way to try again.
class const _Failed() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.circleAlert, size: 48),
const SizedBox(height: 8),
Text(l10n.studyLoadFailed, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _send(context, const StudySessionEventRetried()),
child: Text(l10n.studyRetry),
),
],
),
),
);
}
}
/// Nothing to study now, and when the next card is due.
class const _Nothing({required final DateTime? next}) extends StatelessWidget {
@override

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
@ -285,7 +286,7 @@ class _GridState() extends State<_Grid> {
// Within the library tab, so its back button returns here.
onOpen: () => context.go(RoutePaths.studyset(studyset.id)),
onStudy: () async {
await context.push(RoutePaths.study(studyset.id));
await startStudying(context, studyset.id);
// The session's reviews changed what is due.
bloc.add(const StudysetListEventProgressRefreshRequested());
},

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -73,7 +74,7 @@ class _StudysetRailState() extends State<StudysetRail> {
Future<void> _study(Studyset studyset) async {
final bloc = context.read<StudysetListBloc>();
await context.push(RoutePaths.study(studyset.id));
await startStudying(context, studyset.id);
bloc.add(const StudysetListEventProgressRefreshRequested());
}

View file

@ -28,6 +28,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_list.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
@ -208,7 +209,7 @@ class const _StudysetView({
}) extends StatelessWidget {
Future<void> _study(BuildContext context) async {
final bloc = context.read<StudysetListBloc>();
await context.push(RoutePaths.study(studyset.id));
await startStudying(context, studyset.id);
bloc.add(const StudysetListEventProgressRefreshRequested());
}

View file

@ -32,6 +32,7 @@ CardContent card(String front) =>
CardContent(front: text(front), back: text('$front back'));
void main() {
late FakeStudysetGateway studysetGateway;
late StudysetRepository studysets;
late CardRepository cards;
late FakeReviewGateway reviewGateway;
@ -41,12 +42,12 @@ void main() {
late DateTime now;
setUp(() {
studysets = StudysetRepository(
FakeStudysetGateway()..receiveRemoteChange(
studysetGateway = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
);
);
studysets = StudysetRepository(studysetGateway);
cards = CardRepository(FakeCardGateway());
reviewGateway = FakeReviewGateway();
reviews = ReviewRepository(reviewGateway);
@ -209,6 +210,48 @@ void main() {
});
});
group('When something fails', () {
test("a session that can't be read says so, and starts again on "
'Retry', () async {
await cards.create('japanese', card('猫'));
studysetGateway.failReads = Exception('disk');
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await until(bloc, (s) => s is StudySessionStateFailed);
studysetGateway.failReads = null;
bloc.add(const StudySessionEventRetried());
await showing(bloc, '猫');
});
});
test("a rating that can't be kept says so, and the card stays to be "
'rated again', () 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, '猫');
reviewGateway.failWrites = Exception('disk');
bloc.add(const StudySessionEventRated(Rating.easy));
final failed = await until(
bloc,
(s) => s is StudySessionStateShowing && s.ratingFailed,
) as StudySessionStateShowing;
expect(failed.card.content.front, text('猫'));
reviewGateway.failWrites = null;
bloc.add(const StudySessionEventRated(Rating.easy));
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
});
});
});
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'));
@ -647,6 +690,23 @@ void main() {
});
});
test('alone, asks the back as the settings say, and keeps a turn of it '
'there', () async {
await study(const StudyOptions(backFirst: true));
await withClock(Clock(() => now), () async {
final bloc = focused(const StudyFocus(topics: false));
addTearDown(bloc.close);
expect((await showing(bloc, '食べる')).asksBack, isTrue);
bloc.add(const StudySessionEventBackFirstToggled());
await until(
bloc,
(s) => s is StudySessionStateShowing && !s.asksBack,
);
});
expect((await settings.read()).study.backFirst, isFalse);
});
test('asks the back first for this session alone', () async {
await withClock(Clock(() => now), () async {
final bloc = focused(const StudyFocus(backFirst: true));
@ -671,6 +731,8 @@ void main() {
expect(StudyFocus.fromQuery(focus.toQuery()), focus);
expect(StudyFocus.fromQuery(const {}), isNull);
const alone = StudyFocus(topics: false);
expect(StudyFocus.fromQuery(alone.toQuery()), alone);
});
});

View file

@ -5,6 +5,7 @@ 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/model/studyset_study_options.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/studyset/widget/studyset_breadcrumb.dart';
@ -215,6 +216,27 @@ void main() {
expect(find.textContaining('あ', findRichText: true), findsOneWidget);
});
testWidgets('with cards of its own and in its sub-studysets, asks what to '
'study: everything, or it alone', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await card('japanese', '日本');
await card('kana', 'あ');
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.text('Learn 2'));
await tester.pumpAndSettle();
expect(find.text('Study scope'), findsOneWidget);
await tester.tap(find.text('This studyset only'));
await tester.pumpAndSettle();
expect(
tester.widget<PillProgress>(find.byType(PillProgress)).pills,
hasLength(1),
);
expect(find.textContaining('日本', findRichText: true), findsOneWidget);
});
testWidgets('moves a studyset into another', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana');

View file

@ -7,6 +7,9 @@ class FakeReviewGateway() implements ReviewGateway {
/// Every review stored, in order.
final rows = <ReviewRow>[];
/// When set, storing a review fails with it.
Exception? failWrites;
@override
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) async => [
for (final row in rows)
@ -18,6 +21,9 @@ class FakeReviewGateway() implements ReviewGateway {
@override
Future<void> insert(Iterable<ReviewRow> reviews) async {
if (failWrites case final error?) {
throw error;
}
for (final review in reviews) {
rows.add((
id: 'review-${(_nextId++).toString().padLeft(4, '0')}',