feat(app): choose, manage and filter by tags

The card editor offers its tags: a picker that searches by the start of
a name, creates a tag (name and colour) and chooses it, and saves the
choice with the card. A sheet lists every tag with its count, to add,
rename (merging into a tag of that name), recolour and delete them, with
an undo. The card list shows each card's tags and narrows to one tag.

The editor closed twice once tags changed after saving; it now closes
on becoming done only.

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-01 16:57:57 +02:00
commit 9dc9b6a5ed
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 2076 additions and 63 deletions

View file

@ -6,8 +6,10 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
@ -19,17 +21,22 @@ CardContent card(String front, String back) =>
void main() {
late FakeCardGateway gateway;
late CardRepository cards;
late TagRepository tags;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
});
tearDown(() async {
await tags.dispose();
await cards.dispose();
});
tearDown(() => cards.dispose());
group('CardListBloc', () {
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, 'japanese')
final bloc = CardListBloc(cards, tags, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
@ -59,7 +66,7 @@ void main() {
test('moves a card', () async {
await cards.create('japanese', card('a', ''));
await cards.create('japanese', card('b', ''));
final bloc = CardListBloc(cards, 'japanese')
final bloc = CardListBloc(cards, tags, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere(
@ -81,10 +88,74 @@ void main() {
});
});
group('CardListBloc with tags', () {
late CardListBloc bloc;
late List<String> ids;
setUp(() async {
for (final front in ['猫', '犬', '食べる']) {
await cards.create('japanese', card(front, ''));
}
ids = [for (final card in await cards.cardsOf('japanese')) card.id];
final noun = await tags.create('noun');
final verb = await tags.create('verb');
await tags.create('unused');
await tags.setTagsOf(ids[0], {noun});
await tags.setTagsOf(ids[1], {noun});
await tags.setTagsOf(ids[2], {verb});
bloc = CardListBloc(cards, tags, 'japanese')
..add(const CardListEventStarted());
});
tearDown(() => bloc.close());
/// The next state that has the tags read.
Future<CardListStateLoaded> tagged() async => await bloc.stream.firstWhere(
(state) => state is CardListStateLoaded && state.tagging.all.isNotEmpty,
) as CardListStateLoaded;
test("shows each card's tags, and offers to filter by those the cards "
'have', () async {
final state = await tagged();
expect(state.tagging.of(ids[2]).map((tag) => tag.name), ['verb']);
expect(state.tagsInUse.map((tag) => tag.name), ['noun', 'verb']);
expect(state.shown, hasLength(3));
});
test('a tag narrows the list to its cards, and clearing it shows them '
'all again', () async {
final noun = (await tagged()).tagging.named('noun')!;
bloc.add(CardListEventTagFilterChanged(noun.id));
final narrowed = await bloc.stream.first as CardListStateLoaded;
bloc.add(const CardListEventTagFilterChanged(null));
final all = await bloc.stream.first as CardListStateLoaded;
expect(narrowed.filter, noun);
expect(narrowed.shown.map((card) => card.id), [ids[0], ids[1]]);
expect(all.shown, hasLength(3));
});
test('a deleted tag no longer narrows the list', () async {
final noun = (await tagged()).tagging.named('noun')!;
bloc.add(CardListEventTagFilterChanged(noun.id));
await bloc.stream.first;
await tags.delete(noun.id);
final state = await bloc.stream.firstWhere(
(state) =>
state is CardListStateLoaded && state.tagging.named('noun') == null,
) as CardListStateLoaded;
expect(state.filter, isNull);
expect(state.shown, hasLength(3));
});
});
group('CardEditBloc', () {
test('a new card can be saved once it has content, and then is '
'done', () async {
final bloc = CardEditBloc(cards, 'japanese', null);
final bloc = CardEditBloc(cards, tags, 'japanese', null);
addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse);
@ -109,6 +180,7 @@ void main() {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardEditBloc(
cards,
tags,
'japanese',
(await cards.cardsOf('japanese')).single,
);
@ -138,7 +210,7 @@ void main() {
payload: encodeCardPayload(card('猫', 'the cat')),
);
conflicted = (await cards.cardsOf('japanese')).single;
bloc = CardEditBloc(cards, 'japanese', conflicted);
bloc = CardEditBloc(cards, tags, 'japanese', conflicted);
});
tearDown(() => bloc.close());
@ -171,10 +243,71 @@ void main() {
});
});
test("shows a card's tags; changing only them can be saved, without a "
'new revision of its sides', () async {
final cardId = await cards.create('japanese', card('猫', 'cat'));
final noun = await tags.create('noun');
final animal = await tags.create('animal');
await tags.setTagsOf(cardId, {noun});
final bloc = CardEditBloc(
cards,
tags,
'japanese',
(await cards.cardsOf('japanese')).single,
)..add(const CardEditEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere((s) => s.tagIds != null);
expect(loaded.tagIds, {noun});
expect(loaded.canSave, isFalse);
bloc.add(CardEditEventTagsChanged({noun, animal}));
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
);
bloc.add(const CardEditEventSaveRequested());
await bloc.stream.firstWhere((s) => s.isDone);
final tagging = await tags.read();
expect(tagging.of(cardId).map((tag) => tag.name), ['animal', 'noun']);
expect(await gateway.revisionsOf({cardId}), hasLength(1));
});
test('a new card gets the tags chosen once saved', () async {
final noun = await tags.create('noun');
final bloc = CardEditBloc(cards, tags, 'japanese', null)
..add(const CardEditEventStarted());
addTearDown(bloc.close);
await bloc.stream.firstWhere((s) => s.tagIds != null);
bloc
..add(CardEditEventTagsChanged({noun}))
..add(CardEditEventSideChanged(CardSide.front, text('猫')))
..add(const CardEditEventSaveRequested());
await bloc.stream.firstWhere((s) => s.isDone);
final cardId = (await cards.cardsOf('japanese')).single.id;
expect((await tags.read()).of(cardId).map((tag) => tag.id), [noun]);
});
test('a new card with only tags has nothing to save', () async {
final noun = await tags.create('noun');
final bloc = CardEditBloc(cards, tags, 'japanese', null)
..add(const CardEditEventStarted());
addTearDown(bloc.close);
await bloc.stream.firstWhere((s) => s.tagIds != null);
bloc.add(CardEditEventTagsChanged({noun}));
final state = await bloc.stream.first;
expect(state.canSave, isFalse);
});
test('deleting the card is done too', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardEditBloc(
cards,
tags,
'japanese',
(await cards.cardsOf('japanese')).single,
)..add(const CardEditEventDeleteRequested());

View file

@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import '../../../support/fake_tag_gateway.dart';
void main() {
late FakeTagGateway gateway;
late TagRepository tags;
late TagsBloc bloc;
setUp(() {
gateway = FakeTagGateway();
tags = TagRepository(gateway);
bloc = TagsBloc(tags)..add(const TagsEventStarted());
});
tearDown(() async {
await bloc.close();
await tags.dispose();
});
/// The next state whose tags are [expected], as (name, colour).
Future<TagsStateLoaded> showing(List<(String, String)> expected) async {
bool matches(TagsState state) =>
state is TagsStateLoaded &&
state.tagging.all.map((tag) => (tag.name, tag.colorName)).join('|') ==
expected.join('|');
if (matches(bloc.state)) {
return bloc.state as TagsStateLoaded;
}
return await bloc.stream.firstWhere(matches) as TagsStateLoaded;
}
test('creates, renames and recolours tags', () async {
await showing([]);
bloc.add(
const TagsEventCreateRequested((name: 'Verb', colorName: 'fern')),
);
final created = await showing([('verb', 'fern')]);
bloc.add(
TagsEventUpdateRequested(
created.tagging.all.single.id,
(name: 'verbs', colorName: 'plum'),
),
);
await showing([('verbs', 'plum')]);
});
test('deletes a tag, and an undo brings it back on its cards', () async {
final verb = await tags.create('verb', colorName: 'fern');
await tags.setTagsOf('card-1', {verb});
await showing([('verb', 'fern')]);
bloc.add(TagsEventDeleteRequested(verb));
await showing([]);
bloc.add(const TagsEventUndoDeleteRequested());
final restored = await showing([('verb', 'fern')]);
expect(restored.tagging.of('card-1').map((tag) => tag.name), ['verb']);
});
test('an undo right after deleting waits for the deletion', () async {
final verb = await tags.create('verb');
await showing([('verb', defaultTagColorName)]);
bloc
..add(TagsEventDeleteRequested(verb))
..add(const TagsEventUndoDeleteRequested());
await showing([]);
await showing([('verb', defaultTagColorName)]);
});
test('a failed read shows as failed', () async {
await bloc.close();
gateway.failReads = Exception('disk');
bloc = TagsBloc(tags)..add(const TagsEventStarted());
await expectLater(
bloc.stream,
emits(const TagsStateFailed()),
);
});
}

View file

@ -0,0 +1,258 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/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';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
/// Rich content with one paragraph of [text].
RichContent paragraph(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
void main() {
late FakeCardGateway cardGateway;
late FakeTagGateway tagGateway;
late CardRepository cards;
late TagRepository tags;
setUp(() {
cardGateway = FakeCardGateway();
tagGateway = FakeTagGateway();
cards = CardRepository(cardGateway);
tags = TagRepository(tagGateway);
});
tearDown(() async {
await tags.dispose();
await cards.dispose();
});
/// A card in the studyset `japanese` with [front], and the tags [named].
Future<String> tagged(String front, [List<String> named = const []]) async {
final id = await cards.create(
'japanese',
CardContent(front: paragraph(front), back: paragraph('')),
);
await tags.setTagsOf(id, {
for (final name in named) await tags.create(name),
});
return id;
}
/// Opens the studyset `japanese`, with the cards and tags the test stored
/// through [cards] and [tags]: the app reads them from the same gateways.
Future<void> showList(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
),
);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
tagGateway: tagGateway,
),
location: RoutePaths.studyset('japanese'),
);
// Flushes the description view's timers.
await tester.pump(const Duration(seconds: 1));
}
/// Opens the card with [front] in its editor.
Future<void> openCard(WidgetTester tester, String front) async {
await tester.tap(find.text(front));
await tester.pump();
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Opens the tags sheet from the card list's menu.
Future<void> manageTags(WidgetTester tester) async {
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage tags'));
// A menu item acts once the menu has closed, a frame later.
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
}
Finder chip<T extends Widget>(String label) => find.widgetWithText(T, label);
bool selected(WidgetTester tester, String label) =>
tester.widget<FilterChip>(chip<FilterChip>(label)).selected;
testWidgets('a tag created in the picker is chosen, and saving gives it '
'to the card, which the list then shows', (tester) async {
await tagged('猫');
await showList(tester);
await openCard(tester, '猫');
await tester.tap(find.text('Add tags'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Noun');
await tester.pump();
await tester.tap(find.text('Create "noun"'));
await tester.pumpAndSettle();
expect(find.text('Create tag'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pumpAndSettle();
expect(selected(tester, 'noun'), isTrue);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(chip<TagChip>('noun'), findsOneWidget);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TagLabel, 'noun'), findsOneWidget);
});
testWidgets('the picker narrows the tags to those starting with what is '
'typed, and taking a tag away can be saved', (tester) async {
await tagged('猫', ['verb']);
await tags.create('noun');
await tags.create('adjective');
await showList(tester);
await openCard(tester, '猫');
await tester.tap(find.text('Edit tags'));
await tester.pumpAndSettle();
expect(selected(tester, 'verb'), isTrue);
expect(selected(tester, 'noun'), isFalse);
await tester.enterText(find.byType(TextField).last, 'v');
await tester.pump();
expect(chip<FilterChip>('noun'), findsNothing);
expect(chip<FilterChip>('adjective'), findsNothing);
await tester.tap(chip<FilterChip>('verb'));
await tester.pump();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('Add tags'), findsOneWidget);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TagLabel, 'verb'), findsNothing);
});
testWidgets('managing tags: each with its count; renaming to an existing '
'name says it merges; deleting asks and can be undone', (tester) async {
await tagged('猫', ['noun']);
await tags.create('verb');
await showList(tester);
await manageTags(tester);
expect(find.text('noun (1)'), findsOneWidget);
expect(find.text('verb (0)'), findsOneWidget);
await tester.tap(find.byTooltip('Edit tag').first);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Verb');
await tester.pump();
expect(
find.text(
"There is a tag 'verb' already: saving merges this one into it.",
),
findsOneWidget,
);
await tester.enterText(find.byType(TextField).last, 'nouns');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.pumpAndSettle();
expect(find.text('nouns (1)'), findsOneWidget);
await tester.tap(find.byTooltip('Delete tag').first);
await tester.pumpAndSettle();
expect(find.text("Delete the tag 'nouns'?"), findsOneWidget);
expect(
find.text('It is removed from 1 flashcard, on all your devices.'),
findsOneWidget,
);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('nouns (1)'), findsNothing);
expect(find.text("Deleted the tag 'nouns'"), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('nouns (1)'), findsOneWidget);
});
testWidgets('a new tag cannot take a name that exists', (tester) async {
await tags.create('noun');
await tagged('猫');
await showList(tester);
await manageTags(tester);
await tester.tap(find.text('Add tag'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, ' Noun');
await tester.pump();
expect(find.text("The tag 'noun' exists already."), findsOneWidget);
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
.onPressed,
isNull,
);
});
testWidgets('the cards narrow to those with a tag, among the tags they '
'have, and widen again', (tester) async {
await tagged('猫', ['noun']);
await tagged('食べる', ['verb']);
await tags.create('unused');
await showList(tester);
await tester.tap(find.byTooltip('Filter'));
await tester.pumpAndSettle();
expect(chip<ChoiceChip>('unused'), findsNothing);
await tester.tap(chip<ChoiceChip>('verb'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(find.text('食べる'), findsOneWidget);
expect(find.text('猫'), findsNothing);
await tester.tap(find.byTooltip('Filter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Clear all'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(find.text('猫'), findsOneWidget);
});
}