feat(jellyfin): any stretch of a line's characters chosen by hand
The cut takes the longest word and knows no grammar, so 今日は reads as こんにちは where it is 今日 and は. Any stretch of a line's characters can now be chosen by hand: dragged across with a mouse, Shift+clicked from the word or stretch chosen, or on a touch screen held on a character and dragged on. It is looked up as one word, as it stands or as a form of one, in one small request (the instance has the line's text already); while it is chosen the line reads it as that word (きょう over 今日), and what the dictionary lacks as one word is offered as a search on Jisho. The line's words move into line_words.dart, out of the Watch screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9b5ea88a8a
commit
48ac84d92c
13 changed files with 918 additions and 209 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1840,7 +1840,10 @@
|
||||||
"watchWordCount": "{count}×",
|
"watchWordCount": "{count}×",
|
||||||
"watchAdd": "Hinzufügen",
|
"watchAdd": "Hinzufügen",
|
||||||
"watchAdded": "{word} hinzugefügt",
|
"watchAdded": "{word} hinzugefügt",
|
||||||
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen.",
|
"watchStretchNone": "„{text}“ steht nicht als ein Wort im Wörterbuch.",
|
||||||
|
"watchStretchFailed": "Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.",
|
||||||
|
"watchStretchJisho": "Auf Jisho suchen",
|
||||||
|
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).",
|
||||||
"watchFormOf": "Eine Form von {word}",
|
"watchFormOf": "Eine Form von {word}",
|
||||||
"watchFailed": "Das Video konnte nicht abgespielt werden."
|
"watchFailed": "Das Video konnte nicht abgespielt werden."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2158,8 +2158,12 @@
|
||||||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"watchAdd": "Add",
|
"watchAdd": "Add",
|
||||||
"watchAdded": "{word} added",
|
"watchAdded": "{word} added",
|
||||||
|
"watchStretchNone": "{text} isn't in the dictionary as one word.",
|
||||||
|
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
|
||||||
|
"watchStretchFailed": "Couldn't look it up. Choose it again to try once more.",
|
||||||
|
"watchStretchJisho": "Search Jisho for it",
|
||||||
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
|
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
|
||||||
"watchTapAWord": "Tap a word of the subtitles to look it up.",
|
"watchTapAWord": "Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).",
|
||||||
"watchFormOf": "A form of {word}",
|
"watchFormOf": "A form of {word}",
|
||||||
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
|
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
|
||||||
"watchFailed": "The video couldn't be played."
|
"watchFailed": "The video couldn't be played."
|
||||||
|
|
|
||||||
|
|
@ -10881,10 +10881,28 @@ abstract class AppLocalizations {
|
||||||
/// **'{word} added'**
|
/// **'{word} added'**
|
||||||
String watchAdded(String word);
|
String watchAdded(String word);
|
||||||
|
|
||||||
|
/// No description provided for @watchStretchNone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{text} isn\'t in the dictionary as one word.'**
|
||||||
|
String watchStretchNone(String text);
|
||||||
|
|
||||||
|
/// No description provided for @watchStretchFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t look it up. Choose it again to try once more.'**
|
||||||
|
String get watchStretchFailed;
|
||||||
|
|
||||||
|
/// No description provided for @watchStretchJisho.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search Jisho for it'**
|
||||||
|
String get watchStretchJisho;
|
||||||
|
|
||||||
/// No description provided for @watchTapAWord.
|
/// No description provided for @watchTapAWord.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Tap a word of the subtitles to look it up.'**
|
/// **'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).'**
|
||||||
String get watchTapAWord;
|
String get watchTapAWord;
|
||||||
|
|
||||||
/// No description provided for @watchFormOf.
|
/// No description provided for @watchFormOf.
|
||||||
|
|
|
||||||
|
|
@ -6846,9 +6846,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$word hinzugefügt';
|
return '$word hinzugefügt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String watchStretchNone(String text) {
|
||||||
|
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchStretchFailed =>
|
||||||
|
'Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchStretchJisho => 'Auf Jisho suchen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchTapAWord =>
|
String get watchTapAWord =>
|
||||||
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen.';
|
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchFormOf(String word) {
|
String watchFormOf(String word) {
|
||||||
|
|
|
||||||
|
|
@ -6786,7 +6786,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchTapAWord => 'Tap a word of the subtitles to look it up.';
|
String watchStretchNone(String text) {
|
||||||
|
return '$text isn\'t in the dictionary as one word.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchStretchFailed =>
|
||||||
|
'Couldn\'t look it up. Choose it again to try once more.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchStretchJisho => 'Search Jisho for it';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchTapAWord =>
|
||||||
|
'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchFormOf(String word) {
|
String watchFormOf(String word) {
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,38 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stretches chosen by hand looked up already, by dictionary and text;
|
||||||
|
/// the oldest go once there are [_sentencesKept].
|
||||||
|
final _stretches = <(String, String, String), SentencePart>{};
|
||||||
|
|
||||||
|
/// [text], a stretch of a line chosen by hand from [start], looked up in
|
||||||
|
/// [dictionary] as one word (`stretchPart`): one request for its
|
||||||
|
/// [stretchKeys], none for one asked before.
|
||||||
|
Future<SentencePart> wordOfStretch(
|
||||||
|
Dictionary dictionary,
|
||||||
|
String text, {
|
||||||
|
int start = 0,
|
||||||
|
}) async {
|
||||||
|
final key = (dictionary.source, dictionary.glossLanguage, text);
|
||||||
|
if (_stretches[key] case final known?) {
|
||||||
|
return SentencePart(
|
||||||
|
text: known.text,
|
||||||
|
start: start,
|
||||||
|
entries: known.entries,
|
||||||
|
steps: known.steps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final keys = stretchKeys(text);
|
||||||
|
final found = keys.isEmpty
|
||||||
|
? const <String, List<DictionaryItem>>{}
|
||||||
|
: await _gateway.withKeys(dictionary, keys.toList());
|
||||||
|
final part = _stretches[key] = stretchPart(text, start, found);
|
||||||
|
while (_stretches.length > _sentencesKept) {
|
||||||
|
_stretches.remove(_stretches.keys.first);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
|
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
|
||||||
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||||
_gateway.countLabelled(dictionary, label);
|
_gateway.countLabelled(dictionary, label);
|
||||||
|
|
|
||||||
|
|
@ -201,51 +201,89 @@ List<_Word> _wordsAt(
|
||||||
_inWord(characters[start + longest])) {
|
_inWord(characters[start + longest])) {
|
||||||
longest++;
|
longest++;
|
||||||
}
|
}
|
||||||
final words = <_Word>[];
|
return [
|
||||||
for (var length = longest; length > 0; length--) {
|
for (var length = longest; length > 0; length--)
|
||||||
final text = String.fromCharCodes(
|
?_wordOf(characters, start, length, found),
|
||||||
characters.sublist(start, start + length),
|
];
|
||||||
);
|
}
|
||||||
final kana = !text.runes.any(_isKanji);
|
|
||||||
final candidates =
|
/// The word [length] characters long at [start], as [found] has it by its
|
||||||
<({DictionaryWord entry, List<String> steps, int order})>[];
|
/// keys, as it stands or as a form of a word whose class fits; null when
|
||||||
final seen = <DictionaryRef>{};
|
/// the dictionary has none.
|
||||||
for (final form in deinflect(text)) {
|
_Word? _wordOf(
|
||||||
final key = form.text.toLowerCase();
|
List<int> characters,
|
||||||
for (final (index, item) in (found[key] ?? const []).indexed) {
|
int start,
|
||||||
if (item is! DictionaryWord ||
|
int length,
|
||||||
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
|
Map<String, List<DictionaryItem>> found,
|
||||||
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
|
) {
|
||||||
!seen.add(item.ref)) {
|
final text = String.fromCharCodes(characters.sublist(start, start + length));
|
||||||
continue;
|
final kana = !text.runes.any(_isKanji);
|
||||||
}
|
final candidates =
|
||||||
candidates.add((
|
<({DictionaryWord entry, List<String> steps, int order})>[];
|
||||||
entry: item,
|
final seen = <DictionaryRef>{};
|
||||||
steps: form.steps,
|
for (final form in deinflect(text)) {
|
||||||
// As it stands before a form of it, the fewest steps first; a
|
final key = form.text.toLowerCase();
|
||||||
// word written as the text is before one only read so, kana
|
for (final (index, item) in (found[key] ?? const []).indexed) {
|
||||||
// before kanji for kana; then the dictionary's order.
|
if (item is! DictionaryWord ||
|
||||||
order:
|
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
|
||||||
form.steps.length * 10000 +
|
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
|
||||||
(item.headword == key ? 0 : 1000) +
|
!seen.add(item.ref)) {
|
||||||
(kana && !_usuallyKana(item) ? 500 : 0) +
|
continue;
|
||||||
index,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
candidates.add((
|
||||||
|
entry: item,
|
||||||
|
steps: form.steps,
|
||||||
|
// As it stands before a form of it, the fewest steps first; a
|
||||||
|
// word written as the text is before one only read so, kana
|
||||||
|
// before kanji for kana; then the dictionary's order.
|
||||||
|
order:
|
||||||
|
form.steps.length * 10000 +
|
||||||
|
(item.headword == key ? 0 : 1000) +
|
||||||
|
(kana && !_usuallyKana(item) ? 500 : 0) +
|
||||||
|
index,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
candidates.sort((a, b) => a.order.compareTo(b.order));
|
|
||||||
final entries = [for (final each in candidates) each.entry];
|
|
||||||
if (entries.isEmpty) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
words.add((
|
|
||||||
length: length,
|
|
||||||
entries: entries,
|
|
||||||
steps: candidates.first.steps,
|
|
||||||
kana: kana,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
return words;
|
if (candidates.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
return (
|
||||||
|
length: length,
|
||||||
|
entries: [for (final each in candidates) each.entry],
|
||||||
|
steps: candidates.first.steps,
|
||||||
|
kana: kana,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys a dictionary is asked for to look up [text], a stretch of a
|
||||||
|
/// line chosen by hand, as one word: it as it stands and every word it may
|
||||||
|
/// be a form of.
|
||||||
|
Set<String> stretchKeys(String text) => {
|
||||||
|
if (text.runes.every(_inWord))
|
||||||
|
for (final form in deinflect(text)) form.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [text], a stretch of a line chosen by hand from [start] (decided
|
||||||
|
/// 2026-10-13: by characters, so also part of a word the cut took longer,
|
||||||
|
/// 今日 out of 今日は), as one word, as [found] has it by its
|
||||||
|
/// [stretchKeys]: the entries it may be, as it stands or as a form of one;
|
||||||
|
/// none when it is no word the dictionary has, or holds what no word does.
|
||||||
|
SentencePart stretchPart(
|
||||||
|
String text,
|
||||||
|
int start,
|
||||||
|
Map<String, List<DictionaryItem>> found,
|
||||||
|
) {
|
||||||
|
final characters = text.runes.toList();
|
||||||
|
final word = characters.isNotEmpty && characters.every(_inWord)
|
||||||
|
? _wordOf(characters, 0, characters.length, found)
|
||||||
|
: null;
|
||||||
|
return SentencePart(
|
||||||
|
text: text,
|
||||||
|
start: start,
|
||||||
|
entries: word?.entries ?? const [],
|
||||||
|
steps: word?.steps ?? const [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether [entry], found by [key] for text written in kana alone, is a
|
/// Whether [entry], found by [key] for text written in kana alone, is a
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
@ -33,6 +34,11 @@ enum WatchStatus() {
|
||||||
/// A word of the subtitles chosen: the `part` of the `line`.
|
/// A word of the subtitles chosen: the `part` of the `line`.
|
||||||
typedef WatchSelection = ({int line, int part});
|
typedef WatchSelection = ({int line, int part});
|
||||||
|
|
||||||
|
/// A stretch of the `line` chosen by hand (decided 2026-10-13: by
|
||||||
|
/// characters): its `text`, from `start` to before `end`, as offsets into
|
||||||
|
/// the line as it shows, joined into one.
|
||||||
|
typedef WatchStretch = ({int line, int start, int end, String text});
|
||||||
|
|
||||||
/// A word met in a video: the entry it is, the form it was first met in,
|
/// A word met in a video: the entry it is, the form it was first met in,
|
||||||
/// how often it comes, and the line it first comes in.
|
/// how often it comes, and the line it first comes in.
|
||||||
typedef VideoWord = ({
|
typedef VideoWord = ({
|
||||||
|
|
@ -82,6 +88,16 @@ final class const WatchState({
|
||||||
final Map<int, List<SentencePart>> parts = const {},
|
final Map<int, List<SentencePart>> parts = const {},
|
||||||
final WatchSelection? selected,
|
final WatchSelection? selected,
|
||||||
|
|
||||||
|
/// The stretch chosen by hand, in place of a word ([selected]).
|
||||||
|
final WatchStretch? stretch,
|
||||||
|
|
||||||
|
/// The [stretch] looked up as one word: no entries when the dictionary
|
||||||
|
/// has none; null while it is looked up.
|
||||||
|
final SentencePart? stretchPart,
|
||||||
|
|
||||||
|
/// Whether looking the [stretch] up failed.
|
||||||
|
final bool stretchFailed = false,
|
||||||
|
|
||||||
/// The dictionary entries cards of the studyset were made from.
|
/// The dictionary entries cards of the studyset were made from.
|
||||||
final Set<DictionaryRef> known = const {},
|
final Set<DictionaryRef> known = const {},
|
||||||
|
|
||||||
|
|
@ -110,11 +126,16 @@ final class const WatchState({
|
||||||
String? translationOf(int index) =>
|
String? translationOf(int index) =>
|
||||||
index < lines.length ? textDuring(translations, lines[index]) : null;
|
index < lines.length ? textDuring(translations, lines[index]) : null;
|
||||||
|
|
||||||
/// The part chosen.
|
/// The part chosen: the word, or the stretch once looked up.
|
||||||
SentencePart? get selectedPart => switch (selected) {
|
SentencePart? get selectedPart => stretch != null
|
||||||
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
|
? stretchPart
|
||||||
null => null,
|
: switch (selected) {
|
||||||
};
|
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The line of the word or the stretch chosen.
|
||||||
|
int? get chosenLine => stretch?.line ?? selected?.line;
|
||||||
|
|
||||||
/// The video's words the list shows, as [filter] admits them.
|
/// The video's words the list shows, as [filter] admits them.
|
||||||
List<VideoWord> get listed => [
|
List<VideoWord> get listed => [
|
||||||
|
|
@ -144,6 +165,9 @@ final class const WatchState({
|
||||||
Object? line = _same,
|
Object? line = _same,
|
||||||
Map<int, List<SentencePart>>? parts,
|
Map<int, List<SentencePart>>? parts,
|
||||||
Object? selected = _same,
|
Object? selected = _same,
|
||||||
|
Object? stretch = _same,
|
||||||
|
Object? stretchPart = _same,
|
||||||
|
bool? stretchFailed,
|
||||||
Set<DictionaryRef>? known,
|
Set<DictionaryRef>? known,
|
||||||
Object? words = _same,
|
Object? words = _same,
|
||||||
bool? pauseAtLineEnd,
|
bool? pauseAtLineEnd,
|
||||||
|
|
@ -173,6 +197,11 @@ final class const WatchState({
|
||||||
line: line == _same ? this.line : line as int?,
|
line: line == _same ? this.line : line as int?,
|
||||||
parts: parts ?? this.parts,
|
parts: parts ?? this.parts,
|
||||||
selected: selected == _same ? this.selected : selected as WatchSelection?,
|
selected: selected == _same ? this.selected : selected as WatchSelection?,
|
||||||
|
stretch: stretch == _same ? this.stretch : stretch as WatchStretch?,
|
||||||
|
stretchPart: stretchPart == _same
|
||||||
|
? this.stretchPart
|
||||||
|
: stretchPart as SentencePart?,
|
||||||
|
stretchFailed: stretchFailed ?? this.stretchFailed,
|
||||||
known: known ?? this.known,
|
known: known ?? this.known,
|
||||||
words: words == _same ? this.words : words as List<VideoWord>?,
|
words: words == _same ? this.words : words as List<VideoWord>?,
|
||||||
pauseAtLineEnd: pauseAtLineEnd ?? this.pauseAtLineEnd,
|
pauseAtLineEnd: pauseAtLineEnd ?? this.pauseAtLineEnd,
|
||||||
|
|
@ -199,6 +228,9 @@ final class const WatchState({
|
||||||
line,
|
line,
|
||||||
parts,
|
parts,
|
||||||
selected,
|
selected,
|
||||||
|
stretch,
|
||||||
|
stretchPart,
|
||||||
|
stretchFailed,
|
||||||
known,
|
known,
|
||||||
words,
|
words,
|
||||||
pauseAtLineEnd,
|
pauseAtLineEnd,
|
||||||
|
|
@ -431,10 +463,40 @@ class WatchCubit(
|
||||||
/// Chooses the [part] of the [line]: its entries show, and the video
|
/// Chooses the [part] of the [line]: its entries show, and the video
|
||||||
/// pauses, as reading a word takes a moment.
|
/// pauses, as reading a word takes a moment.
|
||||||
void select(int line, int part) {
|
void select(int line, int part) {
|
||||||
emit(state.copyWith(selected: (line: line, part: part)));
|
emit(state.copyWith(selected: (line: line, part: part), stretch: null));
|
||||||
unawaited(_playback?.pause());
|
unawaited(_playback?.pause());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chooses the characters of the [line] from [start] to before [end],
|
||||||
|
/// [text], by hand, and looks them up as one word; the video pauses, as
|
||||||
|
/// for a word.
|
||||||
|
Future<void> selectStretch(int line, int start, int end, String text) async {
|
||||||
|
final stretch = (line: line, start: start, end: end, text: text);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
selected: null,
|
||||||
|
stretch: stretch,
|
||||||
|
stretchPart: null,
|
||||||
|
stretchFailed: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
unawaited(_playback?.pause());
|
||||||
|
final dictionary = this.dictionary;
|
||||||
|
try {
|
||||||
|
final part = dictionary == null
|
||||||
|
? SentencePart(text: text, start: start)
|
||||||
|
: await _dictionaries.wordOfStretch(dictionary, text, start: start);
|
||||||
|
if (!isClosed && state.stretch == stretch) {
|
||||||
|
emit(state.copyWith(stretchPart: part));
|
||||||
|
}
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Looking up a stretch failed', name: 'Watch', error: error);
|
||||||
|
if (!isClosed && state.stretch == stretch) {
|
||||||
|
emit(state.copyWith(stretchFailed: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Goes to the first line [word] comes in, paused, the word chosen.
|
/// Goes to the first line [word] comes in, paused, the word chosen.
|
||||||
Future<void> showWord(VideoWord word) async {
|
Future<void> showWord(VideoWord word) async {
|
||||||
final parts = state.parts[word.firstLine] ?? const <SentencePart>[];
|
final parts = state.parts[word.firstLine] ?? const <SentencePart>[];
|
||||||
|
|
@ -449,13 +511,14 @@ class WatchCubit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
line: word.firstLine,
|
line: word.firstLine,
|
||||||
selected: (line: word.firstLine, part: part),
|
selected: (line: word.firstLine, part: part),
|
||||||
|
stretch: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chooses no word: the video's words show again.
|
/// Chooses no word: the video's words show again.
|
||||||
void unselect() => emit(state.copyWith(selected: null));
|
void unselect() => emit(state.copyWith(selected: null, stretch: null));
|
||||||
|
|
||||||
/// Adds [entry] to the studyset as a card, tagged with the show and the
|
/// Adds [entry] to the studyset as a card, tagged with the show and the
|
||||||
/// episode. Its hints are the dictionary's examples alone, never the line
|
/// episode. Its hints are the dictionary's examples alone, never the line
|
||||||
|
|
@ -463,7 +526,7 @@ class WatchCubit(
|
||||||
/// a line may be wrong or mistyped, and every episode's line would pile
|
/// a line may be wrong or mistyped, and every episode's line would pile
|
||||||
/// up on a common word.
|
/// up on a common word.
|
||||||
Future<void> add(DictionaryWord entry) async {
|
Future<void> add(DictionaryWord entry) async {
|
||||||
if (state.selected == null) {
|
if (state.selectedPart == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
||||||
|
|
@ -626,6 +689,7 @@ class WatchCubit(
|
||||||
lines: lines,
|
lines: lines,
|
||||||
parts: const {},
|
parts: const {},
|
||||||
selected: null,
|
selected: null,
|
||||||
|
stretch: null,
|
||||||
words: null,
|
words: null,
|
||||||
line: lastCueIndex(
|
line: lastCueIndex(
|
||||||
lines,
|
lines,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,461 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The words of the line [line], each tappable, the kanji's readings above
|
||||||
|
/// them; the line as text while it isn't cut yet. A line broken for showing
|
||||||
|
/// over a picture is one here, wrapping where the width ends (Japanese and
|
||||||
|
/// Chinese join as they are, others with a space).
|
||||||
|
///
|
||||||
|
/// Any stretch of its characters can be chosen by hand too (decided
|
||||||
|
/// 2026-10-13: by characters, so also part of a word the cut took longer,
|
||||||
|
/// 今日 out of 今日は): dragged across with a mouse, from the word or
|
||||||
|
/// stretch chosen to a character Shift+clicked, or on a touch screen held
|
||||||
|
/// on a character and dragged on. It is looked up as one word.
|
||||||
|
class const LineWords({
|
||||||
|
required final int line,
|
||||||
|
required final double fontSize,
|
||||||
|
final WrapAlignment alignment = WrapAlignment.center,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<LineWords> createState() => _LineWordsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A part of the line as it shows, from `from` in the line so shown.
|
||||||
|
typedef _Shown = ({int index, SentencePart part, int from});
|
||||||
|
|
||||||
|
class _LineWordsState() extends State<LineWords> {
|
||||||
|
/// The stretch being dragged across, from the character it started on
|
||||||
|
/// to the one under the pointer; null while none is.
|
||||||
|
(int, int)? _dragging;
|
||||||
|
|
||||||
|
/// The parts as last built, and the line they make.
|
||||||
|
List<_Shown> _shown = const [];
|
||||||
|
String _text = '';
|
||||||
|
|
||||||
|
/// The character of the line under [global], if any.
|
||||||
|
int? _characterAt(Offset global) {
|
||||||
|
final result = HitTestResult();
|
||||||
|
WidgetsBinding.instance.hitTestInView(
|
||||||
|
result,
|
||||||
|
global,
|
||||||
|
View.of(context).viewId,
|
||||||
|
);
|
||||||
|
for (final entry in result.path) {
|
||||||
|
final target = entry.target;
|
||||||
|
if (target is _RenderCharacterRun) {
|
||||||
|
return target.characterAt(global);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stretch from [a] to [b], whichever comes first, as offsets.
|
||||||
|
(int, int) _between(int a, int b) => (math.min(a, b), math.max(a, b));
|
||||||
|
|
||||||
|
void _choose((int, int) characters) {
|
||||||
|
final (from, to) = characters;
|
||||||
|
// To the end of the last character: past a pair of surrogates too.
|
||||||
|
final end = to < _text.length && _isLead(_text.codeUnitAt(to))
|
||||||
|
? to + 2
|
||||||
|
: to + 1;
|
||||||
|
context.read<WatchCubit>().selectStretch(
|
||||||
|
widget.line,
|
||||||
|
from,
|
||||||
|
end,
|
||||||
|
_text.substring(from, end),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _start(Offset global) {
|
||||||
|
if (_characterAt(global) case final at?) {
|
||||||
|
setState(() => _dragging = (at, at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _move(Offset global) {
|
||||||
|
final dragging = _dragging;
|
||||||
|
if (dragging == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_characterAt(global) case final at? when at != dragging.$2) {
|
||||||
|
setState(() => _dragging = (dragging.$1, at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _end() {
|
||||||
|
if (_dragging case (final from, final to)) {
|
||||||
|
_choose(_between(from, to));
|
||||||
|
}
|
||||||
|
setState(() => _dragging = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A click with Shift held: from the word or stretch chosen in this line
|
||||||
|
/// (or the character itself) to the character clicked.
|
||||||
|
void _shiftClick(PointerUpEvent event) {
|
||||||
|
if (event.kind != PointerDeviceKind.mouse ||
|
||||||
|
!HardwareKeyboard.instance.isShiftPressed ||
|
||||||
|
_dragging != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final at = _characterAt(event.position);
|
||||||
|
if (at == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final state = context.read<WatchCubit>().state;
|
||||||
|
final anchor = switch ((state.stretch, state.selected)) {
|
||||||
|
(final stretch?, _) when stretch.line == widget.line => stretch.start,
|
||||||
|
(_, (:final line, :final part)) when line == widget.line =>
|
||||||
|
_shown.where((shown) => shown.index == part).firstOrNull?.from ?? at,
|
||||||
|
_ => at,
|
||||||
|
};
|
||||||
|
_choose(_between(anchor, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<WatchCubit>().state;
|
||||||
|
final line = widget.line;
|
||||||
|
final parts =
|
||||||
|
state.parts[line] ??
|
||||||
|
[SentencePart(text: state.lines[line].text, start: 0)];
|
||||||
|
final joiner = switch (StudysetLanguageScope.of(
|
||||||
|
context,
|
||||||
|
).front?.split('-').first) {
|
||||||
|
'ja' || 'zh' => '',
|
||||||
|
_ => ' ',
|
||||||
|
};
|
||||||
|
var at = 0;
|
||||||
|
_shown = [
|
||||||
|
for (final (index, part) in parts.indexed)
|
||||||
|
() {
|
||||||
|
final shown = !part.isWord && part.text.contains('\n')
|
||||||
|
? SentencePart(
|
||||||
|
text: part.text.replaceAll('\n', joiner),
|
||||||
|
start: part.start,
|
||||||
|
)
|
||||||
|
: part;
|
||||||
|
final from = at;
|
||||||
|
at += shown.text.length;
|
||||||
|
return (index: index, part: shown, from: from);
|
||||||
|
}(),
|
||||||
|
];
|
||||||
|
_text = [for (final shown in _shown) shown.part.text].join();
|
||||||
|
// A stretch found as a word reads as that word while chosen: 今日 out
|
||||||
|
// of 今日は as きょう, not as こんにち.
|
||||||
|
final drawn = switch ((state.stretch, state.stretchPart, _dragging)) {
|
||||||
|
(final stretch?, final found?, null)
|
||||||
|
when stretch.line == line && found.isWord =>
|
||||||
|
_cut(_shown, stretch, found),
|
||||||
|
_ => _shown,
|
||||||
|
};
|
||||||
|
// What shows as chosen: the stretch being dragged, else the one
|
||||||
|
// chosen in this line.
|
||||||
|
final chosen = switch ((_dragging, state.stretch)) {
|
||||||
|
((final a, final b), _) => () {
|
||||||
|
final (from, to) = _between(a, b);
|
||||||
|
return (from, to + 1);
|
||||||
|
}(),
|
||||||
|
(_, final stretch?) when stretch.line == line => (
|
||||||
|
stretch.start,
|
||||||
|
stretch.end,
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
final words = Wrap(
|
||||||
|
alignment: widget.alignment,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (final (:index, :part, :from) in drawn)
|
||||||
|
if (part.text.isNotEmpty)
|
||||||
|
_Word(
|
||||||
|
part: part,
|
||||||
|
from: from,
|
||||||
|
fontSize: widget.fontSize,
|
||||||
|
known: part.entries.any(
|
||||||
|
(entry) => state.known.contains(entry.ref),
|
||||||
|
),
|
||||||
|
selected:
|
||||||
|
state.selected == (line: line, part: index) && part.isWord,
|
||||||
|
chosen: switch (chosen) {
|
||||||
|
(final start, final end)
|
||||||
|
when start < from + part.text.length && end > from =>
|
||||||
|
(start - from, end - from),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
onTap: part.isWord && index >= 0
|
||||||
|
? () {
|
||||||
|
// Shift+click chooses a stretch instead.
|
||||||
|
if (!HardwareKeyboard.instance.isShiftPressed) {
|
||||||
|
context.read<WatchCubit>().select(line, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Listener(
|
||||||
|
onPointerUp: _shiftClick,
|
||||||
|
// Touch: held on a character, then dragged on.
|
||||||
|
child: GestureDetector(
|
||||||
|
supportedDevices: const {
|
||||||
|
PointerDeviceKind.touch,
|
||||||
|
PointerDeviceKind.stylus,
|
||||||
|
PointerDeviceKind.invertedStylus,
|
||||||
|
},
|
||||||
|
onLongPressStart: (details) {
|
||||||
|
_start(details.globalPosition);
|
||||||
|
if (_dragging != null) {
|
||||||
|
unawaited(HapticFeedback.selectionClick());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressMoveUpdate: (details) => _move(details.globalPosition),
|
||||||
|
onLongPressEnd: (_) => _end(),
|
||||||
|
onLongPressCancel: () => setState(() => _dragging = null),
|
||||||
|
// A mouse: dragged across.
|
||||||
|
child: GestureDetector(
|
||||||
|
supportedDevices: const {PointerDeviceKind.mouse},
|
||||||
|
// From the character the button went down on.
|
||||||
|
dragStartBehavior: DragStartBehavior.down,
|
||||||
|
onPanStart: (details) => _start(details.globalPosition),
|
||||||
|
onPanUpdate: (details) => _move(details.globalPosition),
|
||||||
|
onPanEnd: (_) => _end(),
|
||||||
|
onPanCancel: () => setState(() => _dragging = null),
|
||||||
|
child: words,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [shown] with the [stretch] chosen, [found] as a word, as one part of
|
||||||
|
/// its own (not to be tapped: its index is -1), and what it cuts through
|
||||||
|
/// as text before and after it.
|
||||||
|
List<_Shown> _cut(
|
||||||
|
List<_Shown> shown,
|
||||||
|
WatchStretch stretch,
|
||||||
|
SentencePart found,
|
||||||
|
) {
|
||||||
|
final cut = <_Shown>[];
|
||||||
|
var placed = false;
|
||||||
|
for (final each in shown) {
|
||||||
|
final from = each.from;
|
||||||
|
final to = from + each.part.text.length;
|
||||||
|
if (to <= stretch.start || from >= stretch.end) {
|
||||||
|
cut.add(each);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (from < stretch.start) {
|
||||||
|
cut.add((
|
||||||
|
index: -1,
|
||||||
|
part: SentencePart(
|
||||||
|
text: each.part.text.substring(0, stretch.start - from),
|
||||||
|
start: each.part.start,
|
||||||
|
),
|
||||||
|
from: from,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
placed = true;
|
||||||
|
cut.add((
|
||||||
|
index: -1,
|
||||||
|
part: SentencePart(
|
||||||
|
text: stretch.text,
|
||||||
|
start: stretch.start,
|
||||||
|
entries: found.entries,
|
||||||
|
steps: found.steps,
|
||||||
|
),
|
||||||
|
from: stretch.start,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (to > stretch.end) {
|
||||||
|
cut.add((
|
||||||
|
index: -1,
|
||||||
|
part: SentencePart(
|
||||||
|
text: each.part.text.substring(stretch.end - from),
|
||||||
|
start: each.part.start + stretch.end - from,
|
||||||
|
),
|
||||||
|
from: stretch.end,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [unit] starts a pair of surrogates.
|
||||||
|
bool _isLead(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
|
||||||
|
|
||||||
|
/// A word of a line: its kanji with their readings above, tappable, marked
|
||||||
|
/// when the studyset holds it ([known]) or it is chosen ([selected]); or
|
||||||
|
/// the text between words. Its characters within a stretch chosen by hand
|
||||||
|
/// ([chosen], from and to within it) are marked one by one.
|
||||||
|
class const _Word({
|
||||||
|
required final SentencePart part,
|
||||||
|
required final int from,
|
||||||
|
required final double fontSize,
|
||||||
|
required final bool known,
|
||||||
|
required final bool selected,
|
||||||
|
required final (int, int)? chosen,
|
||||||
|
required final VoidCallback? onTap,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final entry = part.entries.firstOrNull;
|
||||||
|
final pieces = entry == null
|
||||||
|
? [(text: part.text, reading: null)]
|
||||||
|
: furigana(part.text, entry.headword, entry.reading);
|
||||||
|
final text = TextStyle(fontSize: fontSize, height: 1.2);
|
||||||
|
final reading = TextStyle(
|
||||||
|
fontSize: fontSize * 0.45,
|
||||||
|
height: 1.1,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
final marked = theme.colorScheme.primaryContainer;
|
||||||
|
var at = 0;
|
||||||
|
final word = Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (final piece in pieces)
|
||||||
|
() {
|
||||||
|
final start = at;
|
||||||
|
at += piece.text.length;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(piece.reading ?? '', style: reading),
|
||||||
|
_CharacterRun(
|
||||||
|
start: from + start,
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: _marked(piece.text, start, chosen, marked),
|
||||||
|
),
|
||||||
|
style: text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (onTap == null) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
|
child: Material(
|
||||||
|
color: selected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
width: 2,
|
||||||
|
color: known
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: word,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [text], from [start] in the word, as spans: what lies within [chosen]
|
||||||
|
/// on [colour], the rest as it is.
|
||||||
|
static List<TextSpan> _marked(
|
||||||
|
String text,
|
||||||
|
int start,
|
||||||
|
(int, int)? chosen,
|
||||||
|
Color colour,
|
||||||
|
) {
|
||||||
|
if (chosen == null) {
|
||||||
|
return [TextSpan(text: text)];
|
||||||
|
}
|
||||||
|
final (from, to) = chosen;
|
||||||
|
final inFrom = (from - start).clamp(0, text.length);
|
||||||
|
final inTo = (to - start).clamp(0, text.length);
|
||||||
|
return [
|
||||||
|
if (inFrom > 0) TextSpan(text: text.substring(0, inFrom)),
|
||||||
|
if (inTo > inFrom)
|
||||||
|
TextSpan(
|
||||||
|
text: text.substring(inFrom, inTo),
|
||||||
|
style: TextStyle(backgroundColor: colour),
|
||||||
|
),
|
||||||
|
if (inTo < text.length) TextSpan(text: text.substring(inTo)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters of a line from [start] in it, whose paragraph (the [child])
|
||||||
|
/// says which of them lies under the pointer.
|
||||||
|
class const _CharacterRun({required final int start, required super.child})
|
||||||
|
extends SingleChildRenderObjectWidget {
|
||||||
|
@override
|
||||||
|
_RenderCharacterRun createRenderObject(BuildContext context) =>
|
||||||
|
_RenderCharacterRun(start);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context,
|
||||||
|
_RenderCharacterRun renderObject,
|
||||||
|
) => renderObject.start = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RenderCharacterRun(var int start) extends RenderProxyBox {
|
||||||
|
/// The character of the line at [global], within this run: the one
|
||||||
|
/// whose box holds it, or the nearest across.
|
||||||
|
int? characterAt(Offset global) {
|
||||||
|
final paragraph = child;
|
||||||
|
if (paragraph is! RenderParagraph) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final local = paragraph.globalToLocal(global);
|
||||||
|
final text = paragraph.text.toPlainText();
|
||||||
|
int? nearest;
|
||||||
|
var distance = double.infinity;
|
||||||
|
for (var offset = 0; offset < text.length; offset++) {
|
||||||
|
if (_isTrail(text.codeUnitAt(offset))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final length = _isLead(text.codeUnitAt(offset)) ? 2 : 1;
|
||||||
|
for (final box in paragraph.getBoxesForSelection(
|
||||||
|
TextSelection(baseOffset: offset, extentOffset: offset + length),
|
||||||
|
)) {
|
||||||
|
final away = local.dx < box.left
|
||||||
|
? box.left - local.dx
|
||||||
|
: local.dx > box.right
|
||||||
|
? local.dx - box.right
|
||||||
|
: 0.0;
|
||||||
|
if (away < distance) {
|
||||||
|
distance = away;
|
||||||
|
nearest = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest == null ? null : start + nearest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [unit] ends a pair of surrogates.
|
||||||
|
bool _isTrail(int unit) => unit >= 0xDC00 && unit <= 0xDFFF;
|
||||||
|
|
@ -15,8 +15,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||||
|
|
@ -25,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/word_filter.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/word_filter.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/line_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_player.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_player.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||||
|
|
@ -369,7 +368,7 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_LineWords(line: line, fontSize: fontSize),
|
LineWords(line: line, fontSize: fontSize),
|
||||||
if (translation != null) ...[
|
if (translation != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -385,60 +384,6 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The words of the line [line], each tappable, the kanji's readings above
|
|
||||||
/// them; the line as text while it isn't cut yet. A line broken for showing
|
|
||||||
/// over a picture is one here, wrapping where the width ends (Japanese and
|
|
||||||
/// Chinese join as they are, others with a space).
|
|
||||||
class const _LineWords({
|
|
||||||
required final int line,
|
|
||||||
required final double fontSize,
|
|
||||||
final WrapAlignment alignment = WrapAlignment.center,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final state = context.watch<WatchCubit>().state;
|
|
||||||
final parts =
|
|
||||||
state.parts[line] ??
|
|
||||||
[SentencePart(text: state.lines[line].text, start: 0)];
|
|
||||||
final joiner = switch (StudysetLanguageScope.of(
|
|
||||||
context,
|
|
||||||
).front?.split('-').first) {
|
|
||||||
'ja' || 'zh' => '',
|
|
||||||
_ => ' ',
|
|
||||||
};
|
|
||||||
final row = [
|
|
||||||
for (final (index, part) in parts.indexed)
|
|
||||||
if (!part.isWord && part.text.contains('\n'))
|
|
||||||
(
|
|
||||||
index,
|
|
||||||
SentencePart(text: part.text.replaceAll('\n', joiner), start: 0),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
(index, part),
|
|
||||||
];
|
|
||||||
return Wrap(
|
|
||||||
alignment: alignment,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.end,
|
|
||||||
children: [
|
|
||||||
for (final (index, part) in row)
|
|
||||||
if (part.text.isNotEmpty)
|
|
||||||
_Word(
|
|
||||||
part: part,
|
|
||||||
fontSize: fontSize,
|
|
||||||
known: part.entries.any(
|
|
||||||
(entry) => state.known.contains(entry.ref),
|
|
||||||
),
|
|
||||||
selected:
|
|
||||||
state.selected == (line: line, part: index) && part.isWord,
|
|
||||||
onTap: part.isWord
|
|
||||||
? () => context.read<WatchCubit>().select(line, index)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A song: its cover as the player's picture, square, its colours glowing
|
/// A song: its cover as the player's picture, square, its colours glowing
|
||||||
/// around it in the dark theme, and its lyrics beside it on a desktop
|
/// around it in the dark theme, and its lyrics beside it on a desktop
|
||||||
/// (below it on a phone) as a moving banner (decided 2026-10-13).
|
/// (below it on a phone) as a moving banner (decided 2026-10-13).
|
||||||
|
|
@ -589,7 +534,7 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
|
||||||
onTap: state.synced
|
onTap: state.synced
|
||||||
? () => context.read<WatchCubit>().playLine(index)
|
? () => context.read<WatchCubit>().playLine(index)
|
||||||
: null,
|
: null,
|
||||||
child: _LineWords(
|
child: LineWords(
|
||||||
line: index,
|
line: index,
|
||||||
fontSize: sung == index ? big : small,
|
fontSize: sung == index ? big : small,
|
||||||
alignment: widget.wide
|
alignment: widget.wide
|
||||||
|
|
@ -605,76 +550,6 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A word of a line: its kanji with their readings above, tappable, marked
|
|
||||||
/// when the studyset holds it ([known]) or it is chosen; or the text
|
|
||||||
/// between words.
|
|
||||||
class const _Word({
|
|
||||||
required final SentencePart part,
|
|
||||||
required final double fontSize,
|
|
||||||
required final bool known,
|
|
||||||
required final bool selected,
|
|
||||||
required final VoidCallback? onTap,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final entry = part.entries.firstOrNull;
|
|
||||||
final pieces = entry == null
|
|
||||||
? [(text: part.text, reading: null)]
|
|
||||||
: furigana(part.text, entry.headword, entry.reading);
|
|
||||||
final text = TextStyle(fontSize: fontSize, height: 1.2);
|
|
||||||
final reading = TextStyle(
|
|
||||||
fontSize: fontSize * 0.45,
|
|
||||||
height: 1.1,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
);
|
|
||||||
final word = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
for (final piece in pieces)
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(piece.reading ?? '', style: reading),
|
|
||||||
Text(piece.text, style: text),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (onTap == null) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
|
||||||
child: Material(
|
|
||||||
color: selected
|
|
||||||
? theme.colorScheme.primaryContainer
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
width: 2,
|
|
||||||
color: known
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: word,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Beside the video: the word chosen, or the video's words; the sources
|
/// Beside the video: the word chosen, or the video's words; the sources
|
||||||
/// of the words pinned in its lower right corner (decided 2026-10-13).
|
/// of the words pinned in its lower right corner (decided 2026-10-13).
|
||||||
class const _Panel({
|
class const _Panel({
|
||||||
|
|
@ -694,7 +569,7 @@ class const _Panel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final selected = context.select<WatchCubit, bool>(
|
final selected = context.select<WatchCubit, bool>(
|
||||||
(cubit) => cubit.state.selectedPart != null,
|
(cubit) => cubit.state.chosenLine != null,
|
||||||
);
|
);
|
||||||
return _VideoTint(
|
return _VideoTint(
|
||||||
colour: colour,
|
colour: colour,
|
||||||
|
|
@ -820,12 +695,71 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
WatchCubit cubit() => context.read<WatchCubit>();
|
WatchCubit cubit() => context.read<WatchCubit>();
|
||||||
final state = context.watch<WatchCubit>().state;
|
final state = context.watch<WatchCubit>().state;
|
||||||
final part = state.selectedPart;
|
final line = state.chosenLine;
|
||||||
final selected = state.selected;
|
if (line == null) {
|
||||||
if (part == null || selected == null) {
|
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
final translation = state.translationOf(selected.line);
|
final part = state.selectedPart;
|
||||||
|
final translation = state.translationOf(line);
|
||||||
|
final heading = Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
part?.text ?? state.stretch?.text ?? '',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
onPressed: () => cubit().unselect(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final lineAndTranslation = [
|
||||||
|
Text(oneLine(state.lines[line].text)),
|
||||||
|
if (translation != null)
|
||||||
|
Text(
|
||||||
|
oneLine(translation),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
// A stretch chosen by hand while it is looked up, if that failed, or
|
||||||
|
// when the dictionary has no such word: Jisho may still know it.
|
||||||
|
if (part == null || !part.isWord) {
|
||||||
|
final text = part?.text ?? state.stretch?.text ?? '';
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
||||||
|
children: [
|
||||||
|
heading,
|
||||||
|
...lineAndTranslation,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (part == null && !state.stretchFailed)
|
||||||
|
const Center(child: AppLoadingIndicator())
|
||||||
|
else ...[
|
||||||
|
Text(
|
||||||
|
state.stretchFailed
|
||||||
|
? l10n.watchStretchFailed
|
||||||
|
: l10n.watchStretchNone(text),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(LucideIcons.externalLink, size: 16),
|
||||||
|
label: Text(l10n.watchStretchJisho),
|
||||||
|
onPressed: () => launchUrl(
|
||||||
|
Uri.https('jisho.org', '/search/$text'),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
// On the headword's line, at its end (decided 2026-10-13): Jisho, and
|
// On the headword's line, at its end (decided 2026-10-13): Jisho, and
|
||||||
// Add for a word the studyset lacks.
|
// Add for a word the studyset lacks.
|
||||||
List<Widget> actions(DictionaryWord entry) => [
|
List<Widget> actions(DictionaryWord entry) => [
|
||||||
|
|
@ -865,18 +799,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
heading,
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(part.text, style: theme.textTheme.headlineSmall),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
|
||||||
icon: const Icon(LucideIcons.x),
|
|
||||||
onPressed: () => cubit().unselect(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (part.steps.isNotEmpty)
|
if (part.steps.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
l10n.watchFormOf(part.entries.first.headword),
|
l10n.watchFormOf(part.entries.first.headword),
|
||||||
|
|
@ -885,14 +808,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(oneLine(state.lines[selected.line].text)),
|
...lineAndTranslation,
|
||||||
if (translation != null)
|
|
||||||
Text(
|
|
||||||
oneLine(translation),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
for (final (index, entry) in part.entries.indexed) ...[
|
for (final (index, entry) in part.entries.indexed) ...[
|
||||||
if (index > 0) const Divider(height: 1),
|
if (index > 0) const Divider(height: 1),
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,62 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('a stretch chosen by hand', () {
|
||||||
|
final greetings = [
|
||||||
|
word('30', '今日', reading: 'きょう', pos: ['n', 'adv']),
|
||||||
|
word('31', '今日は', reading: 'こんにちは', pos: ['int']),
|
||||||
|
word('32', 'は', pos: ['prt']),
|
||||||
|
...words,
|
||||||
|
];
|
||||||
|
|
||||||
|
test('is looked up whole, also inside a word the cut took longer: 今日 '
|
||||||
|
'out of 今日は', () async {
|
||||||
|
final repository = DictionaryRepository(
|
||||||
|
FakeDictionaryGateway(words: greetings),
|
||||||
|
);
|
||||||
|
final [line] = await repository.wordsOf(jmdict, ['今日は']);
|
||||||
|
expect(line.single.entries.first.headword, '今日は');
|
||||||
|
|
||||||
|
final chosen = await repository.wordOfStretch(jmdict, '今日');
|
||||||
|
|
||||||
|
expect(chosen.text, '今日');
|
||||||
|
expect(chosen.start, 0);
|
||||||
|
expect(chosen.entries.single.headword, '今日');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('as it stands or as a form of a word: 会い of 会う', () async {
|
||||||
|
final repository = DictionaryRepository(
|
||||||
|
FakeDictionaryGateway(words: greetings),
|
||||||
|
);
|
||||||
|
|
||||||
|
final chosen = await repository.wordOfStretch(jmdict, '会い', start: 8);
|
||||||
|
|
||||||
|
// A stem goes back to its word in no step of its own.
|
||||||
|
expect(chosen.entries.first.headword, '会う');
|
||||||
|
expect(chosen.start, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is nothing the dictionary has when it is no word, or holds '
|
||||||
|
'punctuation', () async {
|
||||||
|
final repository = DictionaryRepository(
|
||||||
|
FakeDictionaryGateway(words: greetings),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await repository.wordOfStretch(jmdict, '駅で')).isWord, isFalse);
|
||||||
|
expect((await repository.wordOfStretch(jmdict, '昨日、駅')).isWord, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is asked for once', () async {
|
||||||
|
final gateway = FakeDictionaryGateway(words: greetings);
|
||||||
|
final repository = DictionaryRepository(gateway);
|
||||||
|
|
||||||
|
await repository.wordOfStretch(jmdict, '今日');
|
||||||
|
await repository.wordOfStretch(jmdict, '今日');
|
||||||
|
|
||||||
|
expect(gateway.keysAsked, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('furigana', () {
|
group('furigana', () {
|
||||||
test('reads the kanji of a word as its dictionary form gives them', () {
|
test('reads the kanji of a word as its dictionary form gives them', () {
|
||||||
expect(furigana('食べられなかった', '食べる', 'たべる'), [
|
expect(furigana('食べられなかった', '食べる', 'たべる'), [
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,98 @@ void main() {
|
||||||
expect(card.content.entry, (source: 'jmdict', id: '37'));
|
expect(card.content.entry, (source: 'jmdict', id: '37'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('a stretch chosen by hand (decided 2026-10-13: by characters)', () {
|
||||||
|
final greetings = [
|
||||||
|
word('40', '今日', reading: 'きょう', pos: ['n'], glosses: ['today']),
|
||||||
|
word('41', '今日は', reading: 'こんにちは', pos: ['int'], glosses: ['hello']),
|
||||||
|
word('42', 'は', pos: ['prt'], labels: ['common']),
|
||||||
|
word('43', '雨', reading: 'あめ', pos: ['n'], glosses: ['rain']),
|
||||||
|
word('44', 'だ', pos: ['cop'], labels: ['common']),
|
||||||
|
];
|
||||||
|
Future<void> watchGreetings(WidgetTester tester) async {
|
||||||
|
await watch(
|
||||||
|
tester,
|
||||||
|
vtt: {
|
||||||
|
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は雨だ\n',
|
||||||
|
('episode-1', 3):
|
||||||
|
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA grey sky.\n',
|
||||||
|
},
|
||||||
|
words: greetings,
|
||||||
|
);
|
||||||
|
await playTo(tester, const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The middle of the [index]th character of the base text [text] of
|
||||||
|
/// the line (four characters to a run at most here, each as wide).
|
||||||
|
Offset character(WidgetTester tester, String text, int index) {
|
||||||
|
final rect = tester.getRect(find.text(text, findRichText: true).first);
|
||||||
|
final width = rect.width / text.length;
|
||||||
|
return Offset(rect.left + width * (index + 0.5), rect.center.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('dragged across with a mouse, the characters chosen are '
|
||||||
|
'looked up as one word, also inside a word the cut took longer: '
|
||||||
|
'今日 out of 今日は', (tester) async {
|
||||||
|
await watchGreetings(tester);
|
||||||
|
await players.last!.play();
|
||||||
|
expect(find.text('今日', findRichText: true), findsOneWidget);
|
||||||
|
|
||||||
|
final drag = await tester.startGesture(
|
||||||
|
character(tester, '今日', 0),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await drag.moveTo(character(tester, '今日', 1));
|
||||||
|
await drag.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(players.last!.state.value.playing, isFalse);
|
||||||
|
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||||||
|
expect(find.textContaining('hello', findRichText: true), findsNothing);
|
||||||
|
// While chosen, the line reads it as the word chosen: きょう, not
|
||||||
|
// こんにち of こんにちは.
|
||||||
|
expect(find.text('こんにち'), findsNothing);
|
||||||
|
expect(find.text('きょう'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Shift+click chooses from the word chosen to the character '
|
||||||
|
'clicked; what the dictionary lacks as one word is searched on '
|
||||||
|
'Jisho', (tester) async {
|
||||||
|
await watchGreetings(tester);
|
||||||
|
await tester.tap(find.text('雨', findRichText: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('rain', findRichText: true), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||||
|
final click = await tester.startGesture(
|
||||||
|
character(tester, 'だ', 0),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await click.up();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text("雨だ isn't in the dictionary as one word."),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('Search Jisho for it'), findsOneWidget);
|
||||||
|
expect(find.textContaining('rain', findRichText: true), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('on a phone, holding a character starts a stretch, and '
|
||||||
|
'moving on extends it', (tester) async {
|
||||||
|
await watchGreetings(tester);
|
||||||
|
|
||||||
|
final hold = await tester.startGesture(character(tester, '今日', 0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await hold.moveTo(character(tester, '今日', 1));
|
||||||
|
await hold.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets("the episode's words can be studied or crammed: the cards of "
|
testWidgets("the episode's words can be studied or crammed: the cards of "
|
||||||
'its words, held before it was watched too, get its tag', (tester) async {
|
'its words, held before it was watched too, get its tag', (tester) async {
|
||||||
final station = sampleWords.firstWhere((word) => word.headword == '駅');
|
final station = sampleWords.firstWhere((word) => word.headword == '駅');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue