feat(jellyfin): an episode's own tag, and studying an episode's words

A word added from an episode gets the show's tag and the episode's own ("あかね噺 S1 E1"), hidden on cards. The words panel's Study button studies the studyset's cards of the video's words, on their schedules or crammed (each once): it pauses the video and first gives the episode's tag to the cards of its words the studyset held before, then opens the session on that tag. A study focus can now ask for cramming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 19:32:37 +02:00
commit 436dc884b6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_add
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/widget/watch_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -169,8 +170,11 @@ void main() {
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
containsAll(['Sample Journey', 'JLPT-N5']),
containsAll(['Sample Journey', 'Sample Journey S1 E1', 'JLPT-N5']),
);
// The episode's own tag is hidden on cards; the show's is not.
expect(tagging.named('Sample Journey S1 E1')?.hidden, isTrue);
expect(tagging.named('Sample Journey')?.hidden, isFalse);
// How far it is learnt: not yet.
expect(find.text('New'), findsOneWidget);
});
@ -194,7 +198,10 @@ void main() {
expect(words, isNot(contains('で')));
expect(words, contains('待つ'));
await tester.tap(find.textContaining('待つ', findRichText: true).first);
final wait = find.textContaining('待つ', findRichText: true).first;
await tester.ensureVisible(wait);
await tester.pumpAndSettle();
await tester.tap(wait);
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 16));
expect(find.text('A form of 待つ'), findsOneWidget);
@ -584,6 +591,42 @@ void main() {
expect(card.content.entry, (source: 'jmdict', id: '37'));
});
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 {
final station = sampleWords.firstWhere((word) => word.headword == '駅');
await DictionaryAdder(cards, tags).add('japanese', [station]);
final card = (await cards.cardsOf('japanese')).single;
await watch(tester);
await tester.tap(find.byTooltip('Play'));
await tester.pump();
await tester.tap(find.text('Study'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cram, each once'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.name],
contains('Sample Journey S1 E1'),
);
expect(players.last!.state.value.playing, isFalse);
// The session, over the Watch screen, on the episode's tag, crammed (a
// spinner turns there: frames, not settling).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
final location = GoRouterState.of(
tester.element(find.byType(StudyScreen)),
).uri;
expect(location.path, '/study/japanese');
expect(location.queryParameters, containsPair('cram', '1'));
expect(
location.queryParameters,
containsPair('tagName', 'Sample Journey S1 E1'),
);
});
testWidgets('on a phone the words are below the video', (tester) async {
await watch(tester, size: const Size(412, 915));
await playTo(tester, const Duration(seconds: 2));