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:
parent
b0aaecf115
commit
9f1cfc1cdd
17 changed files with 547 additions and 25 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,6 +421,7 @@ class StudySessionBloc(
|
|||
if (state case final StudySessionStateShowing showing) {
|
||||
final card = showing.card;
|
||||
final now = clock.now().toUtc();
|
||||
try {
|
||||
await _reviews.record(
|
||||
cardId: card.id,
|
||||
rating: event.rating,
|
||||
|
|
@ -415,6 +429,12 @@ class StudySessionBloc(
|
|||
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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')}',
|
||||
|
|
|
|||
Loading…
Reference in a new issue