feat(app): quiz vocabulary as multiple choice

The session settings' new Vocabulary section quizzes words as multiple
choice: their meaning (the default), their reading, the expression for
the meaning, or the reading and the meaning in turn ("Mixed"). The
wrong answers come from the other words studied; none of the word's
own meanings counts as wrong, whatever its case. A word without what is
asked, or without two others to draw from, stays a word to flip. It is
asked the same way each time it comes, and rated as any card.

The settings keep both (`quizVocabulary`, `quizAspect`; settings schema
5), on every device.

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:57:04 +02:00
commit 6893db7da8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 583 additions and 13 deletions

View file

@ -563,6 +563,14 @@
"studyModeSpacedHint": "Fällige Karten nach ihrem Zeitplan; Bewertungen verschieben sie.", "studyModeSpacedHint": "Fällige Karten nach ihrem Zeitplan; Bewertungen verschieben sie.",
"studyModeCram": "Pauken", "studyModeCram": "Pauken",
"studyModeCramHint": "Geh jede Karte durch, ohne die Zeitpläne zu ändern.", "studyModeCramHint": "Geh jede Karte durch, ohne die Zeitpläne zu ändern.",
"studySettingsVocabulary": "Vokabeln",
"studySettingsQuizMc": "Als Multiple-Choice abfragen",
"studySettingsQuizMcDescription": "Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.",
"studyAspectMeaning": "Bedeutung",
"studyAspectReading": "Lesung",
"studyAspectExpression": "Ausdruck",
"studyAspectMixed": "Gemischt",
"studySettingsAspectDescription": "Was abgefragt wird — die Bedeutung, die Kana-Lesung oder der Ausdruck selbst (bei angezeigter Bedeutung). „Gemischt“ variiert Bedeutung und Lesung pro Karte; Karten, die nicht abgefragt werden können, bleiben ein normales Umdrehen.",
"studySortOldestFirst": "Älteste zuerst", "studySortOldestFirst": "Älteste zuerst",
"studySortRandom": "Zufällig", "studySortRandom": "Zufällig",
"studySortNewestFirst": "Neueste zuerst", "studySortNewestFirst": "Neueste zuerst",

View file

@ -635,6 +635,14 @@
"studyModeSpacedHint": "Due cards on their schedules; ratings move them.", "studyModeSpacedHint": "Due cards on their schedules; ratings move them.",
"studyModeCram": "Cram", "studyModeCram": "Cram",
"studyModeCramHint": "Walk every card without touching the schedules.", "studyModeCramHint": "Walk every card without touching the schedules.",
"studySettingsVocabulary": "Vocabulary",
"studySettingsQuizMc": "Quiz with multiple choice",
"studySettingsQuizMcDescription": "Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.",
"studyAspectMeaning": "Meaning",
"studyAspectReading": "Reading",
"studyAspectExpression": "Expression",
"studyAspectMixed": "Mixed",
"studySettingsAspectDescription": "What to recall — the meaning, the kana reading, or the expression itself (shown the meaning). Mixed varies meaning and reading per card; cards that can’t be quizzed stay a normal flip.",
"studySortOldestFirst": "Oldest first", "studySortOldestFirst": "Oldest first",
"studySortRandom": "Random", "studySortRandom": "Random",
"studySortNewestFirst": "Newest first", "studySortNewestFirst": "Newest first",

View file

@ -3394,6 +3394,54 @@ abstract class AppLocalizations {
/// **'Walk every card without touching the schedules.'** /// **'Walk every card without touching the schedules.'**
String get studyModeCramHint; String get studyModeCramHint;
/// No description provided for @studySettingsVocabulary.
///
/// In en, this message translates to:
/// **'Vocabulary'**
String get studySettingsVocabulary;
/// No description provided for @studySettingsQuizMc.
///
/// In en, this message translates to:
/// **'Quiz with multiple choice'**
String get studySettingsQuizMc;
/// No description provided for @studySettingsQuizMcDescription.
///
/// In en, this message translates to:
/// **'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.'**
String get studySettingsQuizMcDescription;
/// No description provided for @studyAspectMeaning.
///
/// In en, this message translates to:
/// **'Meaning'**
String get studyAspectMeaning;
/// No description provided for @studyAspectReading.
///
/// In en, this message translates to:
/// **'Reading'**
String get studyAspectReading;
/// No description provided for @studyAspectExpression.
///
/// In en, this message translates to:
/// **'Expression'**
String get studyAspectExpression;
/// No description provided for @studyAspectMixed.
///
/// In en, this message translates to:
/// **'Mixed'**
String get studyAspectMixed;
/// No description provided for @studySettingsAspectDescription.
///
/// In en, this message translates to:
/// **'What to recall — the meaning, the kana reading, or the expression itself (shown the meaning). Mixed varies meaning and reading per card; cards that can’t be quizzed stay a normal flip.'**
String get studySettingsAspectDescription;
/// No description provided for @studySortOldestFirst. /// No description provided for @studySortOldestFirst.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2045,6 +2045,32 @@ class AppLocalizationsDe extends AppLocalizations {
String get studyModeCramHint => String get studyModeCramHint =>
'Geh jede Karte durch, ohne die Zeitpläne zu ändern.'; 'Geh jede Karte durch, ohne die Zeitpläne zu ändern.';
@override
String get studySettingsVocabulary => 'Vokabeln';
@override
String get studySettingsQuizMc => 'Als Multiple-Choice abfragen';
@override
String get studySettingsQuizMcDescription =>
'Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.';
@override
String get studyAspectMeaning => 'Bedeutung';
@override
String get studyAspectReading => 'Lesung';
@override
String get studyAspectExpression => 'Ausdruck';
@override
String get studyAspectMixed => 'Gemischt';
@override
String get studySettingsAspectDescription =>
'Was abgefragt wird — die Bedeutung, die Kana-Lesung oder der Ausdruck selbst (bei angezeigter Bedeutung). „Gemischt“ variiert Bedeutung und Lesung pro Karte; Karten, die nicht abgefragt werden können, bleiben ein normales Umdrehen.';
@override @override
String get studySortOldestFirst => 'Älteste zuerst'; String get studySortOldestFirst => 'Älteste zuerst';

View file

@ -2033,6 +2033,32 @@ class AppLocalizationsEn extends AppLocalizations {
String get studyModeCramHint => String get studyModeCramHint =>
'Walk every card without touching the schedules.'; 'Walk every card without touching the schedules.';
@override
String get studySettingsVocabulary => 'Vocabulary';
@override
String get studySettingsQuizMc => 'Quiz with multiple choice';
@override
String get studySettingsQuizMcDescription =>
'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.';
@override
String get studyAspectMeaning => 'Meaning';
@override
String get studyAspectReading => 'Reading';
@override
String get studyAspectExpression => 'Expression';
@override
String get studyAspectMixed => 'Mixed';
@override
String get studySettingsAspectDescription =>
'What to recall — the meaning, the kana reading, or the expression itself (shown the meaning). Mixed varies meaning and reading per card; cards that can’t be quizzed stay a normal flip.';
@override @override
String get studySortOldestFirst => 'Oldest first'; String get studySortOldestFirst => 'Oldest first';

View file

@ -18,10 +18,12 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
// (period and target), the rest days (dates as YYYY-MM-DD) and the home // (period and target), the rest days (dates as YYYY-MM-DD) and the home
// layout (the widgets' order and those hidden, by name). Schema 4 added the // layout (the widgets' order and those hidden, by name). Schema 4 added the
// Pomodoro timer's profiles and the one chosen, left out while as they come. // Pomodoro timer's profiles and the one chosen, left out while as they come.
// Older schemas read with what they lack as it comes. // Schema 5 added quizzing vocabulary as multiple choice (`quizVocabulary`,
// `quizAspect` in `study`; an aspect this app doesn't know reads as the
// meaning). Older schemas read with what they lack as it comes.
/// The schema this app writes. /// The schema this app writes.
const settingsSchemaVersion = 4; const settingsSchemaVersion = 5;
/// Encodes [settings] into their payload. /// Encodes [settings] into their payload.
ByteData encodeSettingsPayload(PersonSettings settings) { ByteData encodeSettingsPayload(PersonSettings settings) {
@ -45,6 +47,10 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
'cardsPerRound': study.cardsPerRound, 'cardsPerRound': study.cardsPerRound,
if (study.newPerDay != defaults.newPerDay) 'newPerDay': study.newPerDay, if (study.newPerDay != defaults.newPerDay) 'newPerDay': study.newPerDay,
if (study.backFirst != defaults.backFirst) 'backFirst': study.backFirst, if (study.backFirst != defaults.backFirst) 'backFirst': study.backFirst,
if (study.quizVocabulary != defaults.quizVocabulary)
'quizVocabulary': study.quizVocabulary,
if (study.quizAspect != defaults.quizAspect)
'quizAspect': study.quizAspect.name,
}, },
if (settings.goal case final goal?) if (settings.goal case final goal?)
'goal': {'period': goal.period.name, 'target': goal.target}, 'goal': {'period': goal.period.name, 'target': goal.target},
@ -138,6 +144,11 @@ StudyOptions _study(Object? json) {
final bool backFirst => backFirst, final bool backFirst => backFirst,
_ => null, _ => null,
}, },
quizVocabulary: switch (json['quizVocabulary']) {
final bool quiz => quiz,
_ => null,
},
quizAspect: named(VocabularyQuizAspect.values, json['quizAspect']),
); );
} }

View file

@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/vocabulary_quiz.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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/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/data/tag_repository.dart';
@ -104,6 +105,9 @@ class StudySessionBloc(
/// The cards set aside for the day: they aren't studied. /// The cards set aside for the day: they aren't studied.
var _setAside = <String>{}; var _setAside = <String>{};
/// What wrong answers are drawn from, while words are quizzed.
var _pools = const QuizPools(meanings: [], readings: [], expressions: []);
/// The card set aside last in this round, and when, to undo it. /// The card set aside last in this round, and when, to undo it.
({Card card, DateTime at, int index})? _lastBuried; ({Card card, DateTime at, int index})? _lastBuried;
@ -174,6 +178,7 @@ class StudySessionBloc(
}; };
_tagging = await _tags.read(); _tagging = await _tags.read();
final now = clock.now().toUtc(); final now = clock.now().toUtc();
_pools = QuizPools.of([for (final card in _cardsById.values) card.content]);
_setAside = await _reviews.buriedSince({ _setAside = await _reviews.buriedSince({
..._cardsById.keys, ..._cardsById.keys,
}, startOfLocalDay(now)); }, startOfLocalDay(now));
@ -361,6 +366,15 @@ class StudySessionBloc(
final card = _cardsById[id]!; final card = _cardsById[id]!;
return StudySessionStateShowing( return StudySessionStateShowing(
card: card, card: card,
// The same question each time the word comes, its order shuffled.
quiz: _options.quizVocabulary
? vocabularyQuiz(
card.content,
aspect: _options.quizAspect,
pools: _pools,
random: math.Random(card.id.hashCode),
)
: null,
studysetId: _studysetOf[id] ?? _studysetId, studysetId: _studysetOf[id] ?? _studysetId,
tags: _tagging.of(id), tags: _tagging.of(id),
asksBack: _asksBack(card), asksBack: _asksBack(card),
@ -860,6 +874,10 @@ final class const StudySessionStateShowing({
/// The card. /// The card.
required final Card card, required final Card card,
/// A word asked as multiple choice: the question made of it; null for
/// a card asked as it is.
final CardContent? quiz,
/// The studyset it is in. /// The studyset it is in.
required final String studysetId, required final String studysetId,
@ -924,6 +942,7 @@ final class const StudySessionStateShowing({
StudySessionStateShowing _copy({bool? answerShown, bool? ratingFailed}) => StudySessionStateShowing _copy({bool? answerShown, bool? ratingFailed}) =>
StudySessionStateShowing( StudySessionStateShowing(
card: card, card: card,
quiz: quiz,
studysetId: studysetId, studysetId: studysetId,
tags: tags, tags: tags,
asksBack: asksBack, asksBack: asksBack,
@ -947,6 +966,7 @@ final class const StudySessionStateShowing({
@override @override
List<Object?> get props => [ List<Object?> get props => [
card, card,
quiz,
studysetId, studysetId,
tags, tags,
asksBack, asksBack,

View file

@ -22,6 +22,21 @@ enum StudyOrder() {
newestFirst, newestFirst,
} }
/// What a vocabulary card quizzed as multiple choice asks.
enum VocabularyQuizAspect() {
/// Its meaning, the expression shown.
meaning,
/// Its reading, the expression shown.
reading,
/// The expression itself, its meaning shown.
expression,
/// The reading or the meaning, varying from card to card.
mixed,
}
/// How the person studies, the same on every device (in their settings). /// How the person studies, the same on every device (in their settings).
final class const StudyOptions({ final class const StudyOptions({
/// Spaced repetition or cramming. /// Spaced repetition or cramming.
@ -41,6 +56,13 @@ final class const StudyOptions({
/// Whether front/back and vocabulary cards ask their back, on a schedule /// Whether front/back and vocabulary cards ask their back, on a schedule
/// of their own (ADR 0004, rule 3). /// of their own (ADR 0004, rule 3).
final bool backFirst = false, final bool backFirst = false,
/// Whether vocabulary cards come as multiple choice, the wrong answers
/// drawn from the other words studied.
final bool quizVocabulary = false,
/// What a vocabulary card quizzed so asks.
final VocabularyQuizAspect quizAspect = VocabularyQuizAspect.meaning,
}) with Equatable { }) with Equatable {
/// The fewest cards a round holds. /// The fewest cards a round holds.
static const minCardsPerRound = 5; static const minCardsPerRound = 5;
@ -67,6 +89,8 @@ final class const StudyOptions({
int? cardsPerRound, int? cardsPerRound,
int? newPerDay, int? newPerDay,
bool? backFirst, bool? backFirst,
bool? quizVocabulary,
VocabularyQuizAspect? quizAspect,
}) => StudyOptions( }) => StudyOptions(
mode: mode ?? this.mode, mode: mode ?? this.mode,
order: order ?? this.order, order: order ?? this.order,
@ -79,8 +103,18 @@ final class const StudyOptions({
maxNewPerDay, maxNewPerDay,
), ),
backFirst: backFirst ?? this.backFirst, backFirst: backFirst ?? this.backFirst,
quizVocabulary: quizVocabulary ?? this.quizVocabulary,
quizAspect: quizAspect ?? this.quizAspect,
); );
@override @override
List<Object?> get props => [mode, order, cardsPerRound, newPerDay, backFirst]; List<Object?> get props => [
mode,
order,
cardsPerRound,
newPerDay,
backFirst,
quizVocabulary,
quizAspect,
];
} }

View file

@ -0,0 +1,130 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
/// The most options a word quizzed as multiple choice offers.
const _options = 4;
/// The fewest: a word with fewer stays one to flip.
const _fewestOptions = 3;
/// What wrong answers are drawn from: the main meanings, the readings and
/// the expressions of the words studied, each once (whatever its case).
final class const QuizPools({
required final List<String> meanings,
required final List<String> readings,
required final List<String> expressions,
}) with Equatable {
/// The pools of [words]; cards of other types are passed over.
factory of(Iterable<CardContent> words) {
final vocabulary = [
for (final word in words)
if (word.type == CardType.vocabulary) word,
];
return QuizPools(
meanings: _distinct([
for (final word in vocabulary) word.meanings.firstOrNull ?? '',
]),
readings: _distinct([for (final word in vocabulary) word.reading]),
expressions: _distinct([
for (final word in vocabulary) word.front.plainText,
]),
);
}
@override
List<Object?> get props => [meanings, readings, expressions];
}
/// [word] as a multiple-choice question asking [aspect]: its right answer
/// first and up to three wrong ones drawn from [pools] by [random]; null
/// when the word lacks what is asked, or there are fewer than two wrong
/// answers to draw, and it stays a word to flip. What counts as right
/// for the word (any of its meanings, whatever its case) is never offered
/// as wrong.
CardContent? vocabularyQuiz(
CardContent word, {
required VocabularyQuizAspect aspect,
required QuizPools pools,
required math.Random random,
}) {
if (word.type != CardType.vocabulary) {
return null;
}
CardContent? asking(VocabularyQuizAspect aspect) {
final meaning = word.meanings.firstOrNull?.trim() ?? '';
final (right, pool, prompt, alsoRight) = switch (aspect) {
VocabularyQuizAspect.meaning || VocabularyQuizAspect.mixed => (
meaning,
pools.meanings,
word.front,
word.meanings,
),
VocabularyQuizAspect.reading => (
word.reading.trim(),
pools.readings,
word.front,
[word.reading],
),
VocabularyQuizAspect.expression => (
word.front.plainText.trim(),
pools.expressions,
RichContent.plain(meaning),
[word.front.plainText],
),
};
if (right.isEmpty ||
(aspect == VocabularyQuizAspect.expression && meaning.isEmpty)) {
return null;
}
final taken = {for (final each in alsoRight) each.trim().toLowerCase()};
final wrong = <String>[];
for (final each in [...pool]..shuffle(random)) {
if (wrong.length == _options - 1) {
break;
}
final key = each.trim().toLowerCase();
if (key.isNotEmpty && taken.add(key)) {
wrong.add(each.trim());
}
}
if (wrong.length + 1 < _fewestOptions) {
return null;
}
return CardContent(
type: CardType.multipleChoice,
front: prompt,
back: RichContent.empty(),
answers: [
CardAnswer(content: RichContent.plain(right), correct: true),
for (final each in wrong) CardAnswer(content: RichContent.plain(each)),
],
);
}
if (aspect != VocabularyQuizAspect.mixed) {
return asking(aspect);
}
final first = random.nextBool()
? VocabularyQuizAspect.reading
: VocabularyQuizAspect.meaning;
final other = first == VocabularyQuizAspect.reading
? VocabularyQuizAspect.meaning
: VocabularyQuizAspect.reading;
return asking(first) ?? asking(other);
}
/// [values] trimmed, without blanks, each once whatever its case, in their
/// order.
List<String> _distinct(List<String> values) {
final seen = <String>{};
return [
for (final value in values)
if (value.trim() case final value
when value.isNotEmpty && seen.add(value.toLowerCase()))
value,
];
}

View file

@ -528,6 +528,10 @@ class const MultipleChoiceBody({
/// The row above the card's content. /// The row above the card's content.
required final Widget header, required final Widget header,
/// What is asked, when not the card's own content: a word quizzed as
/// multiple choice.
final CardContent? asked,
required final bool checked, required final bool checked,
required final VoidCallback onCheck, required final VoidCallback onCheck,
@ -543,19 +547,21 @@ class const MultipleChoiceBody({
} }
class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> { class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
CardContent get _content => widget.asked ?? widget.card.content;
late final List<int> _order = [ late final List<int> _order = [
for (var i = 0; i < widget.card.content.answers.length; i++) i, for (var i = 0; i < _content.answers.length; i++) i,
]..shuffle(math.Random()); ]..shuffle(math.Random());
final _picked = <int>{}; final _picked = <int>{};
bool get _several => bool get _several =>
widget.card.content.answers.where((answer) => answer.correct).length > 1; _content.answers.where((answer) => answer.correct).length > 1;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final answers = widget.card.content.answers; final answers = _content.answers;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -564,7 +570,7 @@ class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
header: widget.header, header: widget.header,
children: [ children: [
RichContentView( RichContentView(
content: widget.card.content.front, content: _content.front,
textStyle: theme.textTheme.headlineMedium, textStyle: theme.textTheme.headlineMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View file

@ -312,6 +312,17 @@ class const _Showing(
onRated: rate, onRated: rate,
); );
final body = switch (card.content.type) { final body = switch (card.content.type) {
// A word quizzed as multiple choice.
CardType.vocabulary when state.quiz != null => MultipleChoiceBody(
key: key,
card: card,
header: cardHeader,
asked: state.quiz,
checked: state.answerShown,
onCheck: show,
ratings: ratings,
ratingFailed: state.ratingFailed,
),
CardType.multipleChoice when card.content.map != null => MapBody( CardType.multipleChoice when card.content.map != null => MapBody(
key: key, key: key,
card: card, card: card,
@ -392,6 +403,7 @@ class const _Showing(
// Cards answered by picking or sorting have the ratings in their own // Cards answered by picking or sorting have the ratings in their own
// bodies, once checked; the others always below. // bodies, once checked; the others always below.
final ratedBelow = switch (card.content.type) { final ratedBelow = switch (card.content.type) {
CardType.vocabulary when state.quiz != null => false,
CardType.frontBack || CardType.vocabulary => true, CardType.frontBack || CardType.vocabulary => true,
CardType.multipleChoice || CardType.matching => false, CardType.multipleChoice || CardType.matching => false,
}; };

View file

@ -253,6 +253,46 @@ class const _Options({required final StudyOptions options})
], ],
), ),
), ),
const SizedBox(height: 20),
Text(l10n.studySettingsVocabulary, style: heading),
const SizedBox(height: 4),
SwitchListTile(
value: options.quizVocabulary,
onChanged: (quiz) => change(options.copyWith(quizVocabulary: quiz)),
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l10n.studySettingsQuizMc),
subtitle: Text(l10n.studySettingsQuizMcDescription),
),
if (options.quizVocabulary) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final aspect in VocabularyQuizAspect.values)
ChoiceChip(
label: Text(switch (aspect) {
VocabularyQuizAspect.meaning => l10n.studyAspectMeaning,
VocabularyQuizAspect.reading => l10n.studyAspectReading,
VocabularyQuizAspect.expression =>
l10n.studyAspectExpression,
VocabularyQuizAspect.mixed => l10n.studyAspectMixed,
}),
selected: options.quizAspect == aspect,
onSelected: (_) =>
change(options.copyWith(quizAspect: aspect)),
),
],
),
const SizedBox(height: 4),
Text(
l10n.studySettingsAspectDescription,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
], ],
); );
} }

View file

@ -74,6 +74,28 @@ void main() {
); );
}); });
test('quizzing vocabulary as multiple choice survives a round trip, in the '
'fifth schema; an aspect of a newer app reads as the meaning', () {
const settings = PersonSettings(
study: StudyOptions(
quizVocabulary: true,
quizAspect: VocabularyQuizAspect.mixed,
),
);
expect(settingsSchemaVersion, 5);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodeSettingsPayload(
encodePayload(5, {
'studyTags': {},
'study': {'quizVocabulary': true, 'quizAspect': 'kanji'},
}),
).study,
const StudyOptions(quizVocabulary: true),
);
});
test('study options keep their numbers in range', () { test('study options keep their numbers in range', () {
final options = const StudyOptions().copyWith( final options = const StudyOptions().copyWith(
cardsPerRound: 50, cardsPerRound: 50,
@ -166,7 +188,7 @@ void main() {
doNotDisturb: true, doNotDisturb: true,
); );
test('survive a round trip, in the fourth schema', () { test('survive a round trip, in the newest schema', () {
const settings = PersonSettings( const settings = PersonSettings(
pomodoro: PomodoroSettings( pomodoro: PomodoroSettings(
profiles: [PomodoroProfile.classic, mine], profiles: [PomodoroProfile.classic, mine],
@ -176,18 +198,23 @@ void main() {
final payload = encodeSettingsPayload(settings); final payload = encodeSettingsPayload(settings);
expect(settingsSchemaVersion, 4); expect(
expect(decodePayload(payload, newestSchema: 4)['schemaVersion'], 4); decodePayload(
payload,
newestSchema: settingsSchemaVersion,
)['schemaVersion'],
settingsSchemaVersion,
);
expect(decodeSettingsPayload(payload), settings); expect(decodeSettingsPayload(payload), settings);
}); });
test('as they come, are left out, in the fourth schema all the same', () { test('as they come, are left out, in the newest schema all the same', () {
final payload = decodePayload( final payload = decodePayload(
encodeSettingsPayload(const PersonSettings()), encodeSettingsPayload(const PersonSettings()),
newestSchema: 4, newestSchema: settingsSchemaVersion,
); );
expect(payload['schemaVersion'], 4); expect(payload['schemaVersion'], settingsSchemaVersion);
expect(payload.keys, isNot(contains('pomodoro'))); expect(payload.keys, isNot(contains('pomodoro')));
}); });

View file

@ -571,6 +571,34 @@ void main() {
}); });
}); });
test('with vocabulary quizzed, a word comes as multiple choice, its '
'wrong answers drawn from the other words', () async {
CardContent word(String expression, String meaning) => CardContent(
type: CardType.vocabulary,
front: text(expression),
back: RichContent.empty(),
meanings: [meaning],
);
await cards.create('japanese', word('食べる', 'to eat'));
await cards.create('japanese', word('飲む', 'to drink'));
await cards.create('japanese', word('見る', 'to see'));
await study(const StudyOptions(quizVocabulary: true));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final shown = await showing(bloc, '食べる');
final quiz = shown.quiz!;
expect(quiz.type, CardType.multipleChoice);
expect(quiz.answers, hasLength(3));
expect(
quiz.answers.singleWhere((answer) => answer.correct).content,
RichContent.plain('to eat'),
);
});
});
group('With the back first', () { group('With the back first', () {
test('front/back cards ask their back, on their own schedule; a ' test('front/back cards ask their back, on their own schedule; a '
'multiple-choice card asks as ever', () async { 'multiple-choice card asks as ever', () async {

View file

@ -0,0 +1,116 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/vocabulary_quiz.dart';
CardContent word(String expression, String reading, List<String> meanings) =>
CardContent(
type: CardType.vocabulary,
front: RichContent.plain(expression),
back: RichContent.empty(),
reading: reading,
meanings: meanings,
);
void main() {
final taberu = word('食べる', 'たべる', ['to eat', 'to live on']);
final words = [
taberu,
word('飲む', 'のむ', ['to drink']),
word('見る', 'みる', ['to see']),
word('聞く', 'きく', ['to hear']),
word('食う', 'くう', ['TO EAT']),
word('暮らす', 'くらす', ['to live on']),
];
final pools = QuizPools.of(words);
Set<String> options(CardContent quiz) => {
for (final answer in quiz.answers) answer.content.plainText.trim(),
};
test('asks the meaning of a word among three others, the meaning right', () {
final quiz = vocabularyQuiz(
taberu,
aspect: VocabularyQuizAspect.meaning,
pools: pools,
random: math.Random(1),
)!;
expect(quiz.type, CardType.multipleChoice);
expect(quiz.front, taberu.front);
expect(quiz.answers, hasLength(4));
expect(
[
for (final answer in quiz.answers)
if (answer.correct) answer.content.plainText.trim(),
],
['to eat'],
);
// None of its own meanings counts as wrong, in any case.
expect(options(quiz), isNot(contains('TO EAT')));
expect(options(quiz), isNot(contains('to live on')));
});
test('asks the reading, or the expression for the meaning', () {
final reading = vocabularyQuiz(
taberu,
aspect: VocabularyQuizAspect.reading,
pools: pools,
random: math.Random(1),
)!;
expect(reading.front, taberu.front);
expect(options(reading), contains('たべる'));
final expression = vocabularyQuiz(
taberu,
aspect: VocabularyQuizAspect.expression,
pools: pools,
random: math.Random(1),
)!;
expect(expression.front.plainText.trim(), 'to eat');
expect(options(expression), contains('食べる'));
});
test('stays a word to flip without enough to choose from, or without the '
'aspect asked', () {
final few = QuizPools.of([
taberu,
word('飲む', 'のむ', ['to drink']),
]);
expect(
vocabularyQuiz(
taberu,
aspect: VocabularyQuizAspect.meaning,
pools: few,
random: math.Random(1),
),
isNull,
);
expect(
vocabularyQuiz(
word('すごい', '', ['amazing']),
aspect: VocabularyQuizAspect.reading,
pools: pools,
random: math.Random(1),
),
isNull,
);
});
test('mixed asks the reading or the meaning, never the expression', () {
for (var seed = 0; seed < 10; seed++) {
final quiz = vocabularyQuiz(
taberu,
aspect: VocabularyQuizAspect.mixed,
pools: pools,
random: math.Random(seed),
)!;
expect(quiz.front, taberu.front);
final right = quiz.answers.singleWhere((answer) => answer.correct);
expect(['たべる', 'to eat'], contains(right.content.plainText.trim()));
}
});
}

View file

@ -5,7 +5,9 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart'; import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.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_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -451,6 +453,34 @@ void main() {
expect(find.byTooltip('usually written using kana alone'), findsOneWidget); expect(find.byTooltip('usually written using kana alone'), findsOneWidget);
}); });
testWidgets('with vocabulary quizzed, a word asks its meaning among '
'others to choose', (tester) async {
for (final (expression, meaning) in [
('食べる', 'to eat'),
('飲む', 'to drink'),
('見る', 'to see'),
]) {
await cards.create(
'japanese',
CardContent(
type: CardType.vocabulary,
front: paragraph(expression),
back: RichContent.empty(),
meanings: [meaning],
),
);
}
final settings = SettingsRepository(settingsGateway);
addTearDown(settings.dispose);
await settings.update(
(each) => each.withStudy(const StudyOptions(quizVocabulary: true)),
);
await study(tester);
expect(find.text('Select the correct answer'), findsOneWidget);
expect(find.textContaining('to drink', findRichText: true), findsOneWidget);
});
testWidgets('the session settings narrow the cards studied to the tags ' testWidgets('the session settings narrow the cards studied to the tags '
'chosen', (tester) async { 'chosen', (tester) async {
final verb = await tags.create('verb'); final verb = await tags.create('verb');