feat(jellyfin): the words' panel unfolds senses, shows progress, filters
The panel beside the video lists each entry's senses one to a line, as Jisho does (and as every dictionary list in the app now does), folded to two lines; an entry unfolds over the list, not in it, after a moment under the pointer or on a tap, in the video's colour, so nothing moves under the pointer and words of many senses (上げる has 25) crowd out nothing. Each entry links to the word on Jisho.org, opened only when tapped. A word the studyset holds shows how far its card is learnt: New, Learning, Due, or Learned with when it comes again. The video's words go through a filter in place of New only: new to the studyset, held or all, JLPT levels, and word classes. The dictionary's sources stay in the panel's lower right corner, and the player's controls hide after 2 seconds rather than 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
48a9564231
commit
fbe53c3441
15 changed files with 1019 additions and 113 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1792,6 +1792,24 @@
|
||||||
"watchKeyShift": "Umschalt",
|
"watchKeyShift": "Umschalt",
|
||||||
"watchKeyHome": "Pos1",
|
"watchKeyHome": "Pos1",
|
||||||
"watchKeyEnd": "Ende",
|
"watchKeyEnd": "Ende",
|
||||||
|
"watchProgressNew": "Neu",
|
||||||
|
"watchProgressLearning": "Am Lernen",
|
||||||
|
"watchProgressDue": "Fällig",
|
||||||
|
"watchProgressLearned": "Gelernt · {days, plural, =0{heute wieder} =1{morgen wieder} other{in {days} Tagen wieder}}",
|
||||||
|
"watchJisho": "Auf Jisho nachschlagen",
|
||||||
|
"watchFilter": "Wörter filtern",
|
||||||
|
"watchFilterFresh": "Neue Wörter",
|
||||||
|
"watchFilterHeld": "Wörter im Lernset",
|
||||||
|
"watchFilterAll": "Alle Wörter",
|
||||||
|
"watchFilterNoLevel": "Ohne JLPT-Stufe",
|
||||||
|
"watchFilterClass": "Wortart",
|
||||||
|
"watchFilterEmpty": "Keine Wörter dieses Videos passen zum Filter.",
|
||||||
|
"watchClassNoun": "Nomen",
|
||||||
|
"watchClassVerb": "Verben",
|
||||||
|
"watchClassAdjective": "Adjektive",
|
||||||
|
"watchClassAdverb": "Adverbien",
|
||||||
|
"watchClassExpression": "Ausdrücke",
|
||||||
|
"watchClassOther": "Andere",
|
||||||
"watchNone": "Keine",
|
"watchNone": "Keine",
|
||||||
"watchPlay": "Abspielen",
|
"watchPlay": "Abspielen",
|
||||||
"watchPause": "Pause",
|
"watchPause": "Pause",
|
||||||
|
|
@ -1799,7 +1817,6 @@
|
||||||
"watchNextLine": "Nächste Zeile",
|
"watchNextLine": "Nächste Zeile",
|
||||||
"watchPauseAtLineEnd": "Nach jeder Zeile anhalten",
|
"watchPauseAtLineEnd": "Nach jeder Zeile anhalten",
|
||||||
"watchWordsTitle": "Wörter in diesem Video",
|
"watchWordsTitle": "Wörter in diesem Video",
|
||||||
"watchWordsNewOnly": "Nur neue",
|
|
||||||
"watchWordsFinding": "Wörter werden gesucht …",
|
"watchWordsFinding": "Wörter werden gesucht …",
|
||||||
"watchWordCount": "{count}×",
|
"watchWordCount": "{count}×",
|
||||||
"watchAdd": "Hinzufügen",
|
"watchAdd": "Hinzufügen",
|
||||||
|
|
|
||||||
|
|
@ -2105,6 +2105,25 @@
|
||||||
"watchKeyShift": "Shift",
|
"watchKeyShift": "Shift",
|
||||||
"watchKeyHome": "Home",
|
"watchKeyHome": "Home",
|
||||||
"watchKeyEnd": "End",
|
"watchKeyEnd": "End",
|
||||||
|
"watchProgressNew": "New",
|
||||||
|
"watchProgressLearning": "Learning",
|
||||||
|
"watchProgressDue": "Due",
|
||||||
|
"watchProgressLearned": "Learned · {days, plural, =0{again today} =1{again tomorrow} other{again in {days} days}}",
|
||||||
|
"@watchProgressLearned": {"placeholders": {"days": {"type": "int"}}},
|
||||||
|
"watchJisho": "Look up on Jisho",
|
||||||
|
"watchFilter": "Filter the words",
|
||||||
|
"watchFilterFresh": "New words",
|
||||||
|
"watchFilterHeld": "Words in the studyset",
|
||||||
|
"watchFilterAll": "All words",
|
||||||
|
"watchFilterNoLevel": "No JLPT level",
|
||||||
|
"watchFilterClass": "Word class",
|
||||||
|
"watchFilterEmpty": "No words of this video match the filter.",
|
||||||
|
"watchClassNoun": "Nouns",
|
||||||
|
"watchClassVerb": "Verbs",
|
||||||
|
"watchClassAdjective": "Adjectives",
|
||||||
|
"watchClassAdverb": "Adverbs",
|
||||||
|
"watchClassExpression": "Expressions",
|
||||||
|
"watchClassOther": "Others",
|
||||||
"watchNone": "None",
|
"watchNone": "None",
|
||||||
"watchPlay": "Play",
|
"watchPlay": "Play",
|
||||||
"watchPause": "Pause",
|
"watchPause": "Pause",
|
||||||
|
|
@ -2112,7 +2131,6 @@
|
||||||
"watchNextLine": "Next line",
|
"watchNextLine": "Next line",
|
||||||
"watchPauseAtLineEnd": "Pause after each line",
|
"watchPauseAtLineEnd": "Pause after each line",
|
||||||
"watchWordsTitle": "Words in this video",
|
"watchWordsTitle": "Words in this video",
|
||||||
"watchWordsNewOnly": "New only",
|
|
||||||
"watchWordsFinding": "Finding the words…",
|
"watchWordsFinding": "Finding the words…",
|
||||||
"watchWordCount": "{count}×",
|
"watchWordCount": "{count}×",
|
||||||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -10593,6 +10593,114 @@ abstract class AppLocalizations {
|
||||||
/// **'End'**
|
/// **'End'**
|
||||||
String get watchKeyEnd;
|
String get watchKeyEnd;
|
||||||
|
|
||||||
|
/// No description provided for @watchProgressNew.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New'**
|
||||||
|
String get watchProgressNew;
|
||||||
|
|
||||||
|
/// No description provided for @watchProgressLearning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Learning'**
|
||||||
|
String get watchProgressLearning;
|
||||||
|
|
||||||
|
/// No description provided for @watchProgressDue.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Due'**
|
||||||
|
String get watchProgressDue;
|
||||||
|
|
||||||
|
/// No description provided for @watchProgressLearned.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Learned · {days, plural, =0{again today} =1{again tomorrow} other{again in {days} days}}'**
|
||||||
|
String watchProgressLearned(int days);
|
||||||
|
|
||||||
|
/// No description provided for @watchJisho.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Look up on Jisho'**
|
||||||
|
String get watchJisho;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filter the words'**
|
||||||
|
String get watchFilter;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterFresh.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New words'**
|
||||||
|
String get watchFilterFresh;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterHeld.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Words in the studyset'**
|
||||||
|
String get watchFilterHeld;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All words'**
|
||||||
|
String get watchFilterAll;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterNoLevel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No JLPT level'**
|
||||||
|
String get watchFilterNoLevel;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterClass.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Word class'**
|
||||||
|
String get watchFilterClass;
|
||||||
|
|
||||||
|
/// No description provided for @watchFilterEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No words of this video match the filter.'**
|
||||||
|
String get watchFilterEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassNoun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nouns'**
|
||||||
|
String get watchClassNoun;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassVerb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verbs'**
|
||||||
|
String get watchClassVerb;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassAdjective.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Adjectives'**
|
||||||
|
String get watchClassAdjective;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassAdverb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Adverbs'**
|
||||||
|
String get watchClassAdverb;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassExpression.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Expressions'**
|
||||||
|
String get watchClassExpression;
|
||||||
|
|
||||||
|
/// No description provided for @watchClassOther.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Others'**
|
||||||
|
String get watchClassOther;
|
||||||
|
|
||||||
/// No description provided for @watchNone.
|
/// No description provided for @watchNone.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -10635,12 +10743,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Words in this video'**
|
/// **'Words in this video'**
|
||||||
String get watchWordsTitle;
|
String get watchWordsTitle;
|
||||||
|
|
||||||
/// No description provided for @watchWordsNewOnly.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'New only'**
|
|
||||||
String get watchWordsNewOnly;
|
|
||||||
|
|
||||||
/// No description provided for @watchWordsFinding.
|
/// No description provided for @watchWordsFinding.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -6658,6 +6658,70 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get watchKeyEnd => 'Ende';
|
String get watchKeyEnd => 'Ende';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressNew => 'Neu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressLearning => 'Am Lernen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressDue => 'Fällig';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String watchProgressLearned(int days) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
days,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'in $days Tagen wieder',
|
||||||
|
one: 'morgen wieder',
|
||||||
|
zero: 'heute wieder',
|
||||||
|
);
|
||||||
|
return 'Gelernt · $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchJisho => 'Auf Jisho nachschlagen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilter => 'Wörter filtern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterFresh => 'Neue Wörter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterHeld => 'Wörter im Lernset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterAll => 'Alle Wörter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterNoLevel => 'Ohne JLPT-Stufe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterClass => 'Wortart';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterEmpty =>
|
||||||
|
'Keine Wörter dieses Videos passen zum Filter.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassNoun => 'Nomen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassVerb => 'Verben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassAdjective => 'Adjektive';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassAdverb => 'Adverbien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassExpression => 'Ausdrücke';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassOther => 'Andere';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchNone => 'Keine';
|
String get watchNone => 'Keine';
|
||||||
|
|
||||||
|
|
@ -6679,9 +6743,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get watchWordsTitle => 'Wörter in diesem Video';
|
String get watchWordsTitle => 'Wörter in diesem Video';
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchWordsNewOnly => 'Nur neue';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchWordsFinding => 'Wörter werden gesucht …';
|
String get watchWordsFinding => 'Wörter werden gesucht …';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6600,6 +6600,69 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get watchKeyEnd => 'End';
|
String get watchKeyEnd => 'End';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressNew => 'New';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressLearning => 'Learning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchProgressDue => 'Due';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String watchProgressLearned(int days) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
days,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'again in $days days',
|
||||||
|
one: 'again tomorrow',
|
||||||
|
zero: 'again today',
|
||||||
|
);
|
||||||
|
return 'Learned · $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchJisho => 'Look up on Jisho';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilter => 'Filter the words';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterFresh => 'New words';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterHeld => 'Words in the studyset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterAll => 'All words';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterNoLevel => 'No JLPT level';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterClass => 'Word class';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchFilterEmpty => 'No words of this video match the filter.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassNoun => 'Nouns';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassVerb => 'Verbs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassAdjective => 'Adjectives';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassAdverb => 'Adverbs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassExpression => 'Expressions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get watchClassOther => 'Others';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchNone => 'None';
|
String get watchNone => 'None';
|
||||||
|
|
||||||
|
|
@ -6621,9 +6684,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get watchWordsTitle => 'Words in this video';
|
String get watchWordsTitle => 'Words in this video';
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchWordsNewOnly => 'New only';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get watchWordsFinding => 'Finding the words…';
|
String get watchWordsFinding => 'Finding the words…';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,10 @@ class const VocabularySensesView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [senses] as one paragraph for a row of a list, cut short where it
|
/// [senses] for a row of a list, cut short where it runs over: each sense
|
||||||
/// runs over: each sense's glosses, then its grammar notes small, grey and
|
/// on a line of its own, numbered when several, as Jisho lists them
|
||||||
/// slanted, several numbered.
|
/// (decided 2026-10-13), its glosses, then its grammar notes small, grey
|
||||||
|
/// and slanted.
|
||||||
TextSpan vocabularySensesSpan(
|
TextSpan vocabularySensesSpan(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<VocabularySense> senses, {
|
List<VocabularySense> senses, {
|
||||||
|
|
@ -94,7 +95,7 @@ TextSpan vocabularySensesSpan(
|
||||||
style: style,
|
style: style,
|
||||||
children: [
|
children: [
|
||||||
for (final (i, sense) in shown.indexed) ...[
|
for (final (i, sense) in shown.indexed) ...[
|
||||||
if (i > 0) const TextSpan(text: ' '),
|
if (i > 0) const TextSpan(text: '\n'),
|
||||||
if (shown.length > 1) TextSpan(text: '${i + 1}. ', style: markerStyle),
|
if (shown.length > 1) TextSpan(text: '${i + 1}. ', style: markerStyle),
|
||||||
if (sense.label?.trim() case final label? when label.isNotEmpty)
|
if (sense.label?.trim() case final label? when label.isNotEmpty)
|
||||||
TextSpan(text: '($label) ', style: tagStyle),
|
TextSpan(text: '($label) ', style: tagStyle),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
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/study/data/review_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
|
|
||||||
|
/// How far a studyset's words from the dictionary are learnt: the schedule
|
||||||
|
/// of each card made from an entry (front to back), by the entry, so a
|
||||||
|
/// word met again shows where its card stands.
|
||||||
|
class EntryProgress(
|
||||||
|
final CardRepository _cards,
|
||||||
|
final ReviewRepository _reviews,
|
||||||
|
) {
|
||||||
|
/// The schedules of [studysetId]'s cards made from dictionary entries,
|
||||||
|
/// by their entry; a card never studied has one of no reviews.
|
||||||
|
Future<Map<DictionaryRef, CardSchedule>> of(String studysetId) async {
|
||||||
|
final entryOf = <String, DictionaryRef>{
|
||||||
|
for (final card in await _cards.cardsOf(studysetId))
|
||||||
|
card.id: ?card.content.entry,
|
||||||
|
};
|
||||||
|
final schedules = await _reviews.schedulesOf(entryOf.keys.toList());
|
||||||
|
return {
|
||||||
|
for (final MapEntry(key: id, value: ref) in entryOf.entries)
|
||||||
|
ref: ?schedules[id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,17 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
/// A word as a dictionary lists it: its headword and reading, its senses
|
/// A word as a dictionary lists it: its headword and reading, [progress]
|
||||||
/// with their grammar notes small, grey and slanted, cut short at two
|
/// beside them if given, its senses one to a line with their grammar notes
|
||||||
/// lines, its level and whether it is common; [trailing] at its end, and
|
/// small, grey and slanted, cut short at two lines unless [whole], its
|
||||||
/// [onTap] for the whole row.
|
/// level and whether it is common; [trailing] at its end, and [onTap] for
|
||||||
|
/// the whole row.
|
||||||
class const DictionaryWordTile({
|
class const DictionaryWordTile({
|
||||||
required final DictionaryWord word,
|
required final DictionaryWord word,
|
||||||
final Widget? trailing,
|
final Widget? trailing,
|
||||||
final VoidCallback? onTap,
|
final VoidCallback? onTap,
|
||||||
|
final Widget? progress,
|
||||||
|
final bool whole = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -39,23 +42,40 @@ class const DictionaryWordTile({
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (progress case final progress?)
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: progress,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text.rich(
|
if (whole)
|
||||||
vocabularySensesSpan(
|
VocabularySensesView(
|
||||||
context,
|
senses: word.senses,
|
||||||
word.senses,
|
centred: false,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text.rich(
|
||||||
|
vocabularySensesSpan(
|
||||||
|
context,
|
||||||
|
word.senses,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (marks.isNotEmpty) ...[
|
if (marks.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Wrap(
|
Wrap(
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
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/dictionary_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.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/sentence_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
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/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||||
|
|
||||||
/// How far opening a video got.
|
/// How far opening a video got.
|
||||||
|
|
@ -86,8 +89,11 @@ final class const WatchState({
|
||||||
final List<VideoWord>? words,
|
final List<VideoWord>? words,
|
||||||
final bool pauseAtLineEnd = false,
|
final bool pauseAtLineEnd = false,
|
||||||
|
|
||||||
/// Whether the video's words list only those the studyset lacks.
|
/// Which of the video's words its list shows.
|
||||||
final bool newOnly = true,
|
final WordFilter filter = const WordFilter(),
|
||||||
|
|
||||||
|
/// Where the studyset's cards made from entries stand, by entry.
|
||||||
|
final Map<DictionaryRef, CardSchedule> progress = const {},
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// The words of the line shown.
|
/// The words of the line shown.
|
||||||
List<SentencePart>? get lineParts => line == null ? null : parts[line];
|
List<SentencePart>? get lineParts => line == null ? null : parts[line];
|
||||||
|
|
@ -121,7 +127,8 @@ final class const WatchState({
|
||||||
Set<DictionaryRef>? known,
|
Set<DictionaryRef>? known,
|
||||||
Object? words = _same,
|
Object? words = _same,
|
||||||
bool? pauseAtLineEnd,
|
bool? pauseAtLineEnd,
|
||||||
bool? newOnly,
|
WordFilter? filter,
|
||||||
|
Map<DictionaryRef, CardSchedule>? progress,
|
||||||
}) => WatchState(
|
}) => WatchState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
problem: problem == _same ? this.problem : problem as JellyfinProblem?,
|
problem: problem == _same ? this.problem : problem as JellyfinProblem?,
|
||||||
|
|
@ -147,7 +154,8 @@ final class const WatchState({
|
||||||
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,
|
||||||
newOnly: newOnly ?? this.newOnly,
|
filter: filter ?? this.filter,
|
||||||
|
progress: progress ?? this.progress,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -170,7 +178,8 @@ final class const WatchState({
|
||||||
known,
|
known,
|
||||||
words,
|
words,
|
||||||
pauseAtLineEnd,
|
pauseAtLineEnd,
|
||||||
newOnly,
|
filter,
|
||||||
|
progress,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +194,8 @@ class WatchCubit(
|
||||||
final JellyfinRepository _jellyfin,
|
final JellyfinRepository _jellyfin,
|
||||||
final MediaPlayers _players,
|
final MediaPlayers _players,
|
||||||
final DictionaryRepository _dictionaries,
|
final DictionaryRepository _dictionaries,
|
||||||
final DictionaryAdder _adder, {
|
final DictionaryAdder _adder,
|
||||||
|
final EntryProgress _progress, {
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
required final String itemId,
|
required final String itemId,
|
||||||
required final StudysetLanguages languages,
|
required final StudysetLanguages languages,
|
||||||
|
|
@ -226,6 +236,7 @@ class WatchCubit(
|
||||||
final lines = await _cues(info, study);
|
final lines = await _cues(info, study);
|
||||||
final translations = await _cues(info, translation);
|
final translations = await _cues(info, translation);
|
||||||
final known = await _adder.entriesIn(studysetId);
|
final known = await _adder.entriesIn(studysetId);
|
||||||
|
final progress = await _progress.of(studysetId);
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +253,7 @@ class WatchCubit(
|
||||||
translations: translations,
|
translations: translations,
|
||||||
line: lastCueIndex(lines, info.resumeAt),
|
line: lastCueIndex(lines, info.resumeAt),
|
||||||
known: known,
|
known: known,
|
||||||
|
progress: progress,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on JellyfinException catch (error) {
|
} on JellyfinException catch (error) {
|
||||||
|
|
@ -433,8 +445,11 @@ class WatchCubit(
|
||||||
],
|
],
|
||||||
tagNames: [?(item?.seriesName ?? item?.name)],
|
tagNames: [?(item?.seriesName ?? item?.name)],
|
||||||
);
|
);
|
||||||
|
final progress = await _progress.of(studysetId);
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
emit(state.copyWith(known: {...state.known, entry.ref}));
|
emit(
|
||||||
|
state.copyWith(known: {...state.known, entry.ref}, progress: progress),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,8 +527,8 @@ class WatchCubit(
|
||||||
void pauseAtLineEnd({required bool on}) =>
|
void pauseAtLineEnd({required bool on}) =>
|
||||||
emit(state.copyWith(pauseAtLineEnd: on));
|
emit(state.copyWith(pauseAtLineEnd: on));
|
||||||
|
|
||||||
/// Lists only the words the studyset lacks, or all.
|
/// Lists the video's words [filter] admits.
|
||||||
void showNewOnly({required bool on}) => emit(state.copyWith(newOnly: on));
|
void filterWords(WordFilter filter) => emit(state.copyWith(filter: filter));
|
||||||
|
|
||||||
/// Reads [track] as the studyset's language, or none.
|
/// Reads [track] as the studyset's language, or none.
|
||||||
Future<void> chooseStudyTrack(JellyfinSubtitleTrack? track) async {
|
Future<void> chooseStudyTrack(JellyfinSubtitleTrack? track) async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
|
||||||
|
/// Which of a video's words to list by whether the studyset holds them.
|
||||||
|
enum WordsShown() {
|
||||||
|
/// Those it lacks: what is left to learn from the video.
|
||||||
|
fresh,
|
||||||
|
|
||||||
|
/// Those it holds already.
|
||||||
|
held,
|
||||||
|
|
||||||
|
/// All of them.
|
||||||
|
all,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of word a word is, by its first sense's grammar (JMdict's
|
||||||
|
/// codes): the classes a learner sorts words by.
|
||||||
|
enum WordClass() {
|
||||||
|
noun,
|
||||||
|
verb,
|
||||||
|
adjective,
|
||||||
|
adverb,
|
||||||
|
expression,
|
||||||
|
other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The class of [word], by its first sense: `n…` and pronouns nouns, `v…`
|
||||||
|
/// verbs, `adj-…` adjectives, `adv…` adverbs, `exp` expressions.
|
||||||
|
WordClass wordClassOf(DictionaryWord word) {
|
||||||
|
final pos = word.senses.firstOrNull?.pos ?? const <String>[];
|
||||||
|
bool any(bool Function(String code) test) => pos.any(test);
|
||||||
|
if (any((code) => code == 'exp')) {
|
||||||
|
return WordClass.expression;
|
||||||
|
}
|
||||||
|
if (any((code) => code.startsWith('v') && code != 'vs' && code != 'vs-s') &&
|
||||||
|
!any((code) => code == 'n')) {
|
||||||
|
return WordClass.verb;
|
||||||
|
}
|
||||||
|
if (any((code) => code.startsWith('adj'))) {
|
||||||
|
return WordClass.adjective;
|
||||||
|
}
|
||||||
|
if (any((code) => code.startsWith('adv'))) {
|
||||||
|
return WordClass.adverb;
|
||||||
|
}
|
||||||
|
if (any((code) => code == 'n' || code.startsWith('n-') || code == 'pn')) {
|
||||||
|
return WordClass.noun;
|
||||||
|
}
|
||||||
|
return WordClass.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of a video's words its list shows (decided 2026-10-13): by
|
||||||
|
/// whether the studyset holds them, by JLPT level ([levels], 5 to 1, null
|
||||||
|
/// for words without one; none chosen, any) and by [classes] (none
|
||||||
|
/// chosen, any).
|
||||||
|
final class const WordFilter({
|
||||||
|
final WordsShown shown = WordsShown.fresh,
|
||||||
|
final Set<int?> levels = const {},
|
||||||
|
final Set<WordClass> classes = const {},
|
||||||
|
}) with Equatable {
|
||||||
|
/// Whether it narrows the list beyond what it shows at first.
|
||||||
|
bool get narrows =>
|
||||||
|
shown == WordsShown.held || levels.isNotEmpty || classes.isNotEmpty;
|
||||||
|
|
||||||
|
/// Whether [word] is listed, the studyset holding it or not ([held]).
|
||||||
|
bool admits(DictionaryWord word, {required bool held}) =>
|
||||||
|
switch (shown) {
|
||||||
|
WordsShown.fresh => !held,
|
||||||
|
WordsShown.held => held,
|
||||||
|
WordsShown.all => true,
|
||||||
|
} &&
|
||||||
|
(levels.isEmpty || levels.contains(word.jlpt)) &&
|
||||||
|
(classes.isEmpty || classes.contains(wordClassOf(word)));
|
||||||
|
|
||||||
|
WordFilter copyWith({
|
||||||
|
WordsShown? shown,
|
||||||
|
Set<int?>? levels,
|
||||||
|
Set<WordClass>? classes,
|
||||||
|
}) => WordFilter(
|
||||||
|
shown: shown ?? this.shown,
|
||||||
|
levels: levels ?? this.levels,
|
||||||
|
classes: classes ?? this.classes,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [shown, levels, classes];
|
||||||
|
}
|
||||||
|
|
@ -38,8 +38,9 @@ class const WatchPlayer({
|
||||||
required final ValueNotifier<WatchSettingsPage?> settings,
|
required final ValueNotifier<WatchSettingsPage?> settings,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
/// How long the controls stay without the pointer moving.
|
/// How long the controls stay without the pointer moving (decided
|
||||||
static const showFor = Duration(seconds: 3);
|
/// 2026-10-13: 3 s was long when pausing and playing often).
|
||||||
|
static const showFor = Duration(seconds: 2);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WatchPlayer> createState() => _WatchPlayerState();
|
State<WatchPlayer> createState() => _WatchPlayerState();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.dart';
|
||||||
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/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/furigana.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||||
|
|
@ -15,12 +19,15 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dic
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
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/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/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/studyset/widget/studyset_language_scope.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
/// Watching a video of the Jellyfin server to learn from, in the whole
|
/// Watching a video of the Jellyfin server to learn from, in the whole
|
||||||
/// window (Animelon's way): the video, under it its line of subtitles in
|
/// window (Animelon's way): the video, under it its line of subtitles in
|
||||||
|
|
@ -60,6 +67,7 @@ class const _Watch({
|
||||||
dependencies.mediaPlayers,
|
dependencies.mediaPlayers,
|
||||||
dependencies.dictionaries,
|
dependencies.dictionaries,
|
||||||
DictionaryAdder(person.cards, person.tags),
|
DictionaryAdder(person.cards, person.tags),
|
||||||
|
EntryProgress(person.cards, person.reviews),
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
languages: languages,
|
languages: languages,
|
||||||
|
|
@ -452,7 +460,8 @@ class const _Word({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Beside the video: the word chosen, or the video's words.
|
/// 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).
|
||||||
class const _Panel({required final Dictionary? dictionary})
|
class const _Panel({required final Dictionary? dictionary})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -469,9 +478,26 @@ class const _Panel({required final Dictionary? dictionary})
|
||||||
(cubit) => cubit.state.selectedPart != null,
|
(cubit) => cubit.state.selectedPart != null,
|
||||||
);
|
);
|
||||||
return _VideoTint(
|
return _VideoTint(
|
||||||
child: selected
|
child: Column(
|
||||||
? _ChosenWord(dictionary: dictionary)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
: _VideoWords(dictionary: dictionary),
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: selected
|
||||||
|
? _ChosenWord(dictionary: dictionary)
|
||||||
|
: _VideoWords(dictionary: dictionary),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: DictionarySources(
|
||||||
|
dictionary: dictionary,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -528,23 +554,45 @@ class _VideoTintState() extends State<_VideoTint> {
|
||||||
context.select<WatchCubit, JellyfinItem?>((cubit) => cubit.state.item),
|
context.select<WatchCubit, JellyfinItem?>((cubit) => cubit.state.item),
|
||||||
Theme.of(context).brightness,
|
Theme.of(context).brightness,
|
||||||
);
|
);
|
||||||
return MouseRegion(
|
return _VideoColour(
|
||||||
onEnter: (_) => setState(() => _over = true),
|
scheme: _scheme,
|
||||||
onExit: (_) => setState(() => _over = false),
|
child: MouseRegion(
|
||||||
child: AnimatedContainer(
|
onEnter: (_) => setState(() => _over = true),
|
||||||
duration: const Duration(milliseconds: 250),
|
onExit: (_) => setState(() => _over = false),
|
||||||
color: _over
|
child: AnimatedContainer(
|
||||||
? _scheme?.secondaryContainer.withValues(alpha: 0.6) ??
|
duration: const Duration(milliseconds: 250),
|
||||||
Colors.transparent
|
color: _over
|
||||||
: Colors.transparent,
|
? _scheme?.secondaryContainer.withValues(alpha: 0.6) ??
|
||||||
child: widget.child,
|
Colors.transparent
|
||||||
|
: Colors.transparent,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour scheme of the video's picture, for what lights up in it.
|
||||||
|
class const _VideoColour({
|
||||||
|
required final ColorScheme? scheme,
|
||||||
|
required super.child,
|
||||||
|
}) extends InheritedWidget {
|
||||||
|
/// The colour an entry under the pointer takes: the video's, or the
|
||||||
|
/// theme's own without a picture.
|
||||||
|
static Color litOf(BuildContext context) =>
|
||||||
|
context
|
||||||
|
.dependOnInheritedWidgetOfExactType<_VideoColour>()
|
||||||
|
?.scheme
|
||||||
|
?.secondaryContainer ??
|
||||||
|
Theme.of(context).colorScheme.secondaryContainer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_VideoColour oldWidget) => scheme != oldWidget.scheme;
|
||||||
|
}
|
||||||
|
|
||||||
/// The word chosen: as it stands and what it is a form of, its line and
|
/// The word chosen: as it stands and what it is a form of, its line and
|
||||||
/// translation, and its entries, each to add with the line.
|
/// translation, and its entries, each to add with the line, how far it is
|
||||||
|
/// learnt if the studyset holds it, and a link to it on Jisho.
|
||||||
class const _ChosenWord({required final Dictionary dictionary})
|
class const _ChosenWord({required final Dictionary dictionary})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -559,6 +607,26 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
final translation = state.translationOf(selected.line);
|
final translation = state.translationOf(selected.line);
|
||||||
|
Widget? trailing(DictionaryItem entry) => state.known.contains(entry.ref)
|
||||||
|
? null
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await cubit().add(entry as DictionaryWord);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.watchAdded(entry.headword))),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(l10n.watchAdd),
|
||||||
|
);
|
||||||
|
Widget beside(DictionaryWord word) => Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (state.known.contains(word.ref))
|
||||||
|
_ProgressChip(schedule: state.progress[word.ref]),
|
||||||
|
_JishoLink(word: word),
|
||||||
|
],
|
||||||
|
);
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -593,39 +661,271 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
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),
|
||||||
dictionaryItemTile(
|
_Unfolding(
|
||||||
entry,
|
word: entry,
|
||||||
trailing: state.known.contains(entry.ref)
|
folded: DictionaryWordTile(
|
||||||
? Tooltip(
|
word: entry,
|
||||||
message: l10n.dictionaryInStudyset,
|
progress: beside(entry),
|
||||||
child: Icon(
|
trailing: trailing(entry),
|
||||||
LucideIcons.check,
|
),
|
||||||
color: theme.colorScheme.primary,
|
whole: DictionaryWordTile(
|
||||||
),
|
word: entry,
|
||||||
)
|
whole: true,
|
||||||
: FilledButton.tonal(
|
progress: beside(entry),
|
||||||
onPressed: () async {
|
trailing: trailing(entry),
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
),
|
||||||
await cubit().add(entry);
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(l10n.watchAdded(entry.headword)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(l10n.watchAdd),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 8),
|
|
||||||
DictionarySources(dictionary: dictionary, padding: EdgeInsets.zero),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The video's words, the most frequent first: only those the studyset
|
/// An entry with its senses folded to two lines, unfolding over the list
|
||||||
/// lacks unless all are asked for; each opens its first line.
|
/// (decided 2026-10-13): after a moment under the pointer, or on a tap, it
|
||||||
|
/// takes the video's colour and its whole senses show in a card laid over
|
||||||
|
/// the rows below, so nothing moves under the pointer; a tap keeps the card
|
||||||
|
/// open, and leaving it closes it. An entry whose senses fit stays as it is.
|
||||||
|
class const _Unfolding({
|
||||||
|
required final DictionaryWord word,
|
||||||
|
required final Widget folded,
|
||||||
|
required final Widget whole,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
/// How long the pointer rests on an entry before it unfolds.
|
||||||
|
static const rest = Duration(milliseconds: 300);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Unfolding> createState() => _UnfoldingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnfoldingState() extends State<_Unfolding> {
|
||||||
|
final _link = LayerLink();
|
||||||
|
final _card = OverlayPortalController();
|
||||||
|
Timer? _wait;
|
||||||
|
var _pinned = false;
|
||||||
|
var _overRow = false;
|
||||||
|
var _overCard = false;
|
||||||
|
|
||||||
|
void _open() {
|
||||||
|
if (mounted && !_card.isShowing) {
|
||||||
|
setState(_card.show);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() {
|
||||||
|
if (mounted && _card.isShowing) {
|
||||||
|
setState(_card.hide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes it once the pointer is over neither the entry nor its card,
|
||||||
|
/// unless kept open: a moment's grace to cross from one to the other.
|
||||||
|
void _leave() {
|
||||||
|
_wait?.cancel();
|
||||||
|
_wait = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
if (!_overRow && !_overCard && !_pinned) {
|
||||||
|
_close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_wait?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the senses run over two lines at [width].
|
||||||
|
bool _folds(BuildContext context, double width) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: vocabularySensesSpan(
|
||||||
|
context,
|
||||||
|
widget.word.senses,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
textDirection: Directionality.of(context),
|
||||||
|
textScaler: MediaQuery.textScalerOf(context),
|
||||||
|
)..layout(maxWidth: width);
|
||||||
|
final folds = painter.didExceedMaxLines;
|
||||||
|
painter.dispose();
|
||||||
|
return folds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
|
builder: (context, box) {
|
||||||
|
// The senses' room: the row less its padding and what is beside it.
|
||||||
|
if (!_folds(context, box.maxWidth - 140)) {
|
||||||
|
return widget.folded;
|
||||||
|
}
|
||||||
|
final lit = _VideoColour.litOf(context);
|
||||||
|
return CompositedTransformTarget(
|
||||||
|
link: _link,
|
||||||
|
child: OverlayPortal(
|
||||||
|
controller: _card,
|
||||||
|
overlayChildBuilder: (overlay) {
|
||||||
|
final row = context.findRenderObject();
|
||||||
|
final top = row is RenderBox && row.hasSize
|
||||||
|
? row.localToGlobal(Offset.zero).dy
|
||||||
|
: 0.0;
|
||||||
|
final height = MediaQuery.sizeOf(overlay).height;
|
||||||
|
// Below the entry where there is room, else up from its foot.
|
||||||
|
final below = height - top - 16;
|
||||||
|
final up = below < 240;
|
||||||
|
return CompositedTransformFollower(
|
||||||
|
link: _link,
|
||||||
|
targetAnchor: up ? Alignment.bottomLeft : Alignment.topLeft,
|
||||||
|
followerAnchor: up ? Alignment.bottomLeft : Alignment.topLeft,
|
||||||
|
child: Align(
|
||||||
|
alignment: up ? Alignment.bottomLeft : Alignment.topLeft,
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) {
|
||||||
|
_overCard = true;
|
||||||
|
_wait?.cancel();
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
_overCard = false;
|
||||||
|
_leave();
|
||||||
|
},
|
||||||
|
child: SizedBox(
|
||||||
|
width: box.maxWidth,
|
||||||
|
child: Material(
|
||||||
|
elevation: 8,
|
||||||
|
color: lit,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: up
|
||||||
|
? (row is RenderBox && row.hasSize
|
||||||
|
? top + row.size.height
|
||||||
|
: height) -
|
||||||
|
16
|
||||||
|
: below,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_pinned = false;
|
||||||
|
_close();
|
||||||
|
},
|
||||||
|
child: widget.whole,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) {
|
||||||
|
_overRow = true;
|
||||||
|
_wait?.cancel();
|
||||||
|
_wait = Timer(_Unfolding.rest, _open);
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
_overRow = false;
|
||||||
|
_leave();
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () {
|
||||||
|
_pinned = true;
|
||||||
|
_open();
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
color: _card.isShowing ? lit : Colors.transparent,
|
||||||
|
child: widget.folded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a word the studyset holds is learnt (decided 2026-10-13): New,
|
||||||
|
/// Learning, Due, or Learned with when it comes again, in the theme's
|
||||||
|
/// colours for states.
|
||||||
|
class const _ProgressChip({required final CardSchedule? schedule})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final extras = theme.extras;
|
||||||
|
final schedule = this.schedule;
|
||||||
|
final now = clock.now();
|
||||||
|
final (text, color) = switch (schedule) {
|
||||||
|
null || CardSchedule(isNew: true) => (
|
||||||
|
l10n.watchProgressNew,
|
||||||
|
theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
CardSchedule(isLearning: true) => (
|
||||||
|
l10n.watchProgressLearning,
|
||||||
|
extras.info,
|
||||||
|
),
|
||||||
|
CardSchedule(:final due?) when !due.isAfter(now) => (
|
||||||
|
l10n.watchProgressDue,
|
||||||
|
extras.warning,
|
||||||
|
),
|
||||||
|
CardSchedule(:final due) => (
|
||||||
|
l10n.watchProgressLearned(_daysUntil(due, now)),
|
||||||
|
extras.success,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.16),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.6)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local days from [now] to [due]: 0 for later today, 1 for tomorrow.
|
||||||
|
int _daysUntil(DateTime? due, DateTime now) {
|
||||||
|
if (due == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final today = DateUtils.dateOnly(now.toLocal());
|
||||||
|
final day = DateUtils.dateOnly(due.toLocal());
|
||||||
|
return day.difference(today).inDays.clamp(0, 36500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word on Jisho.org, in the browser: Jisho is built on JMdict and
|
||||||
|
/// adds example sentences and the parts of its kanji. Nothing goes there
|
||||||
|
/// unless tapped.
|
||||||
|
class const _JishoLink({required final DictionaryWord word})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => IconButton(
|
||||||
|
tooltip: AppLocalizations.of(context).watchJisho,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 16,
|
||||||
|
icon: const Icon(LucideIcons.externalLink),
|
||||||
|
onPressed: () => launchUrl(
|
||||||
|
Uri.https('jisho.org', '/search/${word.headword}'),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The video's words, the most frequent first, as the filter lets them
|
||||||
|
/// through (at first those the studyset lacks); each opens its first
|
||||||
|
/// line, and those the studyset holds say how far they are learnt.
|
||||||
class const _VideoWords({required final Dictionary dictionary})
|
class const _VideoWords({required final Dictionary dictionary})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -637,12 +937,16 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
final words = state.words;
|
final words = state.words;
|
||||||
final shown = [
|
final shown = [
|
||||||
for (final word in words ?? const <VideoWord>[])
|
for (final word in words ?? const <VideoWord>[])
|
||||||
if (!state.newOnly || !state.known.contains(word.entry.ref)) word,
|
if (state.filter.admits(
|
||||||
|
word.entry,
|
||||||
|
held: state.known.contains(word.entry.ref),
|
||||||
|
))
|
||||||
|
word,
|
||||||
];
|
];
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
|
||||||
sliver: SliverToBoxAdapter(
|
sliver: SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -655,14 +959,9 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FilterChip(
|
const _WordsFilter(),
|
||||||
label: Text(l10n.watchWordsNewOnly),
|
|
||||||
selected: state.newOnly,
|
|
||||||
onSelected: (on) => cubit().showNewOnly(on: on),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
Text(
|
||||||
l10n.watchTapAWord,
|
l10n.watchTapAWord,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
|
@ -686,6 +985,13 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else if (shown.isEmpty && words.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(l10n.watchFilterEmpty, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: shown.length,
|
itemCount: shown.length,
|
||||||
|
|
@ -698,6 +1004,7 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
(entry.senses.firstOrNull?.misc.contains('uk') ?? false);
|
(entry.senses.firstOrNull?.misc.contains('uk') ?? false);
|
||||||
final written = kana ? entry.reading! : entry.headword;
|
final written = kana ? entry.reading! : entry.headword;
|
||||||
final read = kana ? entry.headword : entry.reading;
|
final read = kana ? entry.headword : entry.reading;
|
||||||
|
final held = state.known.contains(entry.ref);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
onTap: () => cubit().showWord(word),
|
onTap: () => cubit().showWord(word),
|
||||||
|
|
@ -715,6 +1022,16 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (held)
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: _ProgressChip(
|
||||||
|
schedule: state.progress[entry.ref],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -736,32 +1053,105 @@ class const _VideoWords({required final Dictionary dictionary})
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.known.contains(entry.ref))
|
Text(
|
||||||
Icon(
|
l10n.watchWordCount(word.count),
|
||||||
LucideIcons.check,
|
style: theme.textTheme.labelMedium,
|
||||||
size: 18,
|
),
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
l10n.watchWordCount(word.count),
|
|
||||||
style: theme.textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
||||||
sliver: SliverToBoxAdapter(
|
|
||||||
child: DictionarySources(
|
|
||||||
dictionary: dictionary,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The words' filter (decided 2026-10-13): a filter button, dotted while
|
||||||
|
/// the list is narrowed, opening a menu of whether the studyset holds them
|
||||||
|
/// (new, held, all), their JLPT levels and their word classes, several of
|
||||||
|
/// each at once.
|
||||||
|
class const _WordsFilter() extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final filter = context.select<WatchCubit, WordFilter>(
|
||||||
|
(cubit) => cubit.state.filter,
|
||||||
|
);
|
||||||
|
void set(WordFilter filter) =>
|
||||||
|
context.read<WatchCubit>().filterWords(filter);
|
||||||
|
final dense = MenuItemButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
);
|
||||||
|
Widget heading(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 2),
|
||||||
|
child: Text(text, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
);
|
||||||
|
return MenuAnchor(
|
||||||
|
menuChildren: [
|
||||||
|
for (final (shown, name) in [
|
||||||
|
(WordsShown.fresh, l10n.watchFilterFresh),
|
||||||
|
(WordsShown.held, l10n.watchFilterHeld),
|
||||||
|
(WordsShown.all, l10n.watchFilterAll),
|
||||||
|
])
|
||||||
|
RadioMenuButton<WordsShown>(
|
||||||
|
value: shown,
|
||||||
|
groupValue: filter.shown,
|
||||||
|
closeOnActivate: false,
|
||||||
|
style: dense,
|
||||||
|
onChanged: (shown) => set(filter.copyWith(shown: shown)),
|
||||||
|
child: Text(name),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
heading('JLPT'),
|
||||||
|
for (final level in const [5, 4, 3, 2, 1, null])
|
||||||
|
CheckboxMenuButton(
|
||||||
|
value: filter.levels.contains(level),
|
||||||
|
closeOnActivate: false,
|
||||||
|
style: dense,
|
||||||
|
onChanged: (on) => set(
|
||||||
|
filter.copyWith(
|
||||||
|
levels: on ?? false
|
||||||
|
? {...filter.levels, level}
|
||||||
|
: ({...filter.levels}..remove(level)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(level == null ? l10n.watchFilterNoLevel : 'N$level'),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
heading(l10n.watchFilterClass),
|
||||||
|
for (final (kind, name) in [
|
||||||
|
(WordClass.noun, l10n.watchClassNoun),
|
||||||
|
(WordClass.verb, l10n.watchClassVerb),
|
||||||
|
(WordClass.adjective, l10n.watchClassAdjective),
|
||||||
|
(WordClass.adverb, l10n.watchClassAdverb),
|
||||||
|
(WordClass.expression, l10n.watchClassExpression),
|
||||||
|
(WordClass.other, l10n.watchClassOther),
|
||||||
|
])
|
||||||
|
CheckboxMenuButton(
|
||||||
|
value: filter.classes.contains(kind),
|
||||||
|
closeOnActivate: false,
|
||||||
|
style: dense,
|
||||||
|
onChanged: (on) => set(
|
||||||
|
filter.copyWith(
|
||||||
|
classes: on ?? false
|
||||||
|
? {...filter.classes, kind}
|
||||||
|
: ({...filter.classes}..remove(kind)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(name),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, controller, _) => IconButton(
|
||||||
|
tooltip: l10n.watchFilter,
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: filter.narrows,
|
||||||
|
smallSize: 8,
|
||||||
|
child: const Icon(LucideIcons.listFilter),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
controller.isOpen ? controller.close() : controller.open(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets("in a list's row, one paragraph: glosses, then the notes "
|
testWidgets("in a list's row, one sense to a line, as Jisho lists them: "
|
||||||
'slanted and grey', (tester) async {
|
'glosses, then the notes slanted and grey', (tester) async {
|
||||||
final theme = await show(
|
final theme = await show(
|
||||||
tester,
|
tester,
|
||||||
Builder(
|
Builder(
|
||||||
|
|
@ -66,7 +66,7 @@ void main() {
|
||||||
final text = tester.widget<RichText>(find.byType(RichText).last).text;
|
final text = tester.widget<RichText>(find.byType(RichText).last).text;
|
||||||
expect(
|
expect(
|
||||||
text.toPlainText(),
|
text.toPlainText(),
|
||||||
"1. house; home n, n-suf 2. one's house n",
|
"1. house; home n, n-suf\n2. one's house n",
|
||||||
);
|
);
|
||||||
final notes = <TextSpan>[];
|
final notes = <TextSpan>[];
|
||||||
text.visitChildren((span) {
|
text.visitChildren((span) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -6,6 +7,9 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
|
|
@ -14,7 +18,9 @@ import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
import '../../support/fake_card_gateway.dart';
|
import '../../support/fake_card_gateway.dart';
|
||||||
|
import '../../support/fake_dictionary_gateway.dart';
|
||||||
import '../../support/fake_jellyfin.dart';
|
import '../../support/fake_jellyfin.dart';
|
||||||
|
import '../../support/fake_review_gateway.dart';
|
||||||
import '../../support/fake_studyset_gateway.dart';
|
import '../../support/fake_studyset_gateway.dart';
|
||||||
import '../../support/fake_tag_gateway.dart';
|
import '../../support/fake_tag_gateway.dart';
|
||||||
import '../../support/pump_app.dart';
|
import '../../support/pump_app.dart';
|
||||||
|
|
@ -51,6 +57,8 @@ void main() {
|
||||||
JellyfinPlayback? playback,
|
JellyfinPlayback? playback,
|
||||||
Map<(String, int), String>? vtt,
|
Map<(String, int), String>? vtt,
|
||||||
List<MediaAudioTrack> audio = const [],
|
List<MediaAudioTrack> audio = const [],
|
||||||
|
List<DictionaryWord>? words,
|
||||||
|
FakeReviewGateway? reviews,
|
||||||
}) async {
|
}) async {
|
||||||
if (playback != null || vtt != null) {
|
if (playback != null || vtt != null) {
|
||||||
jellyfin = sampleJellyfin(
|
jellyfin = sampleJellyfin(
|
||||||
|
|
@ -87,7 +95,10 @@ void main() {
|
||||||
studysetGateway: studysets,
|
studysetGateway: studysets,
|
||||||
cardGateway: cardGateway,
|
cardGateway: cardGateway,
|
||||||
tagGateway: tagGateway,
|
tagGateway: tagGateway,
|
||||||
dictionaryGateway: sampleDictionary(),
|
dictionaryGateway: words == null
|
||||||
|
? sampleDictionary()
|
||||||
|
: FakeDictionaryGateway(words: words),
|
||||||
|
reviewGateway: reviews,
|
||||||
jellyfinGateway: jellyfin,
|
jellyfinGateway: jellyfin,
|
||||||
jellyfinStore: signedInJellyfin(),
|
jellyfinStore: signedInJellyfin(),
|
||||||
mediaPlayers: players,
|
mediaPlayers: players,
|
||||||
|
|
@ -160,7 +171,8 @@ void main() {
|
||||||
[for (final tag in tagging.of(card.id)) tag.name],
|
[for (final tag in tagging.of(card.id)) tag.name],
|
||||||
containsAll(['Sample Journey', 'JLPT-N5']),
|
containsAll(['Sample Journey', 'JLPT-N5']),
|
||||||
);
|
);
|
||||||
expect(find.byTooltip('In this studyset'), findsOneWidget);
|
// How far it is learnt: not yet.
|
||||||
|
expect(find.text('New'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets("the video's words are listed, the most frequent first, "
|
testWidgets("the video's words are listed, the most frequent first, "
|
||||||
|
|
@ -449,6 +461,103 @@ void main() {
|
||||||
expect(shown(), 1);
|
expect(shown(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a word the studyset holds says how far it is learnt, in the '
|
||||||
|
'list of the words it holds', (tester) async {
|
||||||
|
final station = sampleWords.firstWhere((word) => word.headword == '駅');
|
||||||
|
await DictionaryAdder(cards, tags).add('japanese', [station]);
|
||||||
|
final card = (await cards.cardsOf('japanese')).single;
|
||||||
|
final reviews = FakeReviewGateway();
|
||||||
|
await reviews.insert([
|
||||||
|
(
|
||||||
|
id: '',
|
||||||
|
cardId: card.id,
|
||||||
|
rating: 4,
|
||||||
|
reviewedAt: DateTime.now().toUtc().subtract(const Duration(days: 1)),
|
||||||
|
durationMs: 3000,
|
||||||
|
mode: 'review',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await watch(tester, reviews: reviews);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Filter the words'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Words in the studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('駅', findRichText: true), findsOneWidget);
|
||||||
|
expect(find.textContaining('Learned · again'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the filter narrows the words by their class', (tester) async {
|
||||||
|
await watch(tester);
|
||||||
|
expect(find.textContaining('駅', findRichText: true), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Filter the words'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Verbs'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('会う', findRichText: true), findsOneWidget);
|
||||||
|
expect(find.textContaining('駅', findRichText: true), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a word of many senses unfolds over the list under the '
|
||||||
|
'pointer, a tap keeping it open; senses one to a line', (tester) async {
|
||||||
|
final long = [
|
||||||
|
for (final word in sampleWords)
|
||||||
|
if (word.headword == '駅')
|
||||||
|
DictionaryWord(
|
||||||
|
ref: word.ref,
|
||||||
|
headword: word.headword,
|
||||||
|
reading: word.reading,
|
||||||
|
labels: word.labels,
|
||||||
|
senses: [
|
||||||
|
for (var n = 1; n <= 6; n++)
|
||||||
|
VocabularySense(
|
||||||
|
glosses: ['station, as in sense $n of a long entry'],
|
||||||
|
pos: const ['n'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
word,
|
||||||
|
];
|
||||||
|
await watch(tester, words: long);
|
||||||
|
await playTo(tester, const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.text('駅'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(VocabularySensesView), findsNothing);
|
||||||
|
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(mouse.removePointer);
|
||||||
|
await mouse.addPointer(location: Offset.zero);
|
||||||
|
await mouse.moveTo(
|
||||||
|
tester.getCenter(find.textContaining('sense 1 of a long').first),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(VocabularySensesView), findsOneWidget);
|
||||||
|
expect(find.text('station, as in sense 6 of a long entry'), findsOneWidget);
|
||||||
|
|
||||||
|
// Left, it folds again.
|
||||||
|
await mouse.moveTo(Offset.zero);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(VocabularySensesView), findsNothing);
|
||||||
|
|
||||||
|
// Tapped, it stays open with the pointer elsewhere; a tap on it closes
|
||||||
|
// it.
|
||||||
|
await tester.tap(find.textContaining('sense 1 of a long').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await mouse.moveTo(Offset.zero);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(VocabularySensesView), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(VocabularySensesView));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(VocabularySensesView), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('on a phone the words are below the video', (tester) async {
|
testWidgets('on a phone the words are below the video', (tester) async {
|
||||||
await watch(tester, size: const Size(412, 915));
|
await watch(tester, size: const Size(412, 915));
|
||||||
await playTo(tester, const Duration(seconds: 2));
|
await playTo(tester, const Duration(seconds: 2));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue