fix(study): a Japanese word's romaji reading is not typed as pinyin

Any reading in Latin letters counted as pinyin, whatever the studyset's
language, so a Japanese word read in romaji (東京 as Tōkyō or toukyou,
common in imported decks), which was never typed for having no kana, was
typed with pinyin's tone numbers and hint, and tokyo was marked wrong.

A reading is now pinyin only when the studied studyset's fronts are in
Chinese (zh, in any script or region), its own language or inherited: the
session reads it as each round starts, the card the same languages its
screen is set in. A Japanese studyset studies as it did before pinyin.

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-14 17:48:15 +02:00
commit fd783df649
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 80 additions and 24 deletions

View file

@ -1,4 +1,5 @@
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
/// Every vowel's four tone marks, by the vowel.
const _toneMarks = {
@ -97,12 +98,16 @@ Set<String> givenPinyin(String field) => {
/// whole saying is its one reading.
final _readingsApart = RegExp(r'(?<!\s)[,,]|[;;//、]');
/// Whether [reading] is pinyin: Latin letters, with or without tone marks,
/// whatever their case (CC-CEDICT capitalises names: `Ōu zhōu`), and no
/// kana or Han characters.
bool isPinyin(String reading) {
/// Whether [reading], of a word in [language] (BCP 47), is pinyin: the word
/// is Chinese (`zh`, in any script or region), and its reading Latin
/// letters, with or without tone marks, whatever their case (CC-CEDICT
/// capitalises names: `Ōu zhōu`), and no kana or Han characters. A
/// Japanese word's romaji (`Tōkyō`, `toukyou`) is none.
bool isPinyin(String reading, {required String? language}) {
final lower = reading.trim().toLowerCase();
return lower.isNotEmpty && _pinyin.hasMatch(lower);
return primaryLanguageOf(language) == 'zh' &&
lower.isNotEmpty &&
_pinyin.hasMatch(lower);
}
/// Pinyin in lower case, as [isPinyin] reads it.

View file

@ -122,6 +122,11 @@ class StudySessionBloc(
/// The studyset each card of the session is in.
final _studysetOf = <String, String>{};
/// The language of the studied studyset's fronts (BCP 47), its own or
/// inherited, as its screen is set in: only a Chinese word's reading is
/// pinyin.
String? _frontLanguage;
/// The tags of the session's cards.
var _tagging = Tagging.empty;
@ -221,6 +226,7 @@ class StudySessionBloc(
for (final card in group) card.id: card,
};
_tagging = await _tags.read();
_frontLanguage = languagesOf(_studysetId, await _studysets.all()).front;
final now = clock.now().toUtc();
_pools = QuizPools.of([for (final card in _cardsById.values) card.content]);
_mapRegions = {
@ -560,9 +566,10 @@ class StudySessionBloc(
}
/// Whether [card]'s reading is to be typed: a word with Han characters
/// read in kana (Japanese) or in pinyin (Chinese), asked front first,
/// while the person types readings and isn't quizzed. A Korean word's
/// reading, its pronunciation in Hangul, isn't typed.
/// read in kana (Japanese) or in pinyin (a studyset in Chinese; a
/// Japanese word's romaji isn't typed), asked front first, while the
/// person types readings and isn't quizzed. A Korean word's reading, its
/// pronunciation in Hangul, isn't typed.
bool _typesReading(Card card) =>
_options.typeReadings &&
!_options.quizVocabulary &&
@ -570,7 +577,7 @@ class StudySessionBloc(
card.content.type == CardType.vocabulary &&
card.content.reading.trim().isNotEmpty &&
(_kana.hasMatch(card.content.reading) ||
isPinyin(card.content.reading)) &&
isPinyin(card.content.reading, language: _frontLanguage)) &&
// A word written in kana (テレビ) is its own reading.
_kanji.hasMatch(card.content.front.plainText) &&
!card.content.usuallyKana &&
@ -1223,7 +1230,7 @@ final class const StudySessionStateShowing({
/// a card asked as it is.
final CardContent? quiz,
/// Whether the word's reading is to be typed, in kana.
/// Whether the word's reading is to be typed, in kana or in pinyin.
final bool typesReading = false,
/// Whether the character, or the word, is to be written by hand.

View file

@ -560,8 +560,12 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
super.dispose();
}
/// Whether the card's reading is pinyin (Chinese), else kana.
bool get _pinyin => isPinyin(widget.card.content.reading);
/// Whether the card's reading is pinyin (a studyset in Chinese), else
/// kana.
bool get _pinyin => isPinyin(
widget.card.content.reading,
language: ContentLanguages.of(context).front,
);
/// Whether what was typed is the card's reading, or one of them: kana
/// read in each way it can be ([typedReadings]); pinyin with its tones

View file

@ -39,21 +39,30 @@ void main() {
});
test("pinyin is told from kana and from words' own letters", () {
expect(isPinyin('xué xí'), isTrue);
expect(isPinyin('shuō'), isTrue);
expect(isPinyin('たべる'), isFalse);
expect(isPinyin('가치'), isFalse);
expect(isPinyin(''), isFalse);
expect(isPinyin('xué xí', language: 'zh'), isTrue);
expect(isPinyin('shuō', language: 'zh'), isTrue);
expect(isPinyin('たべる', language: 'zh'), isFalse);
expect(isPinyin('가치', language: 'zh'), isFalse);
expect(isPinyin('', language: 'zh'), isFalse);
});
test('a proper noun, capitalised with its tone mark, is pinyin too', () {
expect(isPinyin('Ōu zhōu'), isTrue);
expect(isPinyin('Ào mén'), isTrue);
expect(isPinyin('É yǔ'), isTrue);
expect(isPinyin('Lǘ'), isTrue);
expect(isPinyin('Ōu zhōu', language: 'zh'), isTrue);
expect(isPinyin('Ào mén', language: 'zh'), isTrue);
expect(isPinyin('É yǔ', language: 'zh'), isTrue);
expect(isPinyin('Lǘ', language: 'zh'), isTrue);
expect(givenPinyin('Ōu zhōu').contains(pinyinKey('ou1zhou1')), isTrue);
});
test("only a Chinese word's reading is pinyin, in any script or region; "
"a Japanese word's romaji is not", () {
expect(isPinyin('xué xí', language: 'zh-Hans'), isTrue);
expect(isPinyin('xué xí', language: 'zh-Hant-TW'), isTrue);
expect(isPinyin('Tōkyō', language: 'ja'), isFalse);
expect(isPinyin('toukyou', language: 'ja'), isFalse);
expect(isPinyin('xué xí', language: null), isFalse);
});
test('typing turns a syllable and its tone into the marked syllable, the '
'caret where it was typed', () {
const formatter = PinyinInputFormatter();

View file

@ -72,12 +72,14 @@ void main() {
});
/// Studies the studyset `japanese`, with the cards the test gave [cards]:
/// the app reads them from the same [gateway].
/// the app reads them from the same [gateway]. Its fronts are in
/// [frontLanguage], none unless given.
Future<void> study(
WidgetTester tester, {
GeoMapRepository? maps,
FakeReviewGateway? reviews,
StrokeRepository? strokes,
String? frontLanguage,
double width = 1200,
}) async {
tester.view.physicalSize = Size(width, 1600);
@ -89,7 +91,13 @@ void main() {
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: frontLanguage,
),
),
),
cardGateway: gateway,
tagGateway: tagGateway,
@ -893,7 +901,7 @@ void main() {
await settings.update(
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
);
await study(tester);
await study(tester, frontLanguage: 'zh-Hans');
expect(find.text('The pinyin, with tone numbers or marks'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'xue2xi2');
@ -905,6 +913,29 @@ void main() {
expect(find.text('Right'), findsOneWidget);
});
testWidgets('typing readings, a Japanese word read in romaji flips as '
"before: they aren't pinyin", (tester) async {
await cards.create(
'japanese',
CardContent(
type: CardType.vocabulary,
front: paragraph('東京'),
back: RichContent.empty(),
reading: 'Tōkyō',
meanings: const ['Tokyo'],
),
);
final settings = SettingsRepository(settingsGateway);
addTearDown(settings.dispose);
await settings.update(
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
);
await study(tester, frontLanguage: 'ja');
expect(find.byType(TextField), findsNothing);
expect(find.text('Show answer'), findsOneWidget);
});
testWidgets('a reading typed right says so', (tester) async {
await cards.create(
'japanese',