feat(tag): a tag can be hidden on cards being studied

A tag's eye in Manage tags hides it on cards being studied, or shows it again: for the many tags naming where a word was met (an episode, a song), which would crowd a common word's card. A hidden tag still filters, narrows a study session and shows where tags are edited. Tag schema 2 adds 'hidden', written only for a hidden tag, so older apps go on reading every other.

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:27:11 +02:00
commit 68945b2224
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 179 additions and 18 deletions

View file

@ -597,6 +597,8 @@
"tagManageAdd": "Tag hinzufügen", "tagManageAdd": "Tag hinzufügen",
"tagManageRow": "{name} ({count})", "tagManageRow": "{name} ({count})",
"tagManageEdit": "Tag bearbeiten", "tagManageEdit": "Tag bearbeiten",
"tagManageHide": "Auf Karten ausblenden",
"tagManageShow": "Auf Karten zeigen",
"tagManageDelete": "Tag löschen", "tagManageDelete": "Tag löschen",
"tagDeleteTitle": "Tag „{name}“ 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.}}", "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.}}",

View file

@ -674,6 +674,8 @@
"tagManageRow": "{name} ({count})", "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"}}}, "@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", "tagManageEdit": "Edit tag",
"tagManageHide": "Hide on cards",
"tagManageShow": "Show on cards",
"tagManageDelete": "Delete tag", "tagManageDelete": "Delete tag",
"tagDeleteTitle": "Delete the tag '{name}'?", "tagDeleteTitle": "Delete the tag '{name}'?",
"@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}}, "@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}},

View file

@ -3568,6 +3568,18 @@ abstract class AppLocalizations {
/// **'Edit tag'** /// **'Edit tag'**
String get tagManageEdit; String get tagManageEdit;
/// No description provided for @tagManageHide.
///
/// In en, this message translates to:
/// **'Hide on cards'**
String get tagManageHide;
/// No description provided for @tagManageShow.
///
/// In en, this message translates to:
/// **'Show on cards'**
String get tagManageShow;
/// No description provided for @tagManageDelete. /// No description provided for @tagManageDelete.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2159,6 +2159,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get tagManageEdit => 'Tag bearbeiten'; String get tagManageEdit => 'Tag bearbeiten';
@override
String get tagManageHide => 'Auf Karten ausblenden';
@override
String get tagManageShow => 'Auf Karten zeigen';
@override @override
String get tagManageDelete => 'Tag löschen'; String get tagManageDelete => 'Tag löschen';

View file

@ -2142,6 +2142,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tagManageEdit => 'Edit tag'; String get tagManageEdit => 'Edit tag';
@override
String get tagManageHide => 'Hide on cards';
@override
String get tagManageShow => 'Show on cards';
@override @override
String get tagManageDelete => 'Delete tag'; String get tagManageDelete => 'Delete tag';

View file

@ -35,17 +35,21 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
}; };
/// Adds [words] to [studysetId], at its end, in their order; each card /// Adds [words] to [studysetId], at its end, in their order; each card
/// also tagged [tagNames] (the show a word was met in). /// 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).
Future<DictionaryAdded> add( Future<DictionaryAdded> add(
String studysetId, String studysetId,
List<DictionaryItem> words, { List<DictionaryItem> words, {
List<String> tagNames = const [], List<String> tagNames = const [],
}) => _queue.run(() => _add(studysetId, words, tagNames)); List<String> hiddenTagNames = const [],
}) => _queue.run(() => _add(studysetId, words, tagNames, hiddenTagNames));
Future<DictionaryAdded> _add( Future<DictionaryAdded> _add(
String studysetId, String studysetId,
List<DictionaryItem> words, List<DictionaryItem> words,
List<String> extraTagNames, List<String> extraTagNames,
List<String> hiddenTagNames,
) async { ) async {
final there = await _entriesIn(studysetId); final there = await _entriesIn(studysetId);
final fresh = [ final fresh = [
@ -71,6 +75,10 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
final tagId = tagIdOf[name] ??= await _tags.create(name); final tagId = tagIdOf[name] ??= await _tags.create(name);
(tagsByCard[ids[index]] ??= {}).add(tagId); (tagsByCard[ids[index]] ??= {}).add(tagId);
} }
for (final name in hiddenTagNames) {
final tagId = tagIdOf[name] ??= await _tags.create(name, hidden: true);
(tagsByCard[ids[index]] ??= {}).add(tagId);
}
} }
await _tags.addTags(tagsByCard); await _tags.addTags(tagsByCard);
return (added: fresh.length, skipped: words.length - fresh.length); return (added: fresh.length, skipped: words.length - fresh.length);

View file

@ -146,7 +146,14 @@ class const StudyCardHeader({
const Spacer(), const Spacer(),
] else ] else
Expanded( Expanded(
child: OverflowingTagChipsRow(tags: tags, onPressed: onTags), // 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,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
PopupMenuButton<_CardAction>( PopupMenuButton<_CardAction>(

View file

@ -28,6 +28,9 @@ class TagsBloc(final TagRepository _tags) extends Bloc<TagsEvent, TagsState> {
colorName: event.content.colorName, colorName: event.content.colorName,
), ),
); );
on<TagsEventHiddenRequested>(
(event, emit) => _tags.setHidden(event.id, hidden: event.hidden),
);
on<TagsEventDeleteRequested>((event, emit) async { on<TagsEventDeleteRequested>((event, emit) async {
_deleting = _tags.delete(event.id); _deleting = _tags.delete(event.id);
await _deleting; await _deleting;
@ -75,6 +78,18 @@ final class const TagsEventUpdateRequested(
List<Object?> get props => [id, content]; 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. /// Delete the tag [id] from every card and then itself.
final class const TagsEventDeleteRequested( final class const TagsEventDeleteRequested(
/// The tag's id. /// The tag's id.

View file

@ -5,21 +5,37 @@ 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). // A tag's content as its synced row stores it (see core/content/payload).
// //
// Schema 1 holds the name and the colour. // 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
// others.
/// The schema this app writes. /// The newest schema this app writes.
const tagSchemaVersion = 1; const tagSchemaVersion = 2;
/// A tag's content: its name and colour. /// A tag's content: its name and colour.
typedef TagContent = ({String name, String colorName}); typedef TagContent = ({String name, String colorName});
/// Encodes a tag's [content] into its payload, the name as a [tidyTagName]. /// Encodes a tag's [content] into its payload, the name as a [tidyTagName].
ByteData encodeTagPayload(TagContent content) => ///
encodePayload(tagSchemaVersion, { /// A [hidden] tag is written in schema 2, any other in schema 1.
ByteData encodeTagPayload(TagContent content, {bool hidden = false}) =>
encodePayload(hidden ? 2 : 1, {
'name': tidyTagName(content.name), 'name': tidyTagName(content.name),
'color': content.colorName, 'color': content.colorName,
if (hidden) 'hidden': true,
}); });
/// Whether the tag in [payload] is hidden on cards being studied; false for
/// anything it can't read.
bool tagPayloadHidden(ByteData payload) {
try {
return decodePayload(payload, newestSchema: tagSchemaVersion)['hidden'] ==
true;
} on FormatException {
return false;
}
}
/// Decodes a tag's payload, the name as a [tidyTagName] whoever wrote it. /// Decodes a tag's payload, the name as a [tidyTagName] whoever wrote it.
/// Names from before tags kept their case are in lowercase. /// Names from before tags kept their case are in lowercase.
/// ///

View file

@ -70,15 +70,17 @@ class TagRepository(
Future<Tagging> read() => _serially(_read); Future<Tagging> read() => _serially(_read);
/// The id of the tag named [name] in any case: an existing one, keeping /// The id of the tag named [name] in any case: an existing one, keeping
/// its case, or a new one in [colorName]. Throws an [ArgumentError] for a blank name. /// its case, or a new one in [colorName], [hidden] on cards if asked.
/// Throws an [ArgumentError] for a blank name.
Future<String> create( Future<String> create(
String name, { String name, {
String colorName = defaultTagColorName, String colorName = defaultTagColorName,
bool hidden = false,
}) async { }) async {
if (tidyTagName(name).isEmpty) { if (tidyTagName(name).isEmpty) {
throw ArgumentError.value(name, 'name', 'is blank'); throw ArgumentError.value(name, 'name', 'is blank');
} }
final id = await _serially(() => _idOf(name, colorName)); final id = await _serially(() => _idOf(name, colorName, hidden: hidden));
await _refresh(); await _refresh();
return id; return id;
} }
@ -94,14 +96,33 @@ class TagRepository(
throw ArgumentError.value(name, 'name', 'is blank'); throw ArgumentError.value(name, 'name', 'is blank');
} }
await _serially( await _serially(
() => _gateway.updateTagPayload( () async => await _gateway.updateTagPayload(
id, id,
encodeTagPayload((name: name, colorName: colorName)), encodeTagPayload(
(name: name, colorName: colorName),
hidden: (await _read()).byId(id)?.hidden ?? false,
),
), ),
); );
await _refresh(); 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 /// Gives the card [cardId] exactly the tags [tagIds]: the missing ones
/// are added, the others taken away. Tags gone in the meantime are left /// are added, the others taken away. Tags gone in the meantime are left
/// out. /// out.
@ -389,13 +410,17 @@ class TagRepository(
} }
/// The id of the tag named [name]: an existing one, or a new one in /// The id of the tag named [name]: an existing one, or a new one in
/// [colorName]; call it only in [_serially]. /// [colorName], [hidden] if asked; call it only in [_serially].
Future<String> _idOf(String name, String colorName) async { Future<String> _idOf(
String name,
String colorName, {
bool hidden = false,
}) async {
if ((await _read()).named(name) case final existing?) { if ((await _read()).named(name) case final existing?) {
return existing.id; return existing.id;
} }
return await _gateway.insertTag( return await _gateway.insertTag(
encodeTagPayload((name: name, colorName: colorName)), encodeTagPayload((name: name, colorName: colorName), hidden: hidden),
); );
} }
@ -410,7 +435,12 @@ class TagRepository(
Tag? _decode(TagRow row) { Tag? _decode(TagRow row) {
try { try {
final (:name, :colorName) = decodeTagPayload(row.payload); final (:name, :colorName) = decodeTagPayload(row.payload);
return Tag(id: row.id, name: name, colorName: colorName); return Tag(
id: row.id,
name: name,
colorName: colorName,
hidden: tagPayloadHidden(row.payload),
);
} on FormatException catch (error) { } on FormatException catch (error) {
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags'); log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
return null; return null;

View file

@ -18,12 +18,18 @@ final class const Tag({
/// The colour, a `PaletteColor`'s name. /// The colour, a `PaletteColor`'s name.
required final String colorName, 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,
}) with Equatable { }) with Equatable {
/// This tag as something else refers to it. /// This tag as something else refers to it.
TagRef get ref => (id: id, name: name); TagRef get ref => (id: id, name: name);
@override @override
List<Object?> get props => [id, name, colorName]; List<Object?> get props => [id, name, colorName, hidden];
} }
/// A tag as something else refers to it, settings say: by its `id`, and by /// A tag as something else refers to it, settings say: by its `id`, and by

View file

@ -112,6 +112,16 @@ class const _TagRow({required final Tag tag, required final Tagging tagging})
), ),
), ),
), ),
IconButton(
tooltip: tag.hidden ? l10n.tagManageShow : l10n.tagManageHide,
onPressed: () => context.read<TagsBloc>().add(
TagsEventHiddenRequested(tag.id, hidden: !tag.hidden),
),
icon: Icon(
tag.hidden ? LucideIcons.eyeOff : LucideIcons.eye,
size: 18,
),
),
IconButton( IconButton(
tooltip: l10n.tagManageEdit, tooltip: l10n.tagManageEdit,
onPressed: () => _edit(context), onPressed: () => _edit(context),

View file

@ -20,6 +20,29 @@ void main() {
}); });
}); });
test('a hidden tag is schema 2 and says so; any other stays schema 1, '
'which older apps read', () {
final hidden = encodeTagPayload((
name: 'あかね噺 S1 E1',
colorName: 'fern',
), hidden: true);
final shown = encodeTagPayload((name: 'あかね噺', colorName: 'fern'));
expect(decodePayload(hidden, newestSchema: tagSchemaVersion), {
'schemaVersion': 2,
'name': 'あかね噺 S1 E1',
'color': 'fern',
'hidden': true,
});
expect(tagPayloadHidden(hidden), isTrue);
expect(decodeTagPayload(hidden).name, 'あかね噺 S1 E1');
expect(
decodePayload(shown, newestSchema: tagSchemaVersion)['schemaVersion'],
1,
);
expect(tagPayloadHidden(shown), isFalse);
});
test('reads a name as another device wrote it, tidied', () { test('reads a name as another device wrote it, tidied', () {
final payload = encodePayload(1, { final payload = encodePayload(1, {
'name': ' Verb form', 'name': ' Verb form',
@ -32,7 +55,7 @@ void main() {
test('refuses a blank name, a newer schema and a blanked tag', () { test('refuses a blank name, a newer schema and a blanked tag', () {
for (final payload in [ for (final payload in [
encodePayload(1, {'name': ' ', 'color': 'fern'}), encodePayload(1, {'name': ' ', 'color': 'fern'}),
encodePayload(2, {'name': 'verb', 'color': 'fern'}), encodePayload(3, {'name': 'verb', 'color': 'fern'}),
redactedPayload, redactedPayload,
]) { ]) {
expect(() => decodeTagPayload(payload), throwsFormatException); expect(() => decodeTagPayload(payload), throwsFormatException);

View file

@ -67,6 +67,24 @@ void main() {
}); });
}); });
test('a tag hidden on cards stays hidden when renamed, and shows again '
'when asked; one is made hidden from the start if asked', () async {
final id = await repository.create('Verb', colorName: 'fern');
await repository.setHidden(id, hidden: true);
expect((await repository.read()).byId(id)?.hidden, isTrue);
await repository.update(id, name: 'Verbs', colorName: 'plum');
expect(
(await repository.read()).byId(id),
Tag(id: id, name: 'Verbs', colorName: 'plum', hidden: true),
);
await repository.setHidden(id, hidden: false);
expect((await repository.read()).byId(id)?.hidden, isFalse);
final episode = await repository.create('あかね噺 S1 E1', hidden: true);
expect((await repository.read()).byId(episode)?.hidden, isTrue);
});
test('lists the tags by name, whatever their case', () async { test('lists the tags by name, whatever their case', () async {
await repository.create('verb'); await repository.create('verb');
await repository.create('adjective'); await repository.create('adjective');