feat(learn): furigana and pinyin over hints, from Tatoeba's transcriptions

The curator puts Tatoeba's readings on a dictionary's examples by their
Tatoeba ID (`withTatoebaReadings`, `curate readings`, and in `update`):
furigana over JMdict's Japanese from its jpn transcriptions, pinyin with
tone marks over Chinese from its cmn ones, each word over as many Han
characters as it has syllables. An example gets `ruby`, `[start, end,
reading]` each, and `rubyUnreviewed` where no one wrote or checked them
(MeCab's furigana, the automatic pinyin). JMdict's conversion goes to 3,
so instances convert again at once.

A word's example hints carry them as ruby marks, the word still in bold.
The hints sheet shows them from the start with a switch above
(Furigana, Pinyin), kept on the device (`DeviceSettings.hintReadings`),
and below a hint whose readings no one reviewed says so.

Whats-New: Example sentences in a word's hints now show furigana or pinyin over them, with a switch to hide them.
Whats-New-De: Beispielsätze in den Hinweisen eines Worts zeigen jetzt Furigana oder Pinyin darüber, mit einem Schalter zum Ausblenden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 20:28:30 +01:00
commit 3257905536
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1083 additions and 78 deletions

View file

@ -2172,5 +2172,9 @@
"libraryShelfLastWeek": "Letzte 7 Tage",
"libraryShelfLastMonth": "Letzter Monat",
"libraryShelfOlder": "Älter",
"studysetSideIntroduction": "Einführung"
"studysetSideIntroduction": "Einführung",
"cardHintsFurigana": "Furigana",
"cardHintsPinyin": "Pinyin",
"cardHintsReadings": "Text darüber",
"cardHintsReadingsUnreviewed": "Manche Lesungen wurden automatisch erstellt und nicht geprüft."
}

View file

@ -2670,5 +2670,13 @@
"libraryShelfOlder": "Older",
"@libraryShelfOlder": {"description": "The library's heading over the studysets studied or changed longer ago."},
"studysetSideIntroduction": "Introduction",
"@studysetSideIntroduction": {"description": "The part of the split beside a studyset's material showing its description, as Tomorrow University names it."}
"@studysetSideIntroduction": {"description": "The part of the split beside a studyset's material showing its description, as Tomorrow University names it."},
"cardHintsFurigana": "Furigana",
"@cardHintsFurigana": {"description": "The hints sheet's switch showing the readings over Japanese sentences."},
"cardHintsPinyin": "Pinyin",
"@cardHintsPinyin": {"description": "The hints sheet's switch showing the pinyin over Chinese sentences."},
"cardHintsReadings": "Text above",
"@cardHintsReadings": {"description": "The hints sheet's switch showing the text above its sentences, where it is neither furigana nor pinyin."},
"cardHintsReadingsUnreviewed": "Some readings were made automatically and not reviewed.",
"@cardHintsReadingsUnreviewed": {"description": "Below a hint whose readings (from Tatoeba, made by MeCab or automatic pinyin) no one reviewed."}
}

View file

@ -12817,6 +12817,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Introduction'**
String get studysetSideIntroduction;
/// The hints sheet's switch showing the readings over Japanese sentences.
///
/// In en, this message translates to:
/// **'Furigana'**
String get cardHintsFurigana;
/// The hints sheet's switch showing the pinyin over Chinese sentences.
///
/// In en, this message translates to:
/// **'Pinyin'**
String get cardHintsPinyin;
/// The hints sheet's switch showing the text above its sentences, where it is neither furigana nor pinyin.
///
/// In en, this message translates to:
/// **'Text above'**
String get cardHintsReadings;
/// Below a hint whose readings (from Tatoeba, made by MeCab or automatic pinyin) no one reviewed.
///
/// In en, this message translates to:
/// **'Some readings were made automatically and not reviewed.'**
String get cardHintsReadingsUnreviewed;
}
class _AppLocalizationsDelegate

View file

@ -8191,4 +8191,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSideIntroduction => 'Einführung';
@override
String get cardHintsFurigana => 'Furigana';
@override
String get cardHintsPinyin => 'Pinyin';
@override
String get cardHintsReadings => 'Text darüber';
@override
String get cardHintsReadingsUnreviewed =>
'Manche Lesungen wurden automatisch erstellt und nicht geprüft.';
}

View file

@ -8118,6 +8118,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSideIntroduction => 'Introduction';
@override
String get cardHintsFurigana => 'Furigana';
@override
String get cardHintsPinyin => 'Pinyin';
@override
String get cardHintsReadings => 'Text above';
@override
String get cardHintsReadingsUnreviewed =>
'Some readings were made automatically and not reviewed.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -50,6 +50,9 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventLibrarySubjectChosen>(
(event, emit) => _apply(emit, state.withLibrarySubject(event.subject)),
);
on<DeviceSettingsEventHintReadingsChosen>(
(event, emit) => _apply(emit, state.withHintReadings(shown: event.shown)),
);
on<DeviceSettingsEventSidebarChosen>(
(event, emit) => _apply(emit, state.withSidebar(open: event.open)),
);
@ -162,6 +165,14 @@ final class const DeviceSettingsEventLibrarySubjectChosen(final String? subject)
List<Object?> get props => [subject];
}
/// The person shows hints' readings from the start, or hides them
/// ([shown]).
final class const DeviceSettingsEventHintReadingsChosen(final bool shown)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [shown];
}
/// The person opens the rail into the sidebar, or closes it ([open]).
final class const DeviceSettingsEventSidebarChosen(final bool open)
extends DeviceSettingsEvent {

View file

@ -82,6 +82,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String id => id,
_ => null,
},
hintReadings: json['hintReadings'] != false,
);
} on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -112,6 +113,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'checkGrammar': settings.checkGrammar,
if (settings.sidebarOpen) 'sidebarOpen': true,
'librarySubject': ?settings.librarySubject,
if (!settings.hintReadings) 'hintReadings': false,
}),
flush: true,
);

View file

@ -12,8 +12,8 @@ export 'package:halkyon_app/halkyon_app.dart' show Appearance;
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, whether and how the person searches
/// the web from the app, who answered the field last and at which effort,
/// whether writing is checked as it is written, and whether the rail is
/// open into the sidebar.
/// whether writing is checked as it is written, whether the rail is
/// open into the sidebar, and whether hints show their readings.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -75,6 +75,11 @@ final class const DeviceSettings({
/// as the person writes (ADR 0024, Settings › Writing): on unless turned
/// off; nothing is checked where the instance checks nothing.
final bool checkGrammar = true,
/// Whether the hints sheet shows readings over its sentences (furigana,
/// pinyin) from the start, as its switch was last left (decided 10 Nov
/// 2026): on unless turned off.
final bool hintReadings = true,
}) with Equatable {
/// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) =>
@ -130,6 +135,10 @@ final class const DeviceSettings({
/// These settings with writing checked as it is written [on] or off.
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
/// These settings with hints' readings [shown] from the start or not.
DeviceSettings withHintReadings({required bool shown}) =>
_copy(hintReadings: shown);
DeviceSettings _copy({
Appearance? appearance,
Object? language = _keep,
@ -145,6 +154,7 @@ final class const DeviceSettings({
bool? checkGrammar,
bool? sidebarOpen,
Object? librarySubject = _keep,
bool? hintReadings,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?,
@ -166,6 +176,7 @@ final class const DeviceSettings({
librarySubject: identical(librarySubject, _keep)
? this.librarySubject
: librarySubject as String?,
hintReadings: hintReadings ?? this.hintReadings,
);
@override
@ -184,6 +195,7 @@ final class const DeviceSettings({
checkGrammar,
sidebarOpen,
librarySubject,
hintReadings,
];
}

View file

@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby_marks.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -17,15 +18,51 @@ import 'package:material_ui/material_ui.dart';
/// How large a reading is beside the text it reads.
const _rubyScale = 0.5;
/// Shows the readings below it over their text from the start ([shown]),
/// rather than under the pointer or after a long press alone: the hints
/// sheet's switch (decided 10 Nov 2026).
class const ReadingsShown({
required final bool shown,
required super.child,
super.key,
}) extends InheritedWidget {
/// Whether readings below [context] show from the start; not unless a
/// [ReadingsShown] says so.
static bool of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ReadingsShown>()?.shown ??
false;
@override
bool updateShouldNotify(ReadingsShown oldWidget) => shown != oldWidget.shown;
}
final _kana = RegExp(r'[\u3040-\u30ff]');
final _latin = RegExp('[a-zA-ZÀ-ɏ]');
/// What the readings in [content] are: furigana where they are kana,
/// pinyin where they are Latin letters, else any.
RubyKind rubyKindOf(RichContent content) {
for (final reading in readingsOf(content)) {
if (_kana.hasMatch(reading)) {
return RubyKind.furigana;
}
if (_latin.hasMatch(reading)) {
return RubyKind.pinyin;
}
}
return RubyKind.any;
}
/// What text above is called where content is written in a language that
/// has a name for it: furigana over Japanese, pinyin over Chinese, the
/// front's language first.
enum _RubyKind() {
/// has a name for it: furigana over Japanese, pinyin over Chinese.
enum RubyKind() {
furigana,
pinyin,
any;
static _RubyKind of(BuildContext context) {
/// The kind of the content around [context], the front's language first.
static RubyKind of(BuildContext context) {
final (:front, :back) = ContentLanguages.of(context);
for (final tag in [front, back]) {
switch (tag?.split('-').first.toLowerCase()) {
@ -43,10 +80,10 @@ enum _RubyKind() {
/// the content around [context].
String rubyButtonName(BuildContext context) {
final l10n = AppLocalizations.of(context);
return switch (_RubyKind.of(context)) {
_RubyKind.furigana => l10n.formatRubyFurigana,
_RubyKind.pinyin => l10n.formatRubyPinyin,
_RubyKind.any => l10n.formatRuby,
return switch (RubyKind.of(context)) {
RubyKind.furigana => l10n.formatRubyFurigana,
RubyKind.pinyin => l10n.formatRubyPinyin,
RubyKind.any => l10n.formatRuby,
};
}
@ -82,7 +119,7 @@ Future<void> editRuby(BuildContext context, EditorState editorState) async {
rubyKey,
selection,
);
final kind = _RubyKind.of(context);
final kind = RubyKind.of(context);
final reading = await showDialog<String>(
context: context,
builder: (dialogContext) =>
@ -156,7 +193,7 @@ TextSpanDecoratorForAttribute withRuby(
),
reading: reading,
style: style,
hidden: true,
hidden: !ReadingsShown.of(context),
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
@ -401,7 +438,7 @@ class _RubyTextState() extends State<RubyText> {
/// completes with it, empty to take it away, or with null when cancelled.
class const _ReadingDialog({
required final String initial,
required final _RubyKind kind,
required final RubyKind kind,
}) extends StatefulWidget {
@override
State<_ReadingDialog> createState() => _ReadingDialogState();
@ -420,17 +457,17 @@ class _ReadingDialogState() extends State<_ReadingDialog> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final (title, label, hint) = switch (widget.kind) {
_RubyKind.furigana => (
RubyKind.furigana => (
l10n.rubyTitleFurigana,
l10n.rubyReading,
l10n.rubyReadingHint,
),
_RubyKind.pinyin => (
RubyKind.pinyin => (
l10n.rubyTitlePinyin,
l10n.rubyTitlePinyin,
l10n.rubyHintPinyin,
),
_RubyKind.any => (l10n.rubyTitle, l10n.rubyText, l10n.rubyHint),
RubyKind.any => (l10n.rubyTitle, l10n.rubyText, l10n.rubyHint),
};
return AlertDialog(
title: Text(title),

View file

@ -0,0 +1,46 @@
import 'package:halkyon_editor/halkyon_editor.dart';
// What content says of its readings (text above, `rubyKey`), in pure Dart
// for the models; the editor draws them (ruby.dart).
/// The key a paragraph's data marks readings with that no one wrote or
/// checked: Tatoeba's made by MeCab, or its automatic pinyin (decided
/// 9 Nov 2026).
const rubyUnreviewedKey = 'rubyUnreviewed';
/// The blocks of [content], and the blocks within them.
Iterable<Map<Object?, Object?>> _blocksOf(RichContent content) sync* {
Iterable<Map<Object?, Object?>> walk(Object? node) sync* {
if (node is! Map) {
return;
}
yield node;
for (final child in node['children'] as List? ?? const []) {
yield* walk(child);
}
}
yield* walk(content.editorJson['document']);
}
/// The readings over text in [content], in order.
Iterable<String> readingsOf(RichContent content) sync* {
for (final block in _blocksOf(content)) {
if (block['data'] case {'delta': final List<Object?> delta}) {
for (final op in delta) {
if (op case {'attributes': {rubyKey: final String reading}}
when reading.isNotEmpty) {
yield reading;
}
}
}
}
}
/// Whether [content] has text with a reading over it.
bool hasReadings(RichContent content) => readingsOf(content).isNotEmpty;
/// Whether [content] holds readings no one reviewed ([rubyUnreviewedKey]).
bool readingsUnreviewed(RichContent content) => _blocksOf(
content,
).any((block) => (block['data'] as Map?)?[rubyUnreviewedKey] == true);

View file

@ -1,6 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby_marks.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -156,10 +160,58 @@ class _HintsSheetState() extends State<_HintsSheet> {
],
)
: null,
body: LearnContentView(
key: ValueKey(_index),
content: widget.hints[_index],
),
body: _Hint(key: ValueKey(_index), hint: widget.hints[_index]),
);
}
}
/// One [hint]; with readings over it, a switch above showing them from the
/// start or not, as the device keeps it (decided 10 Nov 2026), and below
/// it, while they show, whether no one reviewed them.
class const _Hint({required final RichContent hint, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!hasReadings(hint)) {
return LearnContentView(content: hint);
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final shown = context.select<DeviceSettingsBloc, bool>(
(settings) => settings.state.hintReadings,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(switch (rubyKindOf(hint)) {
RubyKind.furigana => l10n.cardHintsFurigana,
RubyKind.pinyin => l10n.cardHintsPinyin,
RubyKind.any => l10n.cardHintsReadings,
}),
value: shown,
onChanged: (on) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventHintReadingsChosen(on),
),
),
ReadingsShown(
shown: shown,
// Drawn anew when switched: the editor keeps the spans it drew.
child: LearnContentView(key: ValueKey(shown), content: hint),
),
if (shown && readingsUnreviewed(hint))
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.cardHintsReadingsUnreviewed,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby_marks.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -49,9 +50,21 @@ final class const Dictionary({
}
/// A sentence using a word, in the word's language, with the word in the
/// form it takes there (`食べて`, `isst`), and its translation into the
/// dictionary's meaning language.
typedef WordExample = ({String form, String sentence, String? translation});
/// form it takes there (`食べて`, `isst`), its translation into the
/// dictionary's meaning language, and the readings over it Tatoeba
/// transcribed (furigana, pinyin; decided 9 Nov 2026), from where to where
/// each goes, which no one reviewed where `rubyUnreviewed` says so.
typedef WordExample = ({
String form,
String sentence,
String? translation,
List<ExampleRuby> ruby,
bool rubyUnreviewed,
});
/// A reading over a sentence: from its start to its end (UTF-16 code units),
/// and what it reads.
typedef ExampleRuby = ({int start, int end, String reading});
/// A form of a word shown beside it: its [text], and what it is
/// (Wiktionary's [tags]: `genitive`, `plural`, `past`).
@ -231,6 +244,11 @@ final class const DictionaryWord({
sentence: example[language]! as String,
translation:
(example[glossLanguage] ?? example['en']) as String?,
ruby: _rubyOf(
example['ruby'],
(example[language]! as String).length,
),
rubyUnreviewed: example['rubyUnreviewed'] == true,
),
],
);
@ -543,11 +561,65 @@ const _educationHanja = 'Education Hanja';
const _secondarySchool = 'Secondary school';
/// [example] as a hint: the sentence, the word in it in bold, and its
/// English on a line below.
/// The readings of an example as a dictionary file gives them
/// (`[start, end, reading]` each), those that fit a sentence [length] long
/// and come one after another without overlapping.
List<ExampleRuby> _rubyOf(Object? ruby, int length) {
final read = <ExampleRuby>[];
for (final item in ruby is List ? ruby : const []) {
if (item case [final int start, final int end, final String reading]
when start >= (read.lastOrNull?.end ?? 0) &&
end > start &&
end <= length &&
reading.isNotEmpty) {
read.add((start: start, end: end, reading: reading));
}
}
return read;
}
/// [example] as a hint: the sentence, the word in it in bold and the
/// readings over it, and its English on a line below; a sentence whose
/// readings no one reviewed says so in its data ([rubyUnreviewedKey]).
RichContent _hint(WordExample example) {
final (:form, sentence: japanese, translation: english) = example;
final (
:form,
sentence: japanese,
translation: english,
:ruby,
:rubyUnreviewed,
) = example;
final at = form.isEmpty ? -1 : japanese.indexOf(form);
// Cut where the word or a reading starts or ends; each piece bold within
// the word, with the reading it lies under.
final cuts = {
0,
japanese.length,
if (at >= 0) ...[at, at + form.length],
for (final (:start, :end, reading: _) in ruby) ...[start, end],
}.toList()..sort();
final delta = <Map<String, Object?>>[];
for (var i = 0; i + 1 < cuts.length; i++) {
final (from, to) = (cuts[i], cuts[i + 1]);
final reading = ruby
.where((each) => each.start <= from && to <= each.end)
.firstOrNull
?.reading;
final attributes = {
if (at >= 0 && at <= from && to <= at + form.length) 'bold': true,
rubyKey: ?reading,
};
final text = japanese.substring(from, to);
if (delta.lastOrNull case final last?
when attributes.isEmpty && last['attributes'] == null) {
delta.last = {'insert': '${last['insert']}$text'};
} else {
delta.add({
'insert': text,
if (attributes.isNotEmpty) 'attributes': attributes,
});
}
}
return RichContent({
'document': {
'type': 'page',
@ -555,19 +627,8 @@ RichContent _hint(WordExample example) {
{
'type': 'paragraph',
'data': {
'delta': [
if (at < 0)
{'insert': japanese}
else ...[
if (at > 0) {'insert': japanese.substring(0, at)},
{
'insert': form,
'attributes': {'bold': true},
},
if (at + form.length < japanese.length)
{'insert': japanese.substring(at + form.length)},
],
],
'delta': delta,
if (rubyUnreviewed && ruby.isNotEmpty) rubyUnreviewedKey: true,
},
},
if (english != null && english.isNotEmpty)

View file

@ -77,6 +77,18 @@ void main() {
expect(read.withLanguage('de').librarySubject, 'languages');
});
test("shows readings over hints' sentences until told not to (decided "
'10 Nov 2026)', () async {
expect((await store.read()).hintReadings, isTrue);
await store.write(const DeviceSettings(hintReadings: false));
final read = await FileDeviceSettingsStore(file).read();
expect(read.hintReadings, isFalse);
expect(read.withHintReadings(shown: true).hintReadings, isTrue);
expect(read.withLanguage('de').hintReadings, isFalse);
});
test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue);

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby_marks.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -324,6 +325,53 @@ void main() {
{'insert': 'た。'},
]);
});
test("an example's readings from Tatoeba go over its sentence, the word "
'still in bold; those no one reviewed are marked so', () {
final word = DictionaryWord.fromEntry(
ref: (source: 'jmdict', id: '1358280'),
headword: '食べる',
reading: 'たべる',
labels: const [],
entry:
'{"senses":[{"glosses":["to eat"],"examples":['
'{"text":"食べ","ja":"果物を食べた。","en":"I ate fruit.",'
'"ruby":[[0,1,"くだ"],[1,2,"もの"],[3,4,"た"]],'
'"rubyUnreviewed":true},'
'{"text":"食べ","ja":"食べた。","ruby":[[0,1,"た"]]}]}]}',
);
final [unreviewed, reviewed] = word.card.hints;
final document = unreviewed.editorJson['document']! as Map;
final [sentence, english] = document['children']! as List;
expect(((sentence as Map)['data']! as Map)['delta'], [
{
'insert': '果',
'attributes': {'ruby': 'くだ'},
},
{
'insert': '物',
'attributes': {'ruby': 'もの'},
},
{'insert': 'を'},
{
'insert': '食',
'attributes': {'bold': true, 'ruby': 'た'},
},
{
'insert': 'べ',
'attributes': {'bold': true},
},
{'insert': 'た。'},
]);
expect(readingsUnreviewed(unreviewed), isTrue);
expect(((english as Map)['data']! as Map)['delta'], [
{'insert': 'I ate fruit.'},
]);
expect(readingsUnreviewed(reviewed), isFalse);
expect(hasReadings(reviewed), isTrue);
expect(hasReadings(RichContent.plain('食べた。')), isFalse);
});
});
group('the repository', () {

View file

@ -1,10 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
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/widget/card_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.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/kanji/data/stroke_repository.dart';
@ -33,6 +35,7 @@ import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
import '../../../support/loaded_maps.dart';
import '../../../support/loaded_strokes.dart';
import '../../../support/memory_device_settings_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -82,6 +85,7 @@ void main() {
StrokeRepository? strokes,
String? frontLanguage,
double width = 1200,
MemoryDeviceSettingsStore? deviceSettings,
}) async {
tester.view.physicalSize = Size(width, 1600);
tester.view.devicePixelRatio = 1;
@ -106,6 +110,7 @@ void main() {
reviewGateway: reviews,
maps: maps,
strokes: strokes,
deviceSettingsStore: deviceSettings,
),
location: RoutePaths.study('japanese'),
);
@ -411,6 +416,60 @@ void main() {
);
});
testWidgets("a hint's readings show over its sentence until the "
"sheet's switch hides them, which the device keeps; those no one "
'reviewed say so, and hints without readings have no switch', (
tester,
) async {
final word = DictionaryWord.fromEntry(
ref: (source: 'jmdict', id: '1358280'),
headword: '食べる',
reading: 'たべる',
labels: const [],
entry:
'{"senses":[{"glosses":["to eat"],"examples":['
'{"text":"食べ","ja":"果物を食べた。","en":"I ate fruit.",'
'"ruby":[[0,2,"くだもの"],[3,4,"た"]],"rubyUnreviewed":true}]}]}',
);
await cards.create(
'japanese',
CardContent(
front: paragraph('食べる'),
back: paragraph('to eat'),
hints: [...word.card.hints, paragraph('a verb')],
),
);
final store = MemoryDeviceSettingsStore();
await study(tester, deviceSettings: store);
await tester.tap(find.text('Hints'));
await tester.pumpAndSettle();
RubyText ruby(String reading) => tester.widget<RubyText>(
find.byWidgetPredicate(
(widget) => widget is RubyText && widget.reading == reading,
),
);
expect(ruby('くだもの').hidden, isFalse);
expect(ruby('た').hidden, isFalse);
expect(
find.text('Some readings were made automatically and not reviewed.'),
findsOneWidget,
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Furigana'));
await tester.pumpAndSettle();
expect(ruby('くだもの').hidden, isTrue);
expect(
find.text('Some readings were made automatically and not reviewed.'),
findsNothing,
);
expect(store.writes.last.hintReadings, isFalse);
await tester.tap(find.byTooltip('Next hint'));
await tester.pumpAndSettle();
expect(find.widgetWithText(SwitchListTile, 'Furigana'), findsNothing);
});
testWidgets('with tags chosen and reviews due beyond them, asks before '
'learning new cards; reviewing first studies those', (tester) async {
final verb = await tags.create('verb');

View file

@ -133,6 +133,8 @@ final sampleWords = [
form: '会いましょう',
sentence: 'また会いましょう。',
translation: "Let's meet again.",
ruby: const [],
rubyUnreviewed: false,
),
],
),