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

View file

@ -372,6 +372,9 @@
"cardUnbury": "Zurückstellung aufheben", "cardUnbury": "Zurückstellung aufheben",
"cardTileBuried": "Karteikarte bis morgen zurückgestellt", "cardTileBuried": "Karteikarte bis morgen zurückgestellt",
"cardTileUnburied": "Karteikarte nicht mehr zurückgestellt", "cardTileUnburied": "Karteikarte nicht mehr zurückgestellt",
"cardDeleteTitle": "Karteikarte löschen?",
"cardDeleteMessage": "Diese Karteikarte wird aus dem Lernset entfernt. Du kannst dies für einige Sekunden rückgängig machen.",
"cardDeleted": "Karteikarte gelöscht",
"cardFilterTooltip": "Filter", "cardFilterTooltip": "Filter",
"cardFilterTitle": "Filter", "cardFilterTitle": "Filter",
"cardFilterTag": "Tag", "cardFilterTag": "Tag",

View file

@ -413,6 +413,9 @@
"cardUnbury": "Unbury", "cardUnbury": "Unbury",
"cardTileBuried": "Flashcard buried until tomorrow", "cardTileBuried": "Flashcard buried until tomorrow",
"cardTileUnburied": "Flashcard unburied", "cardTileUnburied": "Flashcard unburied",
"cardDeleteTitle": "Delete flashcard?",
"cardDeleteMessage": "This flashcard will be removed from the studyset. You can undo this for a few seconds.",
"cardDeleted": "Flashcard deleted",
"cardFilterTooltip": "Filter", "cardFilterTooltip": "Filter",
"cardFilterTitle": "Filter", "cardFilterTitle": "Filter",
"cardFilterTag": "Tag", "cardFilterTag": "Tag",

View file

@ -2266,6 +2266,24 @@ abstract class AppLocalizations {
/// **'Flashcard unburied'** /// **'Flashcard unburied'**
String get cardTileUnburied; String get cardTileUnburied;
/// No description provided for @cardDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete flashcard?'**
String get cardDeleteTitle;
/// No description provided for @cardDeleteMessage.
///
/// In en, this message translates to:
/// **'This flashcard will be removed from the studyset. You can undo this for a few seconds.'**
String get cardDeleteMessage;
/// No description provided for @cardDeleted.
///
/// In en, this message translates to:
/// **'Flashcard deleted'**
String get cardDeleted;
/// No description provided for @cardFilterTooltip. /// No description provided for @cardFilterTooltip.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1320,6 +1320,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cardTileUnburied => 'Karteikarte nicht mehr zurückgestellt'; String get cardTileUnburied => 'Karteikarte nicht mehr zurückgestellt';
@override
String get cardDeleteTitle => 'Karteikarte löschen?';
@override
String get cardDeleteMessage =>
'Diese Karteikarte wird aus dem Lernset entfernt. Du kannst dies für einige Sekunden rückgängig machen.';
@override
String get cardDeleted => 'Karteikarte gelöscht';
@override @override
String get cardFilterTooltip => 'Filter'; String get cardFilterTooltip => 'Filter';

View file

@ -1311,6 +1311,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cardTileUnburied => 'Flashcard unburied'; String get cardTileUnburied => 'Flashcard unburied';
@override
String get cardDeleteTitle => 'Delete flashcard?';
@override
String get cardDeleteMessage =>
'This flashcard will be removed from the studyset. You can undo this for a few seconds.';
@override
String get cardDeleted => 'Flashcard deleted';
@override @override
String get cardFilterTooltip => 'Filter'; String get cardFilterTooltip => 'Filter';

View file

@ -106,6 +106,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
create: (context) => StudySessionBloc( create: (context) => StudySessionBloc(
person.studysets, person.studysets,
person.cards, person.cards,
person.cardDeletions,
person.reviews, person.reviews,
person.tags, person.tags,
person.settings, person.settings,

View file

@ -23,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
@ -255,6 +256,7 @@ Future<PersonDependencies> _openPerson(
pdfSources, pdfSources,
); );
final noteDeletions = NoteDeletions(notes); final noteDeletions = NoteDeletions(notes);
final cardDeletions = CardDeletions(cards);
final pathDeletions = LearningPathDeletions(paths); final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths); final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources); final pdfDeletions = PdfDeletions(pdfSources);
@ -273,6 +275,7 @@ Future<PersonDependencies> _openPerson(
assets: assets, assets: assets,
deletions: deletions, deletions: deletions,
noteDeletions: noteDeletions, noteDeletions: noteDeletions,
cardDeletions: cardDeletions,
pathDeletions: pathDeletions, pathDeletions: pathDeletions,
stepDeletions: stepDeletions, stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions, pdfDeletions: pdfDeletions,
@ -285,6 +288,7 @@ Future<PersonDependencies> _openPerson(
// finish while their repositories are open. // finish while their repositories are open.
await deletions.dispose(); await deletions.dispose();
await noteDeletions.dispose(); await noteDeletions.dispose();
await cardDeletions.dispose();
await pathDeletions.dispose(); await pathDeletions.dispose();
await stepDeletions.dispose(); await stepDeletions.dispose();
await pdfDeletions.dispose(); await pdfDeletions.dispose();

View file

@ -1,5 +1,6 @@
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart'; import 'package:halkyon_learn_flutter/src/core/sync/sync_service.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/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_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
@ -60,6 +61,9 @@ class PersonDependencies({
/// The note deletions that can still be undone. /// The note deletions that can still be undone.
required final NoteDeletions noteDeletions, required final NoteDeletions noteDeletions,
/// The card deletions that can still be undone.
required final CardDeletions cardDeletions,
/// The learning path deletions that can still be undone. /// The learning path deletions that can still be undone.
required final LearningPathDeletions pathDeletions, required final LearningPathDeletions pathDeletions,

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -17,14 +18,12 @@ class CardListBloc(
final CardRepository _cards, final CardRepository _cards,
final TagRepository _tags, final TagRepository _tags,
final ReviewRepository _reviews, final ReviewRepository _reviews,
final CardDeletions _deletions,
final String _studysetId, final String _studysetId,
) extends Bloc<CardListEvent, CardListState> { ) extends Bloc<CardListEvent, CardListState> {
this : super(const CardListStateLoading()) { this : super(const CardListStateLoading()) {
on<CardListEventStarted>(_onStarted); on<CardListEventStarted>(_onStarted);
on<CardListEventMoved>(_onMoved); on<CardListEventMoved>(_onMoved);
on<CardListEventDeleteRequested>(
(event, emit) => _cards.delete(event.card),
);
on<CardListEventReported>( on<CardListEventReported>(
(event, emit) => _cards.saveEdit( (event, emit) => _cards.saveEdit(
event.card.id, event.card.id,
@ -59,6 +58,9 @@ class CardListBloc(
List<Card>? _cardsNow; List<Card>? _cardsNow;
var _tagging = Tagging.empty; var _tagging = Tagging.empty;
var _buried = <String>{}; var _buried = <String>{};
/// The cards whose deletion can still be undone: they don't show.
var _deleted = <String>{};
String? _tagFilter; String? _tagFilter;
Future<void> _onStarted( Future<void> _onStarted(
@ -79,6 +81,15 @@ class CardListBloc(
_reviews.changes, _reviews.changes,
onData: (_) => add(const CardListEventBuriedRead()), onData: (_) => add(const CardListEventBuriedRead()),
), ),
emit.onEach(
_deletions.watchPending(),
onData: (pending) {
_deleted = pending;
if (_loaded() case final loaded?) {
emit(loaded);
}
},
),
emit.forEach( emit.forEach(
_tags.watch(), _tags.watch(),
onData: (tagging) { onData: (tagging) {
@ -99,7 +110,10 @@ class CardListBloc(
/// The list as it stands, or null while the cards are being read. /// The list as it stands, or null while the cards are being read.
CardListStateLoaded? _loaded() => switch (_cardsNow) { CardListStateLoaded? _loaded() => switch (_cardsNow) {
final cards? => CardListStateLoaded( final cards? => CardListStateLoaded(
cards, [
for (final card in cards)
if (!_deleted.contains(card.id)) card,
],
tagging: _tagging, tagging: _tagging,
tagFilter: _tagFilter, tagFilter: _tagFilter,
buried: _buried, buried: _buried,
@ -129,15 +143,6 @@ final class const CardListEventMoved(
List<Object?> get props => [card, toIndex]; List<Object?> get props => [card, toIndex];
} }
/// Delete [card]; the person has confirmed it.
final class const CardListEventDeleteRequested(
/// The card to delete.
final Card card,
) extends CardListEvent {
@override
List<Object?> get props => [card];
}
/// Show only the cards with the tag [tagId], or all when it is null. /// Show only the cards with the tag [tagId], or all when it is null.
final class const CardListEventTagFilterChanged( final class const CardListEventTagFilterChanged(
/// The tag's id. /// The tag's id.

View file

@ -0,0 +1,10 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
/// The person's card deletions, which can be undone for a moment (see
/// [DelayedDeletions]). Once the time to undo is over, the card's revisions
/// are blanked and it is deleted.
class CardDeletions(CardRepository cards, {super.undoWindow})
extends DelayedDeletions {
this : super(cards.deleteById, logName: 'Cards');
}

View file

@ -245,9 +245,12 @@ class CardRepository(final CardGateway _gateway) {
/// Deletes [card]. Its revisions are blanked first: the sync engine keeps /// Deletes [card]. Its revisions are blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2). /// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(Card card) async { Future<void> delete(Card card) => deleteById(card.id);
await _gateway.blankRevisions(card.id);
await _gateway.deleteCard(card.id); /// Deletes the card [id]: its revisions blanked first (ADR 0002, rule 2).
Future<void> deleteById(String id) async {
await _gateway.blankRevisions(id);
await _gateway.deleteCard(id);
_changes.add(null); _changes.add(null);
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
@ -40,9 +41,13 @@ class const CardList({
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocProvider( Widget build(BuildContext context) => BlocProvider(
create: (context) => create: (context) => CardListBloc(
CardListBloc(cards, tags, reviews, studysetId) cards,
..add(const CardListEventStarted()), tags,
reviews,
PersonScope.of(context).cardDeletions,
studysetId,
)..add(const CardListEventStarted()),
child: BlocBuilder<CardListBloc, CardListState>( child: BlocBuilder<CardListBloc, CardListState>(
builder: (context, state) => switch (state) { builder: (context, state) => switch (state) {
CardListStateLoading() => const SizedBox.shrink(), CardListStateLoading() => const SizedBox.shrink(),
@ -321,16 +326,24 @@ class const _Cards(
_bury(context, card, buried: buried.contains(card.id)), _bury(context, card, buried: buried.contains(card.id)),
onOpen: () => context.push(RoutePaths.card(studysetId, card.id)), onOpen: () => context.push(RoutePaths.card(studysetId, card.id)),
onDelete: () async { onDelete: () async {
final bloc = context.read<CardListBloc>();
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
if (await confirmDestructiveAction( final deletions = PersonScope.of(context).cardDeletions;
if (!await confirmDestructiveAction(
context, context,
title: l10n.composerDeleteTitle, title: l10n.cardDeleteTitle,
message: l10n.composerDeleteSavedMessage, message: l10n.cardDeleteMessage,
confirmLabel: l10n.composerDelete, confirmLabel: l10n.cardTileDelete,
)) { ) ||
bloc.add(CardListEventDeleteRequested(card)); !context.mounted) {
return;
} }
// The deletion outlives the tile.
deletions.delete(card.id);
showUndoSnackBar(
context,
message: l10n.cardDeleted,
onUndo: () => deletions.undo(card.id),
);
}, },
), ),
], ],

View file

@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -35,6 +36,7 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
class StudySessionBloc( class StudySessionBloc(
final StudysetRepository _studysets, final StudysetRepository _studysets,
final CardRepository _cards, final CardRepository _cards,
final CardDeletions _deletions,
final ReviewRepository _reviews, final ReviewRepository _reviews,
final TagRepository _tags, final TagRepository _tags,
final SettingsRepository _settings, final SettingsRepository _settings,
@ -67,6 +69,7 @@ class StudySessionBloc(
on<StudySessionEventCardEdited>(_onCardEdited); on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventReported>(_onReported); on<StudySessionEventReported>(_onReported);
on<StudySessionEventCardDeleted>(_onCardDeleted); on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventDeleteUndone>(_onDeleteUndone);
on<StudySessionEventBackFirstToggled>((event, emit) async { on<StudySessionEventBackFirstToggled>((event, emit) async {
// A session focused on a side turns for itself; the settings stay as // A session focused on a side turns for itself; the settings stay as
// they are. // they are.
@ -103,6 +106,9 @@ class StudySessionBloc(
/// The card set aside last in this round, and when, to undo it. /// The card set aside last in this round, and when, to undo it.
({Card card, DateTime at, int index})? _lastBuried; ({Card card, DateTime at, int index})? _lastBuried;
/// The card deleted last in this round, and where it was, to undo it.
({Card card, int index})? _lastDeleted;
final _ratings = <String, Rating>{}; final _ratings = <String, Rating>{};
final _graduated = <String>{}; final _graduated = <String>{};
var _cramIndex = 0; var _cramIndex = 0;
@ -159,6 +165,7 @@ class StudySessionBloc(
_graduated.clear(); _graduated.clear();
_undoable.clear(); _undoable.clear();
_lastBuried = null; _lastBuried = null;
_lastDeleted = null;
_cramIndex = 0; _cramIndex = 0;
final groups = await _groups(); final groups = await _groups();
_cardsById = { _cardsById = {
@ -245,7 +252,9 @@ class StudySessionBloc(
for (final studysetId in studied) { for (final studysetId in studied) {
final group = [ final group = [
for (final card in await _cards.cardsOf(studysetId)) for (final card in await _cards.cardsOf(studysetId))
if (tagged == null || tagged.contains(card.id)) card, if ((tagged == null || tagged.contains(card.id)) &&
!_deletions.pending.contains(card.id))
card,
]; ];
for (final card in group) { for (final card in group) {
_studysetOf[card.id] = studysetId; _studysetOf[card.id] = studysetId;
@ -554,8 +563,30 @@ class StudySessionBloc(
Emitter<StudySessionState> emit, Emitter<StudySessionState> emit,
) async { ) async {
if (state case final StudySessionStateShowing showing) { if (state case final StudySessionStateShowing showing) {
await _cards.delete(showing.card); final card = showing.card;
await _leave(emit, showing.card.id); // Deleted for good once the time to undo it is over.
_deletions.delete(card.id);
_lastDeleted = (card: card, index: _round.indexOf(card.id));
await _leave(emit, card.id);
}
}
void _onDeleteUndone(
StudySessionEventDeleteUndone event,
Emitter<StudySessionState> emit,
) {
final deleted = _lastDeleted;
_lastDeleted = null;
if (deleted == null) {
return;
}
final (:card, :index) = deleted;
_deletions.undo(card.id);
// Back where it was in the round under way; the card on screen stays.
if (state case final StudySessionStateShowing showing) {
_cardsById[card.id] = card;
_round.insert(index.clamp(0, _round.length), card.id);
_reshow(emit, showing);
} }
} }
@ -757,6 +788,9 @@ final class const StudySessionEventReported(
List<Object?> get props => [report]; List<Object?> get props => [report];
} }
/// Take back deleting the last card: it is studied again.
final class const StudySessionEventDeleteUndone() extends StudySessionEvent;
/// Delete the card on screen, and go on with the next. /// Delete the card on screen, and go on with the next.
final class const StudySessionEventCardDeleted() extends StudySessionEvent; final class const StudySessionEventCardDeleted() extends StudySessionEvent;

View file

@ -287,11 +287,21 @@ class const _Showing(
final bloc = context.read<StudySessionBloc>(); final bloc = context.read<StudySessionBloc>();
if (await confirmDestructiveAction( if (await confirmDestructiveAction(
context, context,
title: l10n.composerDeleteTitle, title: l10n.cardDeleteTitle,
message: l10n.composerDeleteSavedMessage, message: l10n.cardDeleteMessage,
confirmLabel: l10n.composerDelete, confirmLabel: l10n.cardTileDelete,
)) { ) &&
context.mounted) {
bloc.add(const StudySessionEventCardDeleted()); bloc.add(const StudySessionEventCardDeleted());
showUndoSnackBar(
context,
message: l10n.cardDeleted,
onUndo: () {
if (!bloc.isClosed) {
bloc.add(const StudySessionEventDeleteUndone());
}
},
);
} }
}, },
); );

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/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.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/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/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_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.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, assets: assets,
deletions: StudysetDeletions(studysets, cards, notes, paths, pdfSources), deletions: StudysetDeletions(studysets, cards, notes, paths, pdfSources),
noteDeletions: NoteDeletions(notes), noteDeletions: NoteDeletions(notes),
cardDeletions: CardDeletions(cards),
pathDeletions: LearningPathDeletions(paths), pathDeletions: LearningPathDeletions(paths),
stepDeletions: StepDeletions(paths), stepDeletions: StepDeletions(paths),
pdfDeletions: PdfDeletions(pdfSources), pdfDeletions: PdfDeletions(pdfSources),

View file

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

View file

@ -110,10 +110,8 @@ void main() {
expect(find.text('Changed on two devices'), findsOneWidget); expect(find.text('Changed on two devices'), findsOneWidget);
}); });
testWidgets("a card's options delete it, after asking", (tester) async { /// Deletes the card shown through its options, saying yes.
final id = await cards.create('japanese', card('猫', 'cat')); Future<void> delete(WidgetTester tester) async {
await showList(tester);
await tester.tap( await tester.tap(
find.descendant( find.descendant(
of: find.byType(CardTile), of: find.byType(CardTile),
@ -126,11 +124,38 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Delete flashcard?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle(); 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(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 " 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:clock/clock.dart';
import 'package:flutter_test/flutter_test.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/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/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
@ -35,6 +36,7 @@ void main() {
late FakeStudysetGateway studysetGateway; late FakeStudysetGateway studysetGateway;
late StudysetRepository studysets; late StudysetRepository studysets;
late CardRepository cards; late CardRepository cards;
late CardDeletions deletions;
late FakeReviewGateway reviewGateway; late FakeReviewGateway reviewGateway;
late ReviewRepository reviews; late ReviewRepository reviews;
late TagRepository tags; late TagRepository tags;
@ -49,6 +51,7 @@ void main() {
); );
studysets = StudysetRepository(studysetGateway); studysets = StudysetRepository(studysetGateway);
cards = CardRepository(FakeCardGateway()); cards = CardRepository(FakeCardGateway());
deletions = CardDeletions(cards, undoWindow: Duration.zero);
reviewGateway = FakeReviewGateway(); reviewGateway = FakeReviewGateway();
reviews = ReviewRepository(reviewGateway); reviews = ReviewRepository(reviewGateway);
tags = TagRepository(FakeTagGateway()); tags = TagRepository(FakeTagGateway());
@ -56,6 +59,7 @@ void main() {
now = DateTime.utc(2026, 10, 5, 9); now = DateTime.utc(2026, 10, 5, 9);
}); });
tearDown(() async { tearDown(() async {
await deletions.dispose();
await studysets.dispose(); await studysets.dispose();
await cards.dispose(); await cards.dispose();
await tags.dispose(); await tags.dispose();
@ -66,6 +70,7 @@ void main() {
StudySessionBloc session() => StudySessionBloc( StudySessionBloc session() => StudySessionBloc(
studysets, studysets,
cards, cards,
deletions,
reviews, reviews,
tags, tags,
settings, settings,
@ -188,6 +193,8 @@ void main() {
bloc.add(const StudySessionEventCardDeleted()); bloc.add(const StudySessionEventCardDeleted());
expect((await showing(bloc, '犬')).pills, hasLength(1)); 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( expect(
(await cards.cardsOf('japanese')).map((each) => each.content.front), (await cards.cardsOf('japanese')).map((each) => each.content.front),
@ -794,6 +801,7 @@ void main() {
StudySessionBloc focused(StudyFocus focus) => StudySessionBloc( StudySessionBloc focused(StudyFocus focus) => StudySessionBloc(
studysets, studysets,
cards, cards,
deletions,
reviews, reviews,
tags, tags,
settings, settings,

View file

@ -253,7 +253,11 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Flashcard deleted'), findsOneWidget);
expect(find.textContaining('犬', findRichText: true), 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)); 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/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_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.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_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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'; import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
@ -170,6 +171,7 @@ Dependencies testDependencies({
pdfSources, pdfSources,
); );
final noteDeletions = NoteDeletions(notes); final noteDeletions = NoteDeletions(notes);
final cardDeletions = CardDeletions(cards);
final pathDeletions = LearningPathDeletions(paths); final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths); final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources); final pdfDeletions = PdfDeletions(pdfSources);
@ -187,6 +189,7 @@ Dependencies testDependencies({
assets: assets, assets: assets,
deletions: deletions, deletions: deletions,
noteDeletions: noteDeletions, noteDeletions: noteDeletions,
cardDeletions: cardDeletions,
pathDeletions: pathDeletions, pathDeletions: pathDeletions,
stepDeletions: stepDeletions, stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions, pdfDeletions: pdfDeletions,
@ -194,6 +197,7 @@ Dependencies testDependencies({
onDispose: () async { onDispose: () async {
await deletions.dispose(); await deletions.dispose();
await noteDeletions.dispose(); await noteDeletions.dispose();
await cardDeletions.dispose();
await pathDeletions.dispose(); await pathDeletions.dispose();
await stepDeletions.dispose(); await stepDeletions.dispose();
await pdfDeletions.dispose(); await pdfDeletions.dispose();