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
6 changed files with 219 additions and 44 deletions

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
@ -62,6 +63,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
assets: assets,
deletions: StudysetDeletions(studysets, cards, notes, paths, pdfSources),
noteDeletions: NoteDeletions(notes),
cardDeletions: CardDeletions(cards),
pathDeletions: LearningPathDeletions(paths),
stepDeletions: StepDeletions(paths),
pdfDeletions: PdfDeletions(pdfSources),

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 "

View file

@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:clock/clock.dart';
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/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/settings/data/settings_repository.dart';
@ -35,6 +36,7 @@ void main() {
late FakeStudysetGateway studysetGateway;
late StudysetRepository studysets;
late CardRepository cards;
late CardDeletions deletions;
late FakeReviewGateway reviewGateway;
late ReviewRepository reviews;
late TagRepository tags;
@ -49,6 +51,7 @@ void main() {
);
studysets = StudysetRepository(studysetGateway);
cards = CardRepository(FakeCardGateway());
deletions = CardDeletions(cards, undoWindow: Duration.zero);
reviewGateway = FakeReviewGateway();
reviews = ReviewRepository(reviewGateway);
tags = TagRepository(FakeTagGateway());
@ -56,6 +59,7 @@ void main() {
now = DateTime.utc(2026, 10, 5, 9);
});
tearDown(() async {
await deletions.dispose();
await studysets.dispose();
await cards.dispose();
await tags.dispose();
@ -66,6 +70,7 @@ void main() {
StudySessionBloc session() => StudySessionBloc(
studysets,
cards,
deletions,
reviews,
tags,
settings,
@ -188,6 +193,8 @@ void main() {
bloc.add(const StudySessionEventCardDeleted());
expect((await showing(bloc, '犬')).pills, hasLength(1));
});
// Deleted for good once the time to undo it is over.
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(
(await cards.cardsOf('japanese')).map((each) => each.content.front),
@ -794,6 +801,7 @@ void main() {
StudySessionBloc focused(StudyFocus focus) => StudySessionBloc(
studysets,
cards,
deletions,
reviews,
tags,
settings,

View file

@ -253,7 +253,11 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Flashcard deleted'), findsOneWidget);
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
// For good once the time to undo it is over.
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(await cards.cardsOf('japanese'), hasLength(1));
});

View file

@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
@ -170,6 +171,7 @@ Dependencies testDependencies({
pdfSources,
);
final noteDeletions = NoteDeletions(notes);
final cardDeletions = CardDeletions(cards);
final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources);
@ -187,6 +189,7 @@ Dependencies testDependencies({
assets: assets,
deletions: deletions,
noteDeletions: noteDeletions,
cardDeletions: cardDeletions,
pathDeletions: pathDeletions,
stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions,
@ -194,6 +197,7 @@ Dependencies testDependencies({
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();
await cardDeletions.dispose();
await pathDeletions.dispose();
await stepDeletions.dispose();
await pdfDeletions.dispose();