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:
Tizian 「ティツィアーン」 2026-10-09 02:09:10 +02:00
commit 02d6542cfa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 571 additions and 5 deletions

View file

@ -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,
);
});
}

View file

@ -83,7 +83,7 @@ void main() {
),
);
expect(settingsSchemaVersion, 5);
expect(settingsSchemaVersion, greaterThanOrEqualTo(5));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
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', () {
final options = const StudyOptions().copyWith(
cardsPerRound: 50,

View file

@ -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/model/rating.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_progress.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);
});
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 '
'chosen', (tester) async {
final verb = await tags.create('verb');