feat(app): a card browser to search, filter, sort and change many cards at once

Browse beside the flashcards' tab opens the studyset's cards over the
tabs (with everything inside it when it has sub-studysets), laid out as
the older design had it:
- a grid of tiles (each card's two sides) or one card at a time beside
  the list (on a phone above a strip of them, swiped through), as last
  left for the studyset on this device;
- a search over front, back, reading, meanings, hints, explanation and
  tag names, katakana found as hiragana and kana by romaji;
- sorting by own order, newest, oldest, A–Z (words by reading), due
  first; a filter sheet by kind, tag and state (new, learning, due,
  buried, reported);
- choosing cards (a toggle; shift-click or long-press for a range,
  select all) to move into another or a new studyset, add or remove
  tags, start afresh, or delete with Undo; the actions in the row on
  wide windows, in a bar at the foot below.

Moving cards between studysets is new in the card repository and its
gateway (studysetId and position written, named columns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 14:21:01 +02:00
commit 42c57bd40a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 4436 additions and 19 deletions

View file

@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
CardContent word(String front, String reading, String meaning) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain(front),
back: RichContent.empty(),
reading: reading,
meanings: [meaning],
);
CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
void main() {
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late CardDeletions deletions;
late CardBrowserBloc bloc;
setUp(() {
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards);
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await tags.dispose();
await reviews.dispose();
await cards.dispose();
});
void start([List<String> studysets = const ['japanese']]) =>
bloc = CardBrowserBloc(cards, tags, reviews, deletions, studysets)
..add(const CardBrowserEventStarted());
Future<CardBrowserState> until(bool Function(CardBrowserState) test) =>
test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
List<String> fronts(CardBrowserState state) => [
for (final each in state.shown) each.card.content.front.plainText.trim(),
];
test('a search finds cards by their front, reading (katakana as '
'hiragana, romaji for kana), meanings and tags', () async {
final taberu = await cards.create('japanese', word('食べる', 'たべる', 'to eat'));
await cards.create('japanese', word('テレビ', 'てれび', 'television'));
await cards.create('japanese', card('hola', 'hello'));
await tags.setTagsOf(taberu, {await tags.create('Verb')});
start();
await until((s) => s.loaded && s.cards.length == 3);
Future<List<String>> search(String query) async {
bloc.add(CardBrowserEventQueryChanged(query));
return fronts(await until((s) => s.filter.query == query));
}
expect(await search('たべ'), ['食べる']);
expect(await search('taberu'), ['食べる']);
expect(await search('テレ'), ['テレビ']);
expect(await search('TELEVISION'), ['テレビ']);
expect(await search('verb'), ['食べる']);
expect(await search('hello'), ['hola']);
expect(await search(''), hasLength(3));
});
test('filters by kind and state, and sorts', () async {
await cards.create('japanese', word('猫', 'ねこ', 'cat'));
final studied = await cards.create('japanese', card('b', 'b'));
await cards.create('japanese', word('犬', 'いぬ', 'dog'));
await reviews.record(
cardId: studied,
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
start();
await until((s) => s.loaded && s.cards.length == 3);
bloc.add(
const CardBrowserEventFilterChanged(kind: CardBrowserKind.vocabulary),
);
expect(
fronts(await until((s) => s.filter.kind == CardBrowserKind.vocabulary)),
['猫', '犬'],
);
bloc.add(const CardBrowserEventSortChanged(CardBrowserSort.alphabetical));
expect(
fronts(
await until((s) => s.sort == CardBrowserSort.alphabetical),
),
['犬', '猫'],
);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.fresh}));
final fresh = await until((s) => s.filter.states.isNotEmpty);
expect(fronts(fresh), ['犬', '猫']);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.due}));
expect(
fronts(await until((s) => s.filter.states.contains(CardStatus.due))),
['b'],
);
bloc.add(const CardBrowserEventFiltersCleared());
expect(await until((s) => !s.isFiltered), isA<CardBrowserState>());
});
test('chooses cards one by one or as a range, and moves, tags, untags and '
'starts them afresh all at once', () async {
final ids = [
for (final front in ['a', 'b', 'c', 'd'])
await cards.create('japanese', card(front, front)),
];
final lesson = await tags.create('Lesson 1');
start();
await until((s) => s.loaded && s.cards.length == 4);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(ids[0]))
..add(CardBrowserEventCardSelected(ids[2], range: true));
final chosen = await until((s) => s.selected.length == 3);
expect(chosen.selected, {ids[0], ids[1], ids[2]});
bloc.add(CardBrowserEventTagsAdded({lesson}));
await until(
(s) => s.cards.where((each) => each.tags.isNotEmpty).length == 3,
);
bloc.add(CardBrowserEventTagsRemoved({lesson}));
await until((s) => s.cards.every((each) => each.tags.isEmpty));
await reviews.record(
cardId: ids[1],
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
bloc.add(const CardBrowserEventProgressReset());
await until(
(s) => s.cards.every((each) => each.schedule?.isNew ?? true),
);
bloc.add(const CardBrowserEventMoved('kana'));
final moved = await until((s) => s.cards.length == 1);
expect(moved.selected, isEmpty);
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[ids[0], ids[1], ids[2]],
);
});
test('a studyset and those inside it are browsed together, in tree '
'order', () async {
await cards.create('japanese', card('top', 'x'));
await cards.create('kana', card('inside', 'x'));
start(['japanese', 'kana']);
final state = await until((s) => s.loaded && s.cards.length == 2);
expect(fronts(state), ['top', 'inside']);
expect(state.shown.last.studysetId, 'kana');
});
test('a card whose deletion waits leaves the browser and the '
'selection', () async {
final gone = await cards.create('japanese', card('gone', 'x'));
await cards.create('japanese', card('stays', 'x'));
start();
await until((s) => s.loaded && s.cards.length == 2);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(gone));
await until((s) => s.selected.isNotEmpty);
deletions.delete(gone);
final state = await until((s) => s.cards.length == 1);
expect(state.selected, isEmpty);
});
}

View file

@ -0,0 +1,175 @@
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:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeCardGateway cardGateway;
late CardRepository cards;
setUp(() {
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
});
tearDown(() => cards.dispose());
/// The studyset `japanese` with a word, a question and another word, on
/// a window of [width].
Future<void> show(
WidgetTester tester, {
double width = 1400,
String? location,
}) async {
tester.view.physicalSize = Size(width, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
await cards.create(
'japanese',
CardContent(
type: CardType.vocabulary,
front: RichContent.plain('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat'],
),
);
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('Capital of Japan?'),
back: RichContent.plain('Tokyo'),
),
);
await cards.create(
'japanese',
CardContent(
type: CardType.vocabulary,
front: RichContent.plain('猫'),
back: RichContent.empty(),
reading: 'ねこ',
meanings: const ['cat'],
),
);
await pumpApp(
tester,
testDependencies(studysetGateway: studysets, cardGateway: cardGateway),
location: location ?? RoutePaths.cardBrowser('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets("Browse beside the flashcards' tab opens the browser, every "
'card a tile with its two sides', (tester) async {
await show(tester, location: RoutePaths.studyset('japanese'));
await tester.tap(find.text('Browse'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Japanese — flashcards'), findsOneWidget);
expect(find.text('Tokyo', findRichText: true), findsOneWidget);
expect(find.text('to eat'), findsOneWidget);
expect(find.text('cat'), findsOneWidget);
});
testWidgets('a search narrows the cards, by reading too; one finding '
'nothing offers to clear it', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'neko');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('cat'), findsOneWidget);
expect(find.text('to eat'), findsNothing);
await tester.enterText(find.byType(TextField), 'zebra');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('No flashcards match'), findsOneWidget);
await tester.tap(find.text('Clear search & filters'));
await tester.pumpAndSettle();
expect(find.text('to eat'), findsOneWidget);
});
testWidgets('the filter sheet narrows them to a kind', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Filter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Words only'));
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(find.text('Tokyo', findRichText: true), findsNothing);
expect(find.text('cat'), findsOneWidget);
});
testWidgets('cards chosen are deleted together, and Undo brings them '
'back', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Select'));
await tester.pumpAndSettle();
await tester.tap(find.text('to eat'));
await tester.tap(find.text('cat'));
await tester.pumpAndSettle();
expect(find.text('2 selected'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('2 flashcards deleted'), findsOneWidget);
expect(find.text('cat'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('cat'), findsOneWidget);
});
testWidgets('the detail view shows one card beside the list, the next a '
'tap away; it stays chosen on this device', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Detail'));
await tester.pumpAndSettle();
expect(find.byTooltip('Next'), findsOneWidget);
await tester.tap(find.byTooltip('Next'));
await tester.pumpAndSettle();
expect(find.text('Tokyo', findRichText: true), findsWidgets);
});
testWidgets('on a phone, what can be done to the chosen cards sits at '
"the screen's foot, and one card shows above a strip of them", (
tester,
) async {
await show(tester, width: 400);
await tester.tap(find.byTooltip('Select'));
await tester.pumpAndSettle();
expect(find.text('Move'), findsOneWidget);
expect(find.text('Add tags'), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Detail'));
await tester.pumpAndSettle();
expect(find.byType(PageView), findsOneWidget);
expect(find.byTooltip('Next'), findsNothing);
});
}