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:
parent
2b188641c3
commit
6893db7da8
16 changed files with 583 additions and 13 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue