feat(app): undo deleting a card

Deleting a card, from the list or while studying, asks first ("Delete
flashcard?"), then offers Undo for a few seconds ("Flashcard deleted").
Meanwhile the card leaves every list and session; once the time to
undo is over, its revisions are blanked and it is deleted for good, as
notes and studysets already are (CardDeletions, a DelayedDeletions).
Undo while studying puts the card back into the round where it was.

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-07 21:52:03 +02:00
commit 2b188641c3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 219 additions and 44 deletions

View file

@ -1,6 +1,7 @@
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_list_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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -22,14 +23,17 @@ void main() {
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late CardDeletions deletions;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards, undoWindow: Duration.zero);
});
tearDown(() async {
await deletions.dispose();
await reviews.dispose();
await tags.dispose();
await cards.dispose();
@ -40,7 +44,7 @@ void main() {
'a card reported or buried shows so, and either is taken back',
() async {
final id = await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
Future<CardListStateLoaded> next(
@ -70,7 +74,7 @@ void main() {
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
@ -100,7 +104,7 @@ void main() {
test('moves a card', () async {
await cards.create('japanese', card('a', ''));
await cards.create('japanese', card('b', ''));
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
final bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere(
@ -137,7 +141,7 @@ void main() {
await tags.setTagsOf(ids[0], {noun});
await tags.setTagsOf(ids[1], {noun});
await tags.setTagsOf(ids[2], {verb});
bloc = CardListBloc(cards, tags, reviews, 'japanese')
bloc = CardListBloc(cards, tags, reviews, deletions, 'japanese')
..add(const CardListEventStarted());
});
tearDown(() => bloc.close());

View file

@ -110,10 +110,8 @@ void main() {
expect(find.text('Changed on two devices'), findsOneWidget);
});
testWidgets("a card's options delete it, after asking", (tester) async {
final id = await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
/// Deletes the card shown through its options, saying yes.
Future<void> delete(WidgetTester tester) async {
await tester.tap(
find.descendant(
of: find.byType(CardTile),
@ -126,11 +124,38 @@ void main() {
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Delete flashcard?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
}
expect(gateway.deletions, ['blank $id', 'delete $id']);
testWidgets("a card's options delete it, after asking; once the time to "
'undo is over, for good', (tester) async {
final id = await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
await delete(tester);
expect(find.text('Flashcard deleted'), findsOneWidget);
expect(find.text('No flashcards yet'), findsOneWidget);
expect(gateway.deletions, isEmpty);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(gateway.deletions, ['blank $id', 'delete $id']);
});
testWidgets('Undo keeps a card deleted a moment ago', (tester) async {
await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
await delete(tester);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('猫'), findsOneWidget);
expect(gateway.deletions, isEmpty);
});
testWidgets("a card's options report it and bury it, each shown on the "