feat(tag): one switch hides the tags from Jellyfin while studying
Every tag the app makes for where words were met on Jellyfin (the show and the episode, the artist and the song) now says so (`origin` in tag schema 2, written only for such a tag), in place of a tag hidden one by one. Manage tags, where there are such tags, has one switch, Hide tags from Jellyfin while studying (`hideJellyfinTags`, settings schema 9, following the person): with it on, cards being studied leave them out of their row at once, and Tags and Manage tags list them faint. They still filter and narrow a session. The per-tag eye button is gone. A word added while watching no longer takes the line it was met in as its first hint: its hints are the dictionary's examples alone. Tatoeba's sentences are checked, a subtitle line may be wrong or mistyped, a word met again in a better line wouldn't get it, and every episode's line would pile up on a common word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
cc777d6b06
commit
c6e7a53dd6
30 changed files with 426 additions and 245 deletions
|
|
@ -597,8 +597,8 @@
|
|||
"tagManageAdd": "Tag hinzufügen",
|
||||
"tagManageRow": "{name} ({count})",
|
||||
"tagManageEdit": "Tag bearbeiten",
|
||||
"tagManageHide": "Auf Karten ausblenden",
|
||||
"tagManageShow": "Auf Karten zeigen",
|
||||
"tagManageHideJellyfin": "Tags aus Jellyfin beim Lernen ausblenden",
|
||||
"tagManageHideJellyfinHint": "Die Serien, Folgen, Künstler und Songs, in denen Wörter vorkamen. Sie bleiben an den Karten, zum Filtern und Lernen.",
|
||||
"tagManageDelete": "Tag löschen",
|
||||
"tagDeleteTitle": "Tag „{name}“ löschen?",
|
||||
"tagDeleteMessage": "{count, plural, =0{Er ist an keiner Karteikarte.} =1{Er wird auf all deinen Geräten von 1 Karteikarte entfernt.} other{Er wird auf all deinen Geräten von {count} Karteikarten entfernt.}}",
|
||||
|
|
@ -1833,7 +1833,7 @@
|
|||
"watchWordsFinding": "Wörter werden gesucht …",
|
||||
"watchWordCount": "{count}×",
|
||||
"watchAdd": "Hinzufügen",
|
||||
"watchAdded": "{word} hinzugefügt, mit dieser Zeile",
|
||||
"watchAdded": "{word} hinzugefügt",
|
||||
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen.",
|
||||
"watchFormOf": "Eine Form von {word}",
|
||||
"watchFailed": "Das Video konnte nicht abgespielt werden."
|
||||
|
|
|
|||
|
|
@ -674,8 +674,8 @@
|
|||
"tagManageRow": "{name} ({count})",
|
||||
"@tagManageRow": {"description": "A tag in the list of tags, with how many flashcards have it.", "placeholders": {"name": {"type": "String"}, "count": {"type": "int"}}},
|
||||
"tagManageEdit": "Edit tag",
|
||||
"tagManageHide": "Hide on cards",
|
||||
"tagManageShow": "Show on cards",
|
||||
"tagManageHideJellyfin": "Hide tags from Jellyfin while studying",
|
||||
"tagManageHideJellyfinHint": "The shows, episodes, artists and songs words were met in. They stay on the cards, to filter and study by.",
|
||||
"tagManageDelete": "Delete tag",
|
||||
"tagDeleteTitle": "Delete the tag '{name}'?",
|
||||
"@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
|
|
@ -2148,7 +2148,7 @@
|
|||
"watchWordCount": "{count}×",
|
||||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"watchAdd": "Add",
|
||||
"watchAdded": "{word} added, with this line",
|
||||
"watchAdded": "{word} added",
|
||||
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
|
||||
"watchTapAWord": "Tap a word of the subtitles to look it up.",
|
||||
"watchFormOf": "A form of {word}",
|
||||
|
|
|
|||
|
|
@ -3568,17 +3568,17 @@ abstract class AppLocalizations {
|
|||
/// **'Edit tag'**
|
||||
String get tagManageEdit;
|
||||
|
||||
/// No description provided for @tagManageHide.
|
||||
/// No description provided for @tagManageHideJellyfin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide on cards'**
|
||||
String get tagManageHide;
|
||||
/// **'Hide tags from Jellyfin while studying'**
|
||||
String get tagManageHideJellyfin;
|
||||
|
||||
/// No description provided for @tagManageShow.
|
||||
/// No description provided for @tagManageHideJellyfinHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show on cards'**
|
||||
String get tagManageShow;
|
||||
/// **'The shows, episodes, artists and songs words were met in. They stay on the cards, to filter and study by.'**
|
||||
String get tagManageHideJellyfinHint;
|
||||
|
||||
/// No description provided for @tagManageDelete.
|
||||
///
|
||||
|
|
@ -10842,7 +10842,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @watchAdded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{word} added, with this line'**
|
||||
/// **'{word} added'**
|
||||
String watchAdded(String word);
|
||||
|
||||
/// No description provided for @watchTapAWord.
|
||||
|
|
|
|||
|
|
@ -2160,10 +2160,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get tagManageEdit => 'Tag bearbeiten';
|
||||
|
||||
@override
|
||||
String get tagManageHide => 'Auf Karten ausblenden';
|
||||
String get tagManageHideJellyfin =>
|
||||
'Tags aus Jellyfin beim Lernen ausblenden';
|
||||
|
||||
@override
|
||||
String get tagManageShow => 'Auf Karten zeigen';
|
||||
String get tagManageHideJellyfinHint =>
|
||||
'Die Serien, Folgen, Künstler und Songs, in denen Wörter vorkamen. Sie bleiben an den Karten, zum Filtern und Lernen.';
|
||||
|
||||
@override
|
||||
String get tagManageDelete => 'Tag löschen';
|
||||
|
|
@ -6798,7 +6800,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String watchAdded(String word) {
|
||||
return '$word hinzugefügt, mit dieser Zeile';
|
||||
return '$word hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -2143,10 +2143,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get tagManageEdit => 'Edit tag';
|
||||
|
||||
@override
|
||||
String get tagManageHide => 'Hide on cards';
|
||||
String get tagManageHideJellyfin => 'Hide tags from Jellyfin while studying';
|
||||
|
||||
@override
|
||||
String get tagManageShow => 'Show on cards';
|
||||
String get tagManageHideJellyfinHint =>
|
||||
'The shows, episodes, artists and songs words were met in. They stay on the cards, to filter and study by.';
|
||||
|
||||
@override
|
||||
String get tagManageDelete => 'Delete tag';
|
||||
|
|
@ -6739,7 +6740,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String watchAdded(String word) {
|
||||
return '$word added, with this line';
|
||||
return '$word added';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/jellyfin_tags_bloc.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The router of one person's app: the four tabs in a shell, each with its own
|
||||
|
|
@ -204,17 +205,26 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
context,
|
||||
studysetId,
|
||||
(studyset, _) => FramedScreen(
|
||||
child: BlocProvider(
|
||||
create: (context) => StudySessionBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.cardDeletions,
|
||||
person.reviews,
|
||||
person.tags,
|
||||
person.settings,
|
||||
studysetId,
|
||||
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
||||
)..add(const StudySessionEventStarted()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => StudySessionBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.cardDeletions,
|
||||
person.reviews,
|
||||
person.tags,
|
||||
person.settings,
|
||||
studysetId,
|
||||
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
||||
)..add(const StudySessionEventStarted()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
JellyfinTagsBloc(person.settings)
|
||||
..add(const JellyfinTagsEventStarted()),
|
||||
),
|
||||
],
|
||||
child: StudyScreen(
|
||||
studysetId: studysetId,
|
||||
title: studyset.content.title,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
};
|
||||
|
||||
/// Gives every card of [studysetId] made from one of [entries] the tag
|
||||
/// [tagName] (made hidden on cards if new): the words of an episode the
|
||||
/// [tagName] (made for Jellyfin if new): the words of an episode the
|
||||
/// studyset held before it was watched too. The tag as something refers
|
||||
/// to it.
|
||||
Future<TagRef> tagHeld(
|
||||
|
|
@ -44,7 +44,7 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
Set<DictionaryRef> entries,
|
||||
String tagName,
|
||||
) => _queue.run(() async {
|
||||
final tagId = await _tags.create(tagName, hidden: true);
|
||||
final tagId = await _tags.create(tagName, fromJellyfin: true);
|
||||
await _tags.addTags({
|
||||
for (final card in await _cards.cardsOf(studysetId))
|
||||
if (entries.contains(card.content.entry)) card.id: {tagId},
|
||||
|
|
@ -53,21 +53,19 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
});
|
||||
|
||||
/// Adds [words] to [studysetId], at its end, in their order; each card
|
||||
/// also tagged [tagNames] (the show a word was met in) and
|
||||
/// [hiddenTagNames], tags made hidden on cards if they are new (the
|
||||
/// episode or song).
|
||||
/// also tagged [jellyfinTagNames], tags made for Jellyfin if they are new
|
||||
/// (the show and the episode, or the artist and the song, a word was met
|
||||
/// in).
|
||||
Future<DictionaryAdded> add(
|
||||
String studysetId,
|
||||
List<DictionaryItem> words, {
|
||||
List<String> tagNames = const [],
|
||||
List<String> hiddenTagNames = const [],
|
||||
}) => _queue.run(() => _add(studysetId, words, tagNames, hiddenTagNames));
|
||||
List<String> jellyfinTagNames = const [],
|
||||
}) => _queue.run(() => _add(studysetId, words, jellyfinTagNames));
|
||||
|
||||
Future<DictionaryAdded> _add(
|
||||
String studysetId,
|
||||
List<DictionaryItem> words,
|
||||
List<String> extraTagNames,
|
||||
List<String> hiddenTagNames,
|
||||
List<String> jellyfinTagNames,
|
||||
) async {
|
||||
final there = await _entriesIn(studysetId);
|
||||
final fresh = [
|
||||
|
|
@ -89,12 +87,15 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
final tagIdOf = <String, String>{};
|
||||
final tagsByCard = <String, Set<String>>{};
|
||||
for (final (index, word) in fresh.indexed) {
|
||||
for (final name in {...word.tagNames, ...extraTagNames}) {
|
||||
for (final name in word.tagNames) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(name);
|
||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
||||
}
|
||||
for (final name in hiddenTagNames) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(name, hidden: true);
|
||||
for (final name in jellyfinTagNames) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(
|
||||
name,
|
||||
fromJellyfin: true,
|
||||
);
|
||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,23 +156,6 @@ final class const DictionaryWord({
|
|||
/// How many of its [examples] a card made from it takes as hints.
|
||||
static const examplesOnCard = 3;
|
||||
|
||||
/// This word with [sentence] as its first example, before the
|
||||
/// dictionary's own: the sentence it was met in, which its card then
|
||||
/// shows first among its hints.
|
||||
DictionaryWord withSentence(WordExample sentence) => DictionaryWord(
|
||||
ref: ref,
|
||||
headword: headword,
|
||||
reading: reading,
|
||||
labels: labels,
|
||||
senses: senses,
|
||||
examples: [
|
||||
sentence,
|
||||
for (final example in examples)
|
||||
if (example.japanese != sentence.japanese) example,
|
||||
],
|
||||
version: version,
|
||||
);
|
||||
|
||||
/// The tags a card made from it gets, wherever it is made: its JLPT level
|
||||
/// (`JLPT-N5`) and its word class by its first sense (JMdict's codes,
|
||||
/// grouped: `Verb`, `Noun`, …), in English as the dictionary's codes are,
|
||||
|
|
|
|||
|
|
@ -444,32 +444,16 @@ class WatchCubit(
|
|||
/// Chooses no word: the video's words show again.
|
||||
void unselect() => emit(state.copyWith(selected: null));
|
||||
|
||||
/// Adds [entry] to the studyset as a card, with the chosen word's line
|
||||
/// (and its translation) as its first example, tagged with the show; the
|
||||
/// word marked in the line is [form], a longer word running over the one
|
||||
/// chosen, or the one chosen.
|
||||
Future<void> add(DictionaryWord entry, {String? form}) async {
|
||||
final selected = state.selected;
|
||||
final part = state.selectedPart;
|
||||
if (selected == null || part == null) {
|
||||
/// 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
|
||||
/// it was met in (decided 2026-10-13): Tatoeba's sentences are checked,
|
||||
/// a line may be wrong or mistyped, and every episode's line would pile
|
||||
/// up on a common word.
|
||||
Future<void> add(DictionaryWord entry) async {
|
||||
if (state.selected == null) {
|
||||
return;
|
||||
}
|
||||
final line = state.lines[selected.line].text;
|
||||
await _adder.add(
|
||||
studysetId,
|
||||
[
|
||||
entry.withSentence((
|
||||
form: form ?? part.text,
|
||||
japanese: oneLine(line),
|
||||
english: switch (state.translationOf(selected.line)) {
|
||||
final text? => oneLine(text),
|
||||
null => null,
|
||||
},
|
||||
)),
|
||||
],
|
||||
tagNames: [?_tags?.shown],
|
||||
hiddenTagNames: [?_tags?.hidden],
|
||||
);
|
||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
||||
final progress = await _progress.of(studysetId);
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
|
|
@ -506,9 +490,11 @@ class WatchCubit(
|
|||
Future<void> nextLine() => playLine((state.line ?? -1) + 1);
|
||||
|
||||
/// The tags a card from this video gets.
|
||||
({String? shown, String? hidden})? get _tags => switch (state.item) {
|
||||
final item? => jellyfinTagsOf(item),
|
||||
null => null,
|
||||
List<String> get _tagNames => switch (state.item) {
|
||||
final item? => switch (jellyfinTagsOf(item)) {
|
||||
(:final whole, :final part) => [whole, ?part],
|
||||
},
|
||||
null => const [],
|
||||
};
|
||||
|
||||
/// Whether the studyset holds any word of the video, to study.
|
||||
|
|
@ -530,7 +516,7 @@ class WatchCubit(
|
|||
}
|
||||
final tag = await _adder.tagHeld(studysetId, {
|
||||
for (final word in words) word.entry.ref,
|
||||
}, jellyfinTagsOf(item).hidden ?? item.name);
|
||||
}, jellyfinTagsOf(item).part ?? item.name);
|
||||
return StudyFocus(tag: tag, cram: cram);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -289,29 +289,30 @@ final class const JellyfinState({
|
|||
List<Object?> get props => [deviceId, account];
|
||||
}
|
||||
|
||||
/// The tags a card made from [item] gets (decided 2026-10-13): the show's
|
||||
/// (or the film's or video's own) name, shown on cards; and the episode's
|
||||
/// own, "あかね噺 S1 E1", hidden on cards (as many would gather on a common
|
||||
/// word), so the episode's words can be studied together.
|
||||
/// The tags a card made from [item] gets (decided 2026-10-13), each made
|
||||
/// for Jellyfin (`Tag.fromJellyfin`, which the settings can hide on cards
|
||||
/// being studied): the show's (or the film's or video's own) name, the
|
||||
/// `whole`; and the episode's own, "あかね噺 S1 E1", the `part`, so the
|
||||
/// episode's words can be studied together.
|
||||
///
|
||||
/// A song gets its artist's, shown, and "宝鐘マリン · 幽霊船戦", hidden.
|
||||
({String? shown, String? hidden}) jellyfinTagsOf(JellyfinItem item) =>
|
||||
/// A song's are its artist's and "宝鐘マリン · 幽霊船戦".
|
||||
({String whole, String? part}) jellyfinTagsOf(JellyfinItem item) =>
|
||||
switch (item) {
|
||||
JellyfinItem(kind: JellyfinItemKind.audio, artist: final artist?) => (
|
||||
shown: artist,
|
||||
hidden: '$artist · ${item.name}',
|
||||
whole: artist,
|
||||
part: '$artist · ${item.name}',
|
||||
),
|
||||
JellyfinItem(
|
||||
seriesName: final series?,
|
||||
seasonNumber: final season?,
|
||||
episodeNumber: final episode?,
|
||||
) =>
|
||||
(shown: series, hidden: '$series S$season E$episode'),
|
||||
(whole: series, part: '$series S$season E$episode'),
|
||||
JellyfinItem(seriesName: final series?) => (
|
||||
shown: series,
|
||||
hidden: '$series · ${item.name}',
|
||||
whole: series,
|
||||
part: '$series · ${item.name}',
|
||||
),
|
||||
_ => (shown: item.name, hidden: null),
|
||||
_ => (whole: item.name, part: null),
|
||||
};
|
||||
|
||||
/// Jellyfin's languages (ISO 639-2, both the bibliographic and the
|
||||
|
|
|
|||
|
|
@ -793,7 +793,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
final translation = state.translationOf(selected.line);
|
||||
// On the headword's line, at its end (decided 2026-10-13): Jisho, and
|
||||
// Add for a word the studyset lacks.
|
||||
List<Widget> actions(DictionaryWord entry, String form) => [
|
||||
List<Widget> actions(DictionaryWord entry) => [
|
||||
_JishoLink(word: entry),
|
||||
if (!state.known.contains(entry.ref))
|
||||
FilledButton.tonal(
|
||||
|
|
@ -802,7 +802,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await cubit().add(entry, form: form);
|
||||
await cubit().add(entry);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.watchAdded(entry.headword))),
|
||||
);
|
||||
|
|
@ -813,18 +813,18 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
Widget? beside(DictionaryWord word) => state.known.contains(word.ref)
|
||||
? _ProgressChip(schedule: state.progress[word.ref])
|
||||
: null;
|
||||
Widget row(DictionaryWord entry, String form) => _Unfolding(
|
||||
Widget row(DictionaryWord entry) => _Unfolding(
|
||||
word: entry,
|
||||
folded: DictionaryWordTile(
|
||||
word: entry,
|
||||
progress: beside(entry),
|
||||
actions: actions(entry, form),
|
||||
actions: actions(entry),
|
||||
),
|
||||
whole: DictionaryWordTile(
|
||||
word: entry,
|
||||
whole: true,
|
||||
progress: beside(entry),
|
||||
actions: actions(entry, form),
|
||||
actions: actions(entry),
|
||||
),
|
||||
);
|
||||
return ListView(
|
||||
|
|
@ -861,7 +861,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
const SizedBox(height: 8),
|
||||
for (final (index, entry) in part.entries.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
row(entry, part.text),
|
||||
row(entry),
|
||||
],
|
||||
// What the cut may have missed (decided 2026-10-13).
|
||||
if (part.wider.isNotEmpty) ...[
|
||||
|
|
@ -870,7 +870,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
for (final wider in part.wider)
|
||||
for (final entry in wider.entries) ...[
|
||||
const Divider(height: 1),
|
||||
row(entry, wider.text),
|
||||
row(entry),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
|||
// learning in Explore (`countLearning`, ADR 0005), left out while off.
|
||||
// Schema 9 added the Jellyfin items tied to each studyset (`jellyfinTies`:
|
||||
// by studyset id, each a server's and an item's id), left out while there
|
||||
// are none. Older schemas read with what they lack as it comes.
|
||||
// are none, and hiding the tags made for Jellyfin on cards being studied
|
||||
// (`hideJellyfinTags`), left out while off. Older schemas read with what they lack as it comes.
|
||||
|
||||
/// The schema this app writes.
|
||||
const settingsSchemaVersion = 9;
|
||||
|
|
@ -82,6 +83,7 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
|||
'selected': pomodoro.selectedId,
|
||||
},
|
||||
if (settings.countLearning) 'countLearning': true,
|
||||
if (settings.hideJellyfinTags) 'hideJellyfinTags': true,
|
||||
if (settings.jellyfinTies.isNotEmpty)
|
||||
'jellyfinTies': {
|
||||
for (final MapEntry(key: studysetId, value: ties)
|
||||
|
|
@ -124,6 +126,7 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
|||
pomodoro: _pomodoro(json['pomodoro']),
|
||||
countLearning: json['countLearning'] == true,
|
||||
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
||||
hideJellyfinTags: json['hideJellyfinTags'] == true,
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a settings payload.');
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ final class const PersonSettings({
|
|||
/// studyset's id, in the order tied: ids only, of any of the person's
|
||||
/// servers.
|
||||
final Map<String, List<JellyfinTie>> jellyfinTies = const {},
|
||||
|
||||
/// Whether cards being studied leave out the tags the app made for where
|
||||
/// words were met on Jellyfin ([Tag.fromJellyfin]; decided 2026-10-13,
|
||||
/// switched in Manage tags).
|
||||
final bool hideJellyfinTags = false,
|
||||
}) with Equatable {
|
||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||
/// cards.
|
||||
|
|
@ -96,6 +101,7 @@ final class const PersonSettings({
|
|||
pomodoro: pomodoro,
|
||||
countLearning: countLearning,
|
||||
jellyfinTies: jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags,
|
||||
);
|
||||
|
||||
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
||||
|
|
@ -112,6 +118,7 @@ final class const PersonSettings({
|
|||
PomodoroSettings? pomodoro,
|
||||
bool? countLearning,
|
||||
Map<String, List<JellyfinTie>>? jellyfinTies,
|
||||
bool? hideJellyfinTags,
|
||||
}) => PersonSettings(
|
||||
studyTags: studyTags ?? this.studyTags,
|
||||
study: study ?? this.study,
|
||||
|
|
@ -121,6 +128,7 @@ final class const PersonSettings({
|
|||
pomodoro: pomodoro ?? this.pomodoro,
|
||||
countLearning: countLearning ?? this.countLearning,
|
||||
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||
);
|
||||
|
||||
/// These settings with what only [other] has added: where both have a
|
||||
|
|
@ -137,6 +145,7 @@ final class const PersonSettings({
|
|||
// Left as it comes (off) counts as none.
|
||||
countLearning: countLearning || other.countLearning,
|
||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -149,5 +158,6 @@ final class const PersonSettings({
|
|||
pomodoro,
|
||||
countLearning,
|
||||
jellyfinTies,
|
||||
hideJellyfinTags,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,14 +146,7 @@ class const StudyCardHeader({
|
|||
const Spacer(),
|
||||
] else
|
||||
Expanded(
|
||||
// Tags hidden on cards (where a word was met) stay out.
|
||||
child: OverflowingTagChipsRow(
|
||||
tags: [
|
||||
for (final tag in tags)
|
||||
if (!tag.hidden) tag,
|
||||
],
|
||||
onPressed: onTags,
|
||||
),
|
||||
child: OverflowingTagChipsRow(tags: tags, onPressed: onTags),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
PopupMenuButton<_CardAction>(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/swipe_to_rate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/writing_body.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/jellyfin_tags_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -341,9 +342,17 @@ class const _Showing(
|
|||
final key = ValueKey((card.id, state.shownAt));
|
||||
void show() => _send(context, const StudySessionEventAnswerShown());
|
||||
void toggle() => _send(context, const StudySessionEventAnswerToggled());
|
||||
// The tags made for Jellyfin stay out while the settings hide them;
|
||||
// choosing tags still shows them all.
|
||||
final hidesJellyfin = context.select<JellyfinTagsBloc, bool>(
|
||||
(bloc) => bloc.state ?? false,
|
||||
);
|
||||
final cardHeader = StudyCardHeader(
|
||||
card: card,
|
||||
tags: state.tags,
|
||||
tags: [
|
||||
for (final tag in state.tags)
|
||||
if (!(hidesJellyfin && tag.fromJellyfin)) tag,
|
||||
],
|
||||
onTags: () => showTagPicker(
|
||||
context,
|
||||
selected: {for (final tag in state.tags) tag.id},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
|
||||
/// Whether cards being studied leave out the tags made for where words were
|
||||
/// met on Jellyfin (`hideJellyfinTags` of the person's settings), as their
|
||||
/// settings hold it now; null until read.
|
||||
class JellyfinTagsBloc(final SettingsRepository _settings)
|
||||
extends Bloc<JellyfinTagsEvent, bool?> {
|
||||
this : super(null) {
|
||||
on<JellyfinTagsEventStarted>(
|
||||
(event, emit) => emit.forEach(
|
||||
_settings.watch(),
|
||||
onData: (settings) => settings.hideJellyfinTags,
|
||||
),
|
||||
);
|
||||
on<JellyfinTagsEventHidingChosen>(
|
||||
(event, emit) => _settings.update(
|
||||
(settings) => settings.copyWith(hideJellyfinTags: event.hidden),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to hiding the tags from Jellyfin.
|
||||
sealed class const JellyfinTagsEvent();
|
||||
|
||||
/// Tags show: follow the setting.
|
||||
final class const JellyfinTagsEventStarted() extends JellyfinTagsEvent;
|
||||
|
||||
/// The person chose whether cards being studied leave them out.
|
||||
final class const JellyfinTagsEventHidingChosen(final bool hidden)
|
||||
extends JellyfinTagsEvent;
|
||||
|
|
@ -28,9 +28,6 @@ class TagsBloc(final TagRepository _tags) extends Bloc<TagsEvent, TagsState> {
|
|||
colorName: event.content.colorName,
|
||||
),
|
||||
);
|
||||
on<TagsEventHiddenRequested>(
|
||||
(event, emit) => _tags.setHidden(event.id, hidden: event.hidden),
|
||||
);
|
||||
on<TagsEventDeleteRequested>((event, emit) async {
|
||||
_deleting = _tags.delete(event.id);
|
||||
await _deleting;
|
||||
|
|
@ -78,18 +75,6 @@ final class const TagsEventUpdateRequested(
|
|||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
||||
/// Hide the tag [id] on cards being studied, or show it again.
|
||||
final class const TagsEventHiddenRequested(
|
||||
/// The tag's id.
|
||||
final String id, {
|
||||
|
||||
/// Whether it is hidden.
|
||||
required final bool hidden,
|
||||
}) extends TagsEvent {
|
||||
@override
|
||||
List<Object?> get props => [id, hidden];
|
||||
}
|
||||
|
||||
/// Delete the tag [id] from every card and then itself.
|
||||
final class const TagsEventDeleteRequested(
|
||||
/// The tag's id.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
|||
|
||||
// A tag's content as its synced row stores it (see core/content/payload).
|
||||
//
|
||||
// Schema 1 holds the name and the colour; schema 2 adds `hidden` (decided
|
||||
// 2026-10-13), written only for a hidden tag, so older apps still read the
|
||||
// Schema 1 holds the name and the colour; schema 2 adds `origin` (decided
|
||||
// 2026-10-13): `jellyfin` for a tag the app made for where words were met on
|
||||
// Jellyfin, written only for such a tag, so older apps still read the
|
||||
// others.
|
||||
|
||||
/// The newest schema this app writes.
|
||||
|
|
@ -17,20 +18,20 @@ typedef TagContent = ({String name, String colorName});
|
|||
|
||||
/// Encodes a tag's [content] into its payload, the name as a [tidyTagName].
|
||||
///
|
||||
/// A [hidden] tag is written in schema 2, any other in schema 1.
|
||||
ByteData encodeTagPayload(TagContent content, {bool hidden = false}) =>
|
||||
encodePayload(hidden ? 2 : 1, {
|
||||
/// A tag made [fromJellyfin] is written in schema 2, any other in schema 1.
|
||||
ByteData encodeTagPayload(TagContent content, {bool fromJellyfin = false}) =>
|
||||
encodePayload(fromJellyfin ? 2 : 1, {
|
||||
'name': tidyTagName(content.name),
|
||||
'color': content.colorName,
|
||||
if (hidden) 'hidden': true,
|
||||
if (fromJellyfin) 'origin': 'jellyfin',
|
||||
});
|
||||
|
||||
/// Whether the tag in [payload] is hidden on cards being studied; false for
|
||||
/// anything it can't read.
|
||||
bool tagPayloadHidden(ByteData payload) {
|
||||
/// Whether the tag in [payload] was made for where words were met on
|
||||
/// Jellyfin; false for anything it can't read.
|
||||
bool tagPayloadFromJellyfin(ByteData payload) {
|
||||
try {
|
||||
return decodePayload(payload, newestSchema: tagSchemaVersion)['hidden'] ==
|
||||
true;
|
||||
return decodePayload(payload, newestSchema: tagSchemaVersion)['origin'] ==
|
||||
'jellyfin';
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,17 +70,19 @@ class TagRepository(
|
|||
Future<Tagging> read() => _serially(_read);
|
||||
|
||||
/// The id of the tag named [name] in any case: an existing one, keeping
|
||||
/// its case, or a new one in [colorName], [hidden] on cards if asked.
|
||||
/// Throws an [ArgumentError] for a blank name.
|
||||
/// its case and where it came from, or a new one in [colorName], made
|
||||
/// [fromJellyfin] if asked. Throws an [ArgumentError] for a blank name.
|
||||
Future<String> create(
|
||||
String name, {
|
||||
String colorName = defaultTagColorName,
|
||||
bool hidden = false,
|
||||
bool fromJellyfin = false,
|
||||
}) async {
|
||||
if (tidyTagName(name).isEmpty) {
|
||||
throw ArgumentError.value(name, 'name', 'is blank');
|
||||
}
|
||||
final id = await _serially(() => _idOf(name, colorName, hidden: hidden));
|
||||
final id = await _serially(
|
||||
() => _idOf(name, colorName, fromJellyfin: fromJellyfin),
|
||||
);
|
||||
await _refresh();
|
||||
return id;
|
||||
}
|
||||
|
|
@ -100,29 +102,13 @@ class TagRepository(
|
|||
id,
|
||||
encodeTagPayload(
|
||||
(name: name, colorName: colorName),
|
||||
hidden: (await _read()).byId(id)?.hidden ?? false,
|
||||
fromJellyfin: (await _read()).byId(id)?.fromJellyfin ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Hides the tag [id] on cards being studied, or shows it again.
|
||||
Future<void> setHidden(String id, {required bool hidden}) async {
|
||||
await _serially(() async {
|
||||
if ((await _read()).byId(id) case final tag?) {
|
||||
await _gateway.updateTagPayload(
|
||||
id,
|
||||
encodeTagPayload(
|
||||
(name: tag.name, colorName: tag.colorName),
|
||||
hidden: hidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Gives the card [cardId] exactly the tags [tagIds]: the missing ones
|
||||
/// are added, the others taken away. Tags gone in the meantime are left
|
||||
/// out.
|
||||
|
|
@ -410,17 +396,21 @@ class TagRepository(
|
|||
}
|
||||
|
||||
/// The id of the tag named [name]: an existing one, or a new one in
|
||||
/// [colorName], [hidden] if asked; call it only in [_serially].
|
||||
/// [colorName], made [fromJellyfin] if asked; call it only in
|
||||
/// [_serially].
|
||||
Future<String> _idOf(
|
||||
String name,
|
||||
String colorName, {
|
||||
bool hidden = false,
|
||||
bool fromJellyfin = false,
|
||||
}) async {
|
||||
if ((await _read()).named(name) case final existing?) {
|
||||
return existing.id;
|
||||
}
|
||||
return await _gateway.insertTag(
|
||||
encodeTagPayload((name: name, colorName: colorName), hidden: hidden),
|
||||
encodeTagPayload(
|
||||
(name: name, colorName: colorName),
|
||||
fromJellyfin: fromJellyfin,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -439,7 +429,7 @@ class TagRepository(
|
|||
id: row.id,
|
||||
name: name,
|
||||
colorName: colorName,
|
||||
hidden: tagPayloadHidden(row.payload),
|
||||
fromJellyfin: tagPayloadFromJellyfin(row.payload),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
|
||||
|
|
|
|||
|
|
@ -19,17 +19,18 @@ final class const Tag({
|
|||
/// The colour, a `PaletteColor`'s name.
|
||||
required final String colorName,
|
||||
|
||||
/// Whether cards being studied leave it out of their tags (decided
|
||||
/// 2026-10-13): for the many tags naming where a word was met, an
|
||||
/// episode or a song, which would crowd a common word's card. It still
|
||||
/// filters, narrows a study session and shows where tags are edited.
|
||||
final bool hidden = false,
|
||||
/// Whether the app made it for where words were met on Jellyfin (a
|
||||
/// show, an episode, an artist, a song; decided 2026-10-13): cards being
|
||||
/// studied leave such tags out while the person's settings say so
|
||||
/// (`hideJellyfinTags`), as they would crowd a common word's card. They
|
||||
/// still filter, narrow a study session and show where tags are chosen.
|
||||
final bool fromJellyfin = false,
|
||||
}) with Equatable {
|
||||
/// This tag as something else refers to it.
|
||||
TagRef get ref => (id: id, name: name);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, colorName, hidden];
|
||||
List<Object?> get props => [id, name, colorName, fromJellyfin];
|
||||
}
|
||||
|
||||
/// A tag as something else refers to it, settings say: by its `id`, and by
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue