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",
"tagManageRow": "{name} ({count})",
"tagManageEdit": "Tag bearbeiten",
"tagManageHide": "Auf Karten ausblenden",
"tagManageShow": "Auf Karten zeigen",
"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.}}",

View file

@ -674,6 +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",
"tagManageDelete": "Delete tag",
"tagDeleteTitle": "Delete the tag '{name}'?",
"@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}},

View file

@ -3568,6 +3568,18 @@ abstract class AppLocalizations {
/// **'Edit tag'**
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.
///
/// In en, this message translates to:

View file

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

View file

@ -2142,6 +2142,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tagManageEdit => 'Edit tag';
@override
String get tagManageHide => 'Hide on cards';
@override
String get tagManageShow => 'Show on cards';
@override
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
/// 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(
String studysetId,
List<DictionaryItem> words, {
List<String> tagNames = const [],
}) => _queue.run(() => _add(studysetId, words, tagNames));
List<String> hiddenTagNames = const [],
}) => _queue.run(() => _add(studysetId, words, tagNames, hiddenTagNames));
Future<DictionaryAdded> _add(
String studysetId,
List<DictionaryItem> words,
List<String> extraTagNames,
List<String> hiddenTagNames,
) async {
final there = await _entriesIn(studysetId);
final fresh = [
@ -71,6 +75,10 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
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);
(tagsByCard[ids[index]] ??= {}).add(tagId);
}
}
await _tags.addTags(tagsByCard);
return (added: fresh.length, skipped: words.length - fresh.length);

View file

@ -146,7 +146,14 @@ class const StudyCardHeader({
const Spacer(),
] else
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),
PopupMenuButton<_CardAction>(

View file

@ -28,6 +28,9 @@ 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;
@ -75,6 +78,18 @@ 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.

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).
//
// 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.
const tagSchemaVersion = 1;
/// The newest schema this app writes.
const tagSchemaVersion = 2;
/// A tag's content: its name and colour.
typedef TagContent = ({String name, String colorName});
/// 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),
'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.
/// Names from before tags kept their case are in lowercase.
///

View file

@ -70,15 +70,17 @@ 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]. 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(
String name, {
String colorName = defaultTagColorName,
bool hidden = false,
}) async {
if (tidyTagName(name).isEmpty) {
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();
return id;
}
@ -94,14 +96,33 @@ class TagRepository(
throw ArgumentError.value(name, 'name', 'is blank');
}
await _serially(
() => _gateway.updateTagPayload(
() async => await _gateway.updateTagPayload(
id,
encodeTagPayload((name: name, colorName: colorName)),
encodeTagPayload(
(name: name, colorName: colorName),
hidden: (await _read()).byId(id)?.hidden ?? 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.
@ -389,13 +410,17 @@ class TagRepository(
}
/// The id of the tag named [name]: an existing one, or a new one in
/// [colorName]; call it only in [_serially].
Future<String> _idOf(String name, String colorName) async {
/// [colorName], [hidden] if asked; call it only in [_serially].
Future<String> _idOf(
String name,
String colorName, {
bool hidden = false,
}) async {
if ((await _read()).named(name) case final existing?) {
return existing.id;
}
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) {
try {
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) {
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
return null;

View file

@ -18,12 +18,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,
}) 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];
List<Object?> get props => [id, name, colorName, hidden];
}
/// 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(
tooltip: l10n.tagManageEdit,
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', () {
final payload = encodePayload(1, {
'name': ' Verb form',
@ -32,7 +55,7 @@ void main() {
test('refuses a blank name, a newer schema and a blanked tag', () {
for (final payload in [
encodePayload(1, {'name': ' ', 'color': 'fern'}),
encodePayload(2, {'name': 'verb', 'color': 'fern'}),
encodePayload(3, {'name': 'verb', 'color': 'fern'}),
redactedPayload,
]) {
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 {
await repository.create('verb');
await repository.create('adjective');