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:flutter/services.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
/// Every vowel's four tone marks, by the vowel. /// Every vowel's four tone marks, by the vowel.
const _toneMarks = { const _toneMarks = {
@ -97,12 +98,16 @@ Set<String> givenPinyin(String field) => {
/// whole saying is its one reading. /// whole saying is its one reading.
final _readingsApart = RegExp(r'(?<!\s)[,,]|[;;//、]'); final _readingsApart = RegExp(r'(?<!\s)[,,]|[;;//、]');
/// Whether [reading] is pinyin: Latin letters, with or without tone marks, /// Whether [reading], of a word in [language] (BCP 47), is pinyin: the word
/// whatever their case (CC-CEDICT capitalises names: `Ōu zhōu`), and no /// is Chinese (`zh`, in any script or region), and its reading Latin
/// kana or Han characters. /// letters, with or without tone marks, whatever their case (CC-CEDICT
bool isPinyin(String reading) { /// 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(); 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. /// 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. /// The studyset each card of the session is in.
final _studysetOf = <String, String>{}; 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. /// The tags of the session's cards.
var _tagging = Tagging.empty; var _tagging = Tagging.empty;
@ -221,6 +226,7 @@ class StudySessionBloc(
for (final card in group) card.id: card, for (final card in group) card.id: card,
}; };
_tagging = await _tags.read(); _tagging = await _tags.read();
_frontLanguage = languagesOf(_studysetId, await _studysets.all()).front;
final now = clock.now().toUtc(); final now = clock.now().toUtc();
_pools = QuizPools.of([for (final card in _cardsById.values) card.content]); _pools = QuizPools.of([for (final card in _cardsById.values) card.content]);
_mapRegions = { _mapRegions = {
@ -560,9 +566,10 @@ class StudySessionBloc(
} }
/// Whether [card]'s reading is to be typed: a word with Han characters /// Whether [card]'s reading is to be typed: a word with Han characters
/// read in kana (Japanese) or in pinyin (Chinese), asked front first, /// read in kana (Japanese) or in pinyin (a studyset in Chinese; a
/// while the person types readings and isn't quizzed. A Korean word's /// Japanese word's romaji isn't typed), asked front first, while the
/// reading, its pronunciation in Hangul, isn't typed. /// person types readings and isn't quizzed. A Korean word's reading, its
/// pronunciation in Hangul, isn't typed.
bool _typesReading(Card card) => bool _typesReading(Card card) =>
_options.typeReadings && _options.typeReadings &&
!_options.quizVocabulary && !_options.quizVocabulary &&
@ -570,7 +577,7 @@ class StudySessionBloc(
card.content.type == CardType.vocabulary && card.content.type == CardType.vocabulary &&
card.content.reading.trim().isNotEmpty && card.content.reading.trim().isNotEmpty &&
(_kana.hasMatch(card.content.reading) || (_kana.hasMatch(card.content.reading) ||
isPinyin(card.content.reading)) && isPinyin(card.content.reading, language: _frontLanguage)) &&
// A word written in kana (テレビ) is its own reading. // A word written in kana (テレビ) is its own reading.
_kanji.hasMatch(card.content.front.plainText) && _kanji.hasMatch(card.content.front.plainText) &&
!card.content.usuallyKana && !card.content.usuallyKana &&
@ -1223,7 +1230,7 @@ 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. /// Whether the word's reading is to be typed, in kana or in pinyin.
final bool typesReading = false, final bool typesReading = false,
/// Whether the character, or the word, is to be written by hand. /// 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(); super.dispose();
} }
/// Whether the card's reading is pinyin (Chinese), else kana. /// Whether the card's reading is pinyin (a studyset in Chinese), else
bool get _pinyin => isPinyin(widget.card.content.reading); /// 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 /// 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 /// 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", () { test("pinyin is told from kana and from words' own letters", () {
expect(isPinyin('xué xí'), isTrue); expect(isPinyin('xué xí', language: 'zh'), isTrue);
expect(isPinyin('shuō'), isTrue); expect(isPinyin('shuō', language: 'zh'), isTrue);
expect(isPinyin('たべる'), isFalse); expect(isPinyin('たべる', language: 'zh'), isFalse);
expect(isPinyin('가치'), isFalse); expect(isPinyin('가치', language: 'zh'), isFalse);
expect(isPinyin(''), isFalse); expect(isPinyin('', language: 'zh'), isFalse);
}); });
test('a proper noun, capitalised with its tone mark, is pinyin too', () { test('a proper noun, capitalised with its tone mark, is pinyin too', () {
expect(isPinyin('Ōu zhōu'), isTrue); expect(isPinyin('Ōu zhōu', language: 'zh'), isTrue);
expect(isPinyin('Ào mén'), isTrue); expect(isPinyin('Ào mén', language: 'zh'), isTrue);
expect(isPinyin('É yǔ'), isTrue); expect(isPinyin('É yǔ', language: 'zh'), isTrue);
expect(isPinyin('Lǘ'), isTrue); expect(isPinyin('Lǘ', language: 'zh'), isTrue);
expect(givenPinyin('Ōu zhōu').contains(pinyinKey('ou1zhou1')), 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 ' test('typing turns a syllable and its tone into the marked syllable, the '
'caret where it was typed', () { 'caret where it was typed', () {
const formatter = PinyinInputFormatter(); const formatter = PinyinInputFormatter();

View file

@ -72,12 +72,14 @@ void main() {
}); });
/// Studies the studyset `japanese`, with the cards the test gave [cards]: /// 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( Future<void> study(
WidgetTester tester, { WidgetTester tester, {
GeoMapRepository? maps, GeoMapRepository? maps,
FakeReviewGateway? reviews, FakeReviewGateway? reviews,
StrokeRepository? strokes, StrokeRepository? strokes,
String? frontLanguage,
double width = 1200, double width = 1200,
}) async { }) async {
tester.view.physicalSize = Size(width, 1600); tester.view.physicalSize = Size(width, 1600);
@ -89,7 +91,13 @@ void main() {
studysetGateway: FakeStudysetGateway() studysetGateway: FakeStudysetGateway()
..receiveRemoteChange( ..receiveRemoteChange(
'japanese', 'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')), encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: frontLanguage,
),
),
), ),
cardGateway: gateway, cardGateway: gateway,
tagGateway: tagGateway, tagGateway: tagGateway,
@ -893,7 +901,7 @@ void main() {
await settings.update( await settings.update(
(each) => each.withStudy(const StudyOptions(typeReadings: true)), (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); expect(find.text('The pinyin, with tone numbers or marks'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'xue2xi2'); await tester.enterText(find.byType(TextField), 'xue2xi2');
@ -905,6 +913,29 @@ void main() {
expect(find.text('Right'), findsOneWidget); 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 { testWidgets('a reading typed right says so', (tester) async {
await cards.create( await cards.create(
'japanese', 'japanese',