feat(app): type a word's reading while studying
A new study option, Type the reading (settings schema 6, beside the vocabulary quiz; turning one on turns the other off): a word with kanji shows alone, and its reading is typed, in kana; romaji turn into kana as they are typed (doubled consonants, n before a consonant, nn and n'), and the device's own Japanese input works as it does. Check, or Enter, says whether it was right (katakana counts as hiragana) and what was typed beside the reading, then shows the meanings and the ratings; the person still rates, as Anki's typed answers have it. A word written in kana, one usually written so, and a card asked back first flip as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
30dde3b8fa
commit
02d6542cfa
15 changed files with 571 additions and 5 deletions
|
|
@ -732,6 +732,11 @@
|
||||||
"studySettingsVocabulary": "Vokabeln",
|
"studySettingsVocabulary": "Vokabeln",
|
||||||
"studySettingsQuizMc": "Als Multiple-Choice abfragen",
|
"studySettingsQuizMc": "Als Multiple-Choice abfragen",
|
||||||
"studySettingsQuizMcDescription": "Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.",
|
"studySettingsQuizMcDescription": "Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.",
|
||||||
|
"studySettingsTypeReading": "Lesung eintippen",
|
||||||
|
"studySettingsTypeReadingDescription": "Wörter mit Kanji fragen nach ihrer Lesung, in Kana getippt; Romaji werden beim Tippen zu Kana.",
|
||||||
|
"studyTypedHint": "Die Lesung, in Kana oder Romaji",
|
||||||
|
"studyTypedRight": "Richtig",
|
||||||
|
"studyTypedWrong": "Du hast {answer} getippt",
|
||||||
"studyAspectMeaning": "Bedeutung",
|
"studyAspectMeaning": "Bedeutung",
|
||||||
"studyAspectReading": "Lesung",
|
"studyAspectReading": "Lesung",
|
||||||
"studyAspectExpression": "Ausdruck",
|
"studyAspectExpression": "Ausdruck",
|
||||||
|
|
|
||||||
|
|
@ -820,6 +820,12 @@
|
||||||
"studySettingsVocabulary": "Vocabulary",
|
"studySettingsVocabulary": "Vocabulary",
|
||||||
"studySettingsQuizMc": "Quiz with multiple choice",
|
"studySettingsQuizMc": "Quiz with multiple choice",
|
||||||
"studySettingsQuizMcDescription": "Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.",
|
"studySettingsQuizMcDescription": "Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.",
|
||||||
|
"studySettingsTypeReading": "Type the reading",
|
||||||
|
"studySettingsTypeReadingDescription": "Words with kanji ask for their reading, typed in kana; romaji turn into kana as you type.",
|
||||||
|
"studyTypedHint": "The reading, in kana or romaji",
|
||||||
|
"studyTypedRight": "Right",
|
||||||
|
"studyTypedWrong": "You typed {answer}",
|
||||||
|
"@studyTypedWrong": {"placeholders": {"answer": {"type": "String"}}},
|
||||||
"studyAspectMeaning": "Meaning",
|
"studyAspectMeaning": "Meaning",
|
||||||
"studyAspectReading": "Reading",
|
"studyAspectReading": "Reading",
|
||||||
"studyAspectExpression": "Expression",
|
"studyAspectExpression": "Expression",
|
||||||
|
|
|
||||||
|
|
@ -4396,6 +4396,36 @@ abstract class AppLocalizations {
|
||||||
/// **'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.'**
|
/// **'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.'**
|
||||||
String get studySettingsQuizMcDescription;
|
String get studySettingsQuizMcDescription;
|
||||||
|
|
||||||
|
/// No description provided for @studySettingsTypeReading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Type the reading'**
|
||||||
|
String get studySettingsTypeReading;
|
||||||
|
|
||||||
|
/// No description provided for @studySettingsTypeReadingDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Words with kanji ask for their reading, typed in kana; romaji turn into kana as you type.'**
|
||||||
|
String get studySettingsTypeReadingDescription;
|
||||||
|
|
||||||
|
/// No description provided for @studyTypedHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The reading, in kana or romaji'**
|
||||||
|
String get studyTypedHint;
|
||||||
|
|
||||||
|
/// No description provided for @studyTypedRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Right'**
|
||||||
|
String get studyTypedRight;
|
||||||
|
|
||||||
|
/// No description provided for @studyTypedWrong.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You typed {answer}'**
|
||||||
|
String studyTypedWrong(String answer);
|
||||||
|
|
||||||
/// No description provided for @studyAspectMeaning.
|
/// No description provided for @studyAspectMeaning.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2687,6 +2687,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get studySettingsQuizMcDescription =>
|
String get studySettingsQuizMcDescription =>
|
||||||
'Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.';
|
'Zeige Vokabelkarten als Multiple-Choice-Frage, mit falschen Antworten aus anderen Vokabeln in diesem Set.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studySettingsTypeReading => 'Lesung eintippen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studySettingsTypeReadingDescription =>
|
||||||
|
'Wörter mit Kanji fragen nach ihrer Lesung, in Kana getippt; Romaji werden beim Tippen zu Kana.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyTypedHint => 'Die Lesung, in Kana oder Romaji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyTypedRight => 'Richtig';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studyTypedWrong(String answer) {
|
||||||
|
return 'Du hast $answer getippt';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studyAspectMeaning => 'Bedeutung';
|
String get studyAspectMeaning => 'Bedeutung';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2673,6 +2673,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get studySettingsQuizMcDescription =>
|
String get studySettingsQuizMcDescription =>
|
||||||
'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.';
|
'Show vocabulary cards as a multiple-choice question, with wrong answers drawn from other vocabulary in this set.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studySettingsTypeReading => 'Type the reading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studySettingsTypeReadingDescription =>
|
||||||
|
'Words with kanji ask for their reading, typed in kana; romaji turn into kana as you type.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyTypedHint => 'The reading, in kana or romaji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyTypedRight => 'Right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studyTypedWrong(String answer) {
|
||||||
|
return 'You typed $answer';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studyAspectMeaning => 'Meaning';
|
String get studyAspectMeaning => 'Meaning';
|
||||||
|
|
||||||
|
|
|
||||||
130
halkyon_learn_flutter/lib/src/core/japanese/kana_input.dart
Normal file
130
halkyon_learn_flutter/lib/src/core/japanese/kana_input.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// [text] with its katakana as hiragana (`タベル` is `たべる`), the
|
||||||
|
/// long-vowel mark and everything else as it is: two readings are the same
|
||||||
|
/// when this makes them equal.
|
||||||
|
String katakanaToHiragana(String text) => String.fromCharCodes([
|
||||||
|
for (final code in text.runes)
|
||||||
|
// ァ (U+30A1) to ヶ (U+30F6) lie 0x60 above ぁ to ゖ.
|
||||||
|
if (code >= 0x30A1 && code <= 0x30F6) code - 0x60 else code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// Romaji, as people type it, and the kana it stands for; Hepburn, Kunrei
|
||||||
|
/// and the ways keyboards spell small kana (`xya`, `ltu`).
|
||||||
|
const _romaji = {
|
||||||
|
// Three letters.
|
||||||
|
'kya': 'きゃ', 'kyu': 'きゅ', 'kyo': 'きょ',
|
||||||
|
'gya': 'ぎゃ', 'gyu': 'ぎゅ', 'gyo': 'ぎょ',
|
||||||
|
'sha': 'しゃ', 'shu': 'しゅ', 'sho': 'しょ', 'shi': 'し', 'she': 'しぇ',
|
||||||
|
'sya': 'しゃ', 'syu': 'しゅ', 'syo': 'しょ',
|
||||||
|
'cha': 'ちゃ', 'chu': 'ちゅ', 'cho': 'ちょ', 'chi': 'ち', 'che': 'ちぇ',
|
||||||
|
'tya': 'ちゃ', 'tyu': 'ちゅ', 'tyo': 'ちょ',
|
||||||
|
'tsu': 'つ', 'nya': 'にゃ', 'nyu': 'にゅ', 'nyo': 'にょ',
|
||||||
|
'hya': 'ひゃ', 'hyu': 'ひゅ', 'hyo': 'ひょ',
|
||||||
|
'bya': 'びゃ', 'byu': 'びゅ', 'byo': 'びょ',
|
||||||
|
'pya': 'ぴゃ', 'pyu': 'ぴゅ', 'pyo': 'ぴょ',
|
||||||
|
'mya': 'みゃ', 'myu': 'みゅ', 'myo': 'みょ',
|
||||||
|
'rya': 'りゃ', 'ryu': 'りゅ', 'ryo': 'りょ',
|
||||||
|
'jya': 'じゃ', 'jyu': 'じゅ', 'jyo': 'じょ',
|
||||||
|
'zya': 'じゃ', 'zyu': 'じゅ', 'zyo': 'じょ',
|
||||||
|
'dya': 'ぢゃ', 'dyu': 'ぢゅ', 'dyo': 'ぢょ',
|
||||||
|
'xya': 'ゃ', 'xyu': 'ゅ', 'xyo': 'ょ', 'lya': 'ゃ', 'lyu': 'ゅ', 'lyo': 'ょ',
|
||||||
|
'xtu': 'っ', 'ltu': 'っ',
|
||||||
|
// Two letters.
|
||||||
|
'ka': 'か', 'ki': 'き', 'ku': 'く', 'ke': 'け', 'ko': 'こ',
|
||||||
|
'ga': 'が', 'gi': 'ぎ', 'gu': 'ぐ', 'ge': 'げ', 'go': 'ご',
|
||||||
|
'sa': 'さ', 'si': 'し', 'su': 'す', 'se': 'せ', 'so': 'そ',
|
||||||
|
'za': 'ざ', 'zi': 'じ', 'zu': 'ず', 'ze': 'ぜ', 'zo': 'ぞ',
|
||||||
|
'ja': 'じゃ', 'ji': 'じ', 'ju': 'じゅ', 'je': 'じぇ', 'jo': 'じょ',
|
||||||
|
'ta': 'た', 'ti': 'ち', 'tu': 'つ', 'te': 'て', 'to': 'と',
|
||||||
|
'da': 'だ', 'di': 'ぢ', 'du': 'づ', 'de': 'で', 'do': 'ど',
|
||||||
|
'na': 'な', 'ni': 'に', 'nu': 'ぬ', 'ne': 'ね', 'no': 'の',
|
||||||
|
'ha': 'は', 'hi': 'ひ', 'hu': 'ふ', 'fu': 'ふ', 'he': 'へ', 'ho': 'ほ',
|
||||||
|
'fa': 'ふぁ', 'fi': 'ふぃ', 'fe': 'ふぇ', 'fo': 'ふぉ',
|
||||||
|
'ba': 'ば', 'bi': 'び', 'bu': 'ぶ', 'be': 'べ', 'bo': 'ぼ',
|
||||||
|
'pa': 'ぱ', 'pi': 'ぴ', 'pu': 'ぷ', 'pe': 'ぺ', 'po': 'ぽ',
|
||||||
|
'ma': 'ま', 'mi': 'み', 'mu': 'む', 'me': 'め', 'mo': 'も',
|
||||||
|
'ya': 'や', 'yu': 'ゆ', 'yo': 'よ',
|
||||||
|
'ra': 'ら', 'ri': 'り', 'ru': 'る', 're': 'れ', 'ro': 'ろ',
|
||||||
|
'la': 'ら', 'li': 'り', 'lu': 'る', 'le': 'れ', 'lo': 'ろ',
|
||||||
|
'wa': 'わ', 'wi': 'うぃ', 'we': 'うぇ', 'wo': 'を',
|
||||||
|
'va': 'ゔぁ', 'vi': 'ゔぃ', 'vu': 'ゔ', 've': 'ゔぇ', 'vo': 'ゔぉ',
|
||||||
|
'nn': 'ん', "n'": 'ん',
|
||||||
|
'xa': 'ぁ', 'xi': 'ぃ', 'xu': 'ぅ', 'xe': 'ぇ', 'xo': 'ぉ',
|
||||||
|
// One letter.
|
||||||
|
'a': 'あ', 'i': 'い', 'u': 'う', 'e': 'え', 'o': 'お', '-': 'ー',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [text] with the romaji typed in it turned into hiragana as far as they
|
||||||
|
/// spell kana (`taberu` is `たべる`): a doubled consonant is a small っ
|
||||||
|
/// (`kitte`), `n` before a consonant ん. What can still become more kana is
|
||||||
|
/// left as typed (`tab` is `たb`, a last `n` stays `n`), unless [finished].
|
||||||
|
/// Kana and anything else stay as they are.
|
||||||
|
String romajiToHiragana(String text, {bool finished = false}) {
|
||||||
|
final out = StringBuffer();
|
||||||
|
final lower = text.toLowerCase();
|
||||||
|
var i = 0;
|
||||||
|
while (i < lower.length) {
|
||||||
|
final rest = lower.substring(i);
|
||||||
|
String? kana;
|
||||||
|
var length = 0;
|
||||||
|
for (final size in const [3, 2, 1]) {
|
||||||
|
if (rest.length >= size && _romaji[rest.substring(0, size)] != null) {
|
||||||
|
kana = _romaji[rest.substring(0, size)];
|
||||||
|
length = size;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kana != null) {
|
||||||
|
out.write(kana);
|
||||||
|
i += length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final char = rest[0];
|
||||||
|
final next = rest.length > 1 ? rest[1] : null;
|
||||||
|
if (char == 'n' && next != null && !"aiueoyn'".contains(next)) {
|
||||||
|
// ん before another consonant.
|
||||||
|
out.write('ん');
|
||||||
|
i++;
|
||||||
|
} else if (next == char && 'bcdfghjkmpqrstvwxyz'.contains(char)) {
|
||||||
|
// A doubled consonant: a small っ, the second starting the next kana.
|
||||||
|
out.write('っ');
|
||||||
|
i++;
|
||||||
|
} else if (char == 't' && next == 'c') {
|
||||||
|
// っち, as Hepburn writes it: "matcha".
|
||||||
|
out.write('っ');
|
||||||
|
i++;
|
||||||
|
} else if (char == 'n' && next == null && finished) {
|
||||||
|
out.write('ん');
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
out.write(text[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns romaji into hiragana as they are typed, for answers in kana on a
|
||||||
|
/// keyboard without Japanese: what can't be kana yet stays as typed, the
|
||||||
|
/// caret at the end. Text being composed by the device's own Japanese input
|
||||||
|
/// is left to it.
|
||||||
|
class const KanaInputFormatter() extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
if (newValue.composing.isValid && !newValue.composing.isCollapsed) {
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
final kana = romajiToHiragana(newValue.text);
|
||||||
|
if (kana == newValue.text) {
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
return TextEditingValue(
|
||||||
|
text: kana,
|
||||||
|
selection: TextSelection.collapsed(offset: kana.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,10 +20,12 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
// 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.
|
||||||
// Schema 5 added quizzing vocabulary as multiple choice (`quizVocabulary`,
|
// Schema 5 added quizzing vocabulary as multiple choice (`quizVocabulary`,
|
||||||
// `quizAspect` in `study`; an aspect this app doesn't know reads as the
|
// `quizAspect` in `study`; an aspect this app doesn't know reads as the
|
||||||
// meaning). Older schemas read with what they lack as it comes.
|
// meaning). Schema 6 added typing the readings of vocabulary
|
||||||
|
// (`typeReadings` in `study`). Older schemas read with what they lack as it
|
||||||
|
// comes.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const settingsSchemaVersion = 5;
|
const settingsSchemaVersion = 6;
|
||||||
|
|
||||||
/// Encodes [settings] into their payload.
|
/// Encodes [settings] into their payload.
|
||||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
|
|
@ -51,6 +53,8 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
'quizVocabulary': study.quizVocabulary,
|
'quizVocabulary': study.quizVocabulary,
|
||||||
if (study.quizAspect != defaults.quizAspect)
|
if (study.quizAspect != defaults.quizAspect)
|
||||||
'quizAspect': study.quizAspect.name,
|
'quizAspect': study.quizAspect.name,
|
||||||
|
if (study.typeReadings != defaults.typeReadings)
|
||||||
|
'typeReadings': study.typeReadings,
|
||||||
},
|
},
|
||||||
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},
|
||||||
|
|
@ -149,6 +153,10 @@ StudyOptions _study(Object? json) {
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
quizAspect: named(VocabularyQuizAspect.values, json['quizAspect']),
|
quizAspect: named(VocabularyQuizAspect.values, json['quizAspect']),
|
||||||
|
typeReadings: switch (json['typeReadings']) {
|
||||||
|
final bool type => type,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,16 @@ class StudySessionBloc(
|
||||||
emit(_showing(next, now, schedule: schedules[next]));
|
emit(_showing(next, now, schedule: schedules[next]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [card]'s reading is to be typed: a word with kanji, asked
|
||||||
|
/// front first, while the person types readings and isn't quizzed.
|
||||||
|
bool _typesReading(Card card) =>
|
||||||
|
_options.typeReadings &&
|
||||||
|
!_options.quizVocabulary &&
|
||||||
|
card.content.type == CardType.vocabulary &&
|
||||||
|
card.content.reading.trim().isNotEmpty &&
|
||||||
|
!card.content.usuallyKana &&
|
||||||
|
!_asksBack(card);
|
||||||
|
|
||||||
/// How far ahead a card in its learning steps comes early, once nothing
|
/// How far ahead a card in its learning steps comes early, once nothing
|
||||||
/// else is due in the round.
|
/// else is due in the round.
|
||||||
static const _learnAhead = Duration(minutes: 20);
|
static const _learnAhead = Duration(minutes: 20);
|
||||||
|
|
@ -479,6 +489,7 @@ class StudySessionBloc(
|
||||||
random: math.Random(card.id.hashCode),
|
random: math.Random(card.id.hashCode),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
typesReading: _typesReading(card),
|
||||||
studysetId: _studysetOf[id] ?? _studysetId,
|
studysetId: _studysetOf[id] ?? _studysetId,
|
||||||
tags: _tagging.of(id),
|
tags: _tagging.of(id),
|
||||||
asksBack: _asksBack(card),
|
asksBack: _asksBack(card),
|
||||||
|
|
@ -1089,6 +1100,9 @@ final class const StudySessionStateShowing({
|
||||||
/// a card asked as it is.
|
/// a card asked as it is.
|
||||||
final CardContent? quiz,
|
final CardContent? quiz,
|
||||||
|
|
||||||
|
/// Whether the word's reading is to be typed, in kana.
|
||||||
|
final bool typesReading = false,
|
||||||
|
|
||||||
/// The studyset it is in.
|
/// The studyset it is in.
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
|
|
||||||
|
|
@ -1158,6 +1172,7 @@ final class const StudySessionStateShowing({
|
||||||
StudySessionStateShowing(
|
StudySessionStateShowing(
|
||||||
card: card,
|
card: card,
|
||||||
quiz: quiz,
|
quiz: quiz,
|
||||||
|
typesReading: typesReading,
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
asksBack: asksBack,
|
asksBack: asksBack,
|
||||||
|
|
@ -1183,6 +1198,7 @@ final class const StudySessionStateShowing({
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
card,
|
card,
|
||||||
quiz,
|
quiz,
|
||||||
|
typesReading,
|
||||||
studysetId,
|
studysetId,
|
||||||
tags,
|
tags,
|
||||||
asksBack,
|
asksBack,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ final class const StudyOptions({
|
||||||
|
|
||||||
/// What a vocabulary card quizzed so asks.
|
/// What a vocabulary card quizzed so asks.
|
||||||
final VocabularyQuizAspect quizAspect = VocabularyQuizAspect.meaning,
|
final VocabularyQuizAspect quizAspect = VocabularyQuizAspect.meaning,
|
||||||
|
|
||||||
|
/// Whether a word with a reading asks for it typed, in kana; words in
|
||||||
|
/// kana alone, and those asked back first, flip as before. Not together
|
||||||
|
/// with [quizVocabulary], which the settings turn off.
|
||||||
|
final bool typeReadings = false,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// The fewest cards a round holds.
|
/// The fewest cards a round holds.
|
||||||
static const minCardsPerRound = 5;
|
static const minCardsPerRound = 5;
|
||||||
|
|
@ -91,6 +96,7 @@ final class const StudyOptions({
|
||||||
bool? backFirst,
|
bool? backFirst,
|
||||||
bool? quizVocabulary,
|
bool? quizVocabulary,
|
||||||
VocabularyQuizAspect? quizAspect,
|
VocabularyQuizAspect? quizAspect,
|
||||||
|
bool? typeReadings,
|
||||||
}) => StudyOptions(
|
}) => StudyOptions(
|
||||||
mode: mode ?? this.mode,
|
mode: mode ?? this.mode,
|
||||||
order: order ?? this.order,
|
order: order ?? this.order,
|
||||||
|
|
@ -105,6 +111,7 @@ final class const StudyOptions({
|
||||||
backFirst: backFirst ?? this.backFirst,
|
backFirst: backFirst ?? this.backFirst,
|
||||||
quizVocabulary: quizVocabulary ?? this.quizVocabulary,
|
quizVocabulary: quizVocabulary ?? this.quizVocabulary,
|
||||||
quizAspect: quizAspect ?? this.quizAspect,
|
quizAspect: quizAspect ?? this.quizAspect,
|
||||||
|
typeReadings: typeReadings ?? this.typeReadings,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -116,5 +123,6 @@ final class const StudyOptions({
|
||||||
backFirst,
|
backFirst,
|
||||||
quizVocabulary,
|
quizVocabulary,
|
||||||
quizAspect,
|
quizAspect,
|
||||||
|
typeReadings,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/japanese/kana_input.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||||
|
|
@ -317,6 +318,160 @@ class const VocabularyBody({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A word whose reading is typed: its expression large and centred, a
|
||||||
|
/// field for the reading in kana (romaji turn into kana as typed, the
|
||||||
|
/// device's own Japanese input works too), Check below the card or Enter;
|
||||||
|
/// once checked, whether it was right, what was typed beside the reading,
|
||||||
|
/// the word's meanings, and the ratings.
|
||||||
|
class const TypedReadingBody({
|
||||||
|
required final Card card,
|
||||||
|
|
||||||
|
/// The row above the card's content.
|
||||||
|
required final Widget header,
|
||||||
|
required final bool checked,
|
||||||
|
required final VoidCallback onCheck,
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
State<TypedReadingBody> createState() => _TypedReadingBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypedReadingBodyState() extends State<TypedReadingBody> {
|
||||||
|
final _typed = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Check follows whether anything is typed.
|
||||||
|
_typed.addListener(() => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_typed.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What was typed as a reading: in hiragana, without spaces, a last `n`
|
||||||
|
/// as ん.
|
||||||
|
String get _answer => _normalized(_typed.text);
|
||||||
|
|
||||||
|
static String _normalized(String reading) => katakanaToHiragana(
|
||||||
|
romajiToHiragana(reading, finished: true),
|
||||||
|
).replaceAll(RegExp(r'\s+'), '');
|
||||||
|
|
||||||
|
void _check() {
|
||||||
|
if (_typed.text.trim().isNotEmpty && !widget.checked) {
|
||||||
|
widget.onCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final content = widget.card.content;
|
||||||
|
final right = _answer == _normalized(content.reading);
|
||||||
|
final mark = right ? theme.extras.success : theme.colorScheme.error;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StudyCardSurface(
|
||||||
|
header: widget.header,
|
||||||
|
children: [
|
||||||
|
_Expression(
|
||||||
|
card: widget.card,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
showReading: widget.checked,
|
||||||
|
),
|
||||||
|
..._hintsPart(widget.card),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (!widget.checked)
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: TextField(
|
||||||
|
controller: _typed,
|
||||||
|
autofocus: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
inputFormatters: const [KanaInputFormatter()],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.studyTypedHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _check(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
right ? LucideIcons.circleCheckBig : LucideIcons.circleX,
|
||||||
|
color: mark,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
right
|
||||||
|
? l10n.studyTypedRight
|
||||||
|
: l10n.studyTypedWrong(
|
||||||
|
romajiToHiragana(
|
||||||
|
_typed.text.trim(),
|
||||||
|
finished: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: mark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (content.senses.isEmpty)
|
||||||
|
_Meanings(
|
||||||
|
meanings: content.meanings,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_Senses(
|
||||||
|
senses: content.senses,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
if (content.explanation case final explanation?) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Center(child: ExplanationButton(explanation: explanation)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_CheckOrRate(
|
||||||
|
checked: widget.checked,
|
||||||
|
canCheck: _typed.text.trim().isNotEmpty,
|
||||||
|
onCheck: _check,
|
||||||
|
ratings: widget.ratings,
|
||||||
|
ratingFailed: widget.ratingFailed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A word's expression in [style], centred, with its reading above it in a
|
/// A word's expression in [style], centred, with its reading above it in a
|
||||||
/// little over half the size when [showReading].
|
/// little over half the size when [showReading].
|
||||||
class const _Expression({
|
class const _Expression({
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,16 @@ class const _Showing(
|
||||||
onRated: rate,
|
onRated: rate,
|
||||||
);
|
);
|
||||||
final body = switch (card.content.type) {
|
final body = switch (card.content.type) {
|
||||||
|
// A word whose reading is typed.
|
||||||
|
CardType.vocabulary when state.typesReading => TypedReadingBody(
|
||||||
|
key: key,
|
||||||
|
card: card,
|
||||||
|
header: cardHeader,
|
||||||
|
checked: state.answerShown,
|
||||||
|
onCheck: show,
|
||||||
|
ratings: ratings,
|
||||||
|
ratingFailed: state.ratingFailed,
|
||||||
|
),
|
||||||
// A word quizzed as multiple choice.
|
// A word quizzed as multiple choice.
|
||||||
CardType.vocabulary when state.quiz != null => MultipleChoiceBody(
|
CardType.vocabulary when state.quiz != null => MultipleChoiceBody(
|
||||||
key: key,
|
key: key,
|
||||||
|
|
@ -489,7 +499,8 @@ 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.vocabulary when state.quiz != null || state.typesReading =>
|
||||||
|
false,
|
||||||
CardType.frontBack || CardType.vocabulary => true,
|
CardType.frontBack || CardType.vocabulary => true,
|
||||||
CardType.multipleChoice || CardType.matching => false,
|
CardType.multipleChoice || CardType.matching => false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,12 @@ class const _Options({required final StudyOptions options})
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
value: options.quizVocabulary,
|
value: options.quizVocabulary,
|
||||||
onChanged: (quiz) => change(options.copyWith(quizVocabulary: quiz)),
|
onChanged: (quiz) => change(
|
||||||
|
options.copyWith(
|
||||||
|
quizVocabulary: quiz,
|
||||||
|
typeReadings: quiz ? false : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(l10n.studySettingsQuizMc),
|
title: Text(l10n.studySettingsQuizMc),
|
||||||
|
|
@ -310,6 +315,19 @@ class const _Options({required final StudyOptions options})
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
SwitchListTile(
|
||||||
|
value: options.typeReadings,
|
||||||
|
onChanged: (type) => change(
|
||||||
|
options.copyWith(
|
||||||
|
typeReadings: type,
|
||||||
|
quizVocabulary: type ? false : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: Text(l10n.studySettingsTypeReading),
|
||||||
|
subtitle: Text(l10n.studySettingsTypeReadingDescription),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/japanese/kana_input.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('romaji become hiragana as far as they spell kana', () {
|
||||||
|
expect(romajiToHiragana('taberu'), 'たべる');
|
||||||
|
expect(romajiToHiragana('tab'), 'たb');
|
||||||
|
expect(romajiToHiragana('kitte'), 'きって');
|
||||||
|
expect(romajiToHiragana('matcha'), 'まっちゃ');
|
||||||
|
expect(romajiToHiragana('shinbun'), 'しんぶn');
|
||||||
|
expect(romajiToHiragana('shinbun', finished: true), 'しんぶん');
|
||||||
|
expect(romajiToHiragana('konnnichiha'), 'こんにちは');
|
||||||
|
expect(romajiToHiragana("kan'i"), 'かんい');
|
||||||
|
expect(romajiToHiragana('toukyou'), 'とうきょう');
|
||||||
|
expect(romajiToHiragana('si ti tu hu zi'), 'し ち つ ふ じ');
|
||||||
|
expect(romajiToHiragana('Kaado'), 'かあど');
|
||||||
|
expect(romajiToHiragana('ra-men'), 'らーめn');
|
||||||
|
expect(romajiToHiragana('たべru'), 'たべる');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('katakana count as hiragana', () {
|
||||||
|
expect(katakanaToHiragana('タベル'), 'たべる');
|
||||||
|
expect(katakanaToHiragana('ラーメン'), 'らーめん');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typing turns romaji into kana, leaving the device's own Japanese "
|
||||||
|
'input alone', () {
|
||||||
|
const formatter = KanaInputFormatter();
|
||||||
|
final typed = formatter.formatEditUpdate(
|
||||||
|
TextEditingValue.empty,
|
||||||
|
const TextEditingValue(text: 'tabe'),
|
||||||
|
);
|
||||||
|
expect(typed.text, 'たべ');
|
||||||
|
expect(typed.selection, const TextSelection.collapsed(offset: 2));
|
||||||
|
|
||||||
|
const composing = TextEditingValue(
|
||||||
|
text: 'tabe',
|
||||||
|
composing: TextRange(start: 0, end: 4),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
formatter.formatEditUpdate(TextEditingValue.empty, composing),
|
||||||
|
composing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ void main() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(settingsSchemaVersion, 5);
|
expect(settingsSchemaVersion, greaterThanOrEqualTo(5));
|
||||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||||
expect(
|
expect(
|
||||||
decodeSettingsPayload(
|
decodeSettingsPayload(
|
||||||
|
|
@ -96,6 +96,20 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('typing readings survives a round trip, in the sixth schema; a '
|
||||||
|
'payload from before has it off', () {
|
||||||
|
const settings = PersonSettings(study: StudyOptions(typeReadings: true));
|
||||||
|
|
||||||
|
expect(settingsSchemaVersion, 6);
|
||||||
|
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||||
|
expect(
|
||||||
|
decodeSettingsPayload(
|
||||||
|
encodePayload(5, {'studyTags': {}, 'study': {}}),
|
||||||
|
).study.typeReadings,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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/widget/rating_row.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_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';
|
||||||
|
|
@ -548,6 +549,88 @@ void main() {
|
||||||
expect(find.textContaining('to drink', findRichText: true), findsOneWidget);
|
expect(find.textContaining('to drink', findRichText: true), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('typing the reading: romaji turn into kana, Check says whether '
|
||||||
|
'it was right, then the meanings and the ratings; a word in kana '
|
||||||
|
'flips', (tester) async {
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: paragraph('食べる'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
reading: 'たべる',
|
||||||
|
meanings: const ['to eat'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final settings = SettingsRepository(settingsGateway);
|
||||||
|
addTearDown(settings.dispose);
|
||||||
|
await settings.update(
|
||||||
|
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
|
||||||
|
);
|
||||||
|
await study(tester);
|
||||||
|
|
||||||
|
expect(find.text('Show answer'), findsNothing);
|
||||||
|
await tester.enterText(find.byType(TextField), 'tabemasu');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('たべます'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Check'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('You typed たべます'), findsOneWidget);
|
||||||
|
// The reading above the word, and the meaning, once checked.
|
||||||
|
expect(find.text('たべる'), findsOneWidget);
|
||||||
|
expect(find.text('to eat'), findsOneWidget);
|
||||||
|
expect(find.byType(RatingRow), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a reading typed right says so', (tester) async {
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: paragraph('東京'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
reading: 'とうきょう',
|
||||||
|
meanings: const ['Tokyo'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final settings = SettingsRepository(settingsGateway);
|
||||||
|
addTearDown(settings.dispose);
|
||||||
|
await settings.update(
|
||||||
|
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
|
||||||
|
);
|
||||||
|
await study(tester);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'トウキョウ');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Right'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('typing readings, a word written in kana flips as before', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: paragraph('テレビ'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
meanings: const ['television'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final settings = SettingsRepository(settingsGateway);
|
||||||
|
addTearDown(settings.dispose);
|
||||||
|
await settings.update(
|
||||||
|
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
|
||||||
|
);
|
||||||
|
await study(tester);
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
expect(find.text('Show answer'), 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