feat(app): deleting a studyset can be undone for a moment
Some checks failed
CI / check (push) Failing after 24s
CI / deploy (push) Has been skipped

Decided 2026-10-01. Deleting still asks first; once confirmed, the studyset
leaves the library at once and "Studyset deleted" offers Undo for three
seconds. Only when the time to undo is over (four seconds: the message's
three and a moment for a late tap) are its cards and then the studyset
blanked and deleted, since blanking can't be undone (ADR 0002, rule 2). A
deletion still waiting when the library closes (the app quits, the person
changes) is dropped, and the studyset stays.

The library's bloc keeps the waiting deletions; the event that ends the
time to undo is private, so no screen can skip it. AGENTS.md now describes
the library's shared widgets, the sheets (and why a sheet that asks before
discarding can't be swiped away), the order of studysets, and this Undo.

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 08:55:39 +02:00
commit 41f536bbe2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 206 additions and 44 deletions

View file

@ -25,8 +25,9 @@
"librarySearchNoMatches": "No studysets match your search",
"libraryStudysetArchived": "Studyset archived",
"libraryStudysetUnarchived": "Studyset unarchived",
"libraryStudysetDeleted": "Studyset deleted",
"libraryDeleteTitle": "Delete studyset?",
"libraryDeleteMessage": "It is deleted with its cards, on all your devices.",
"libraryDeleteMessage": "It is deleted with its cards, on all your devices. You can undo this for a few seconds.",
"libraryDeleteConfirm": "Delete",
"studysetSheetCreateTitle": "Create studyset",

View file

@ -214,6 +214,12 @@ abstract class AppLocalizations {
/// **'Studyset unarchived'**
String get libraryStudysetUnarchived;
/// No description provided for @libraryStudysetDeleted.
///
/// In en, this message translates to:
/// **'Studyset deleted'**
String get libraryStudysetDeleted;
/// No description provided for @libraryDeleteTitle.
///
/// In en, this message translates to:
@ -223,7 +229,7 @@ abstract class AppLocalizations {
/// No description provided for @libraryDeleteMessage.
///
/// In en, this message translates to:
/// **'It is deleted with its cards, on all your devices.'**
/// **'It is deleted with its cards, on all your devices. You can undo this for a few seconds.'**
String get libraryDeleteMessage;
/// No description provided for @libraryDeleteConfirm.

View file

@ -70,12 +70,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryStudysetUnarchived => 'Studyset unarchived';
@override
String get libraryStudysetDeleted => 'Studyset deleted';
@override
String get libraryDeleteTitle => 'Delete studyset?';
@override
String get libraryDeleteMessage =>
'It is deleted with its cards, on all your devices.';
'It is deleted with its cards, on all your devices. You can undo this for a few seconds.';
@override
String get libraryDeleteConfirm => 'Delete';

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
@ -69,7 +70,8 @@ final class const StudysetListEventUnarchiveRequested(
List<Object?> get props => [id];
}
/// Delete the studyset [id] with its cards.
/// Delete the studyset [id] with its cards, once the time to undo that is
/// over.
final class const StudysetListEventDeleteRequested(
/// The studyset's id.
final String id,
@ -78,6 +80,25 @@ final class const StudysetListEventDeleteRequested(
List<Object?> get props => [id];
}
/// Keep the studyset [id] after all, while its deletion still waits.
final class const StudysetListEventDeleteUndone(
/// The studyset's id.
final String id,
) extends StudysetListEvent {
@override
List<Object?> get props => [id];
}
/// The time to undo deleting the studyset [id] is over.
// Private, unlike the other events: only the bloc's own timer sends it, so a
// screen can't skip the time to undo.
// ignore: leancode_lint/bloc_subclasses_naming
final class const _StudysetListEventDeletionDue(final String id)
extends StudysetListEvent {
@override
List<Object?> get props => [id];
}
/// Work out the studysets' progress again, as after a study session.
final class const StudysetListEventProgressRefreshRequested()
extends StudysetListEvent;
@ -114,11 +135,21 @@ final class const StudysetListStateLoaded(
/// The library: the person's studysets with the progress of their cards,
/// following every change, local or synced; creates, edits, stars, archives
/// and deletes studysets.
///
/// Deleting can be undone for a moment (decided 2026-10-01): the studyset
/// leaves the list at once, and its content is blanked and deleted only once
/// [_undoWindow] is over, since blanking can't be undone (ADR 0002, rule 2).
/// A deletion still waiting when the library closes is dropped, and the
/// studyset stays.
class StudysetListBloc(
final StudysetRepository _studysets,
final CardRepository _cards,
final ReviewRepository _reviews,
) extends Bloc<StudysetListEvent, StudysetListState> {
final ReviewRepository _reviews, {
/// How long a deletion can be undone: the three seconds the message
/// offering it shows, and a moment for a late tap.
final Duration _undoWindow = const Duration(seconds: 4),
}) extends Bloc<StudysetListEvent, StudysetListState> {
this : super(const StudysetListStateLoading()) {
on<StudysetListEventStarted>(_onStarted);
on<StudysetListEventCreateRequested>(
@ -139,6 +170,8 @@ class StudysetListBloc(
(event, emit) => _studysets.unarchive(event.id),
);
on<StudysetListEventDeleteRequested>(_onDeleteRequested);
on<StudysetListEventDeleteUndone>(_onDeleteUndone);
on<_StudysetListEventDeletionDue>(_onDeletionDue);
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
}
@ -146,6 +179,35 @@ class StudysetListBloc(
/// out never overwrites a newer one.
var _progressRequests = 0;
/// The person's studysets as last read.
var _studysetsRead = const <Studyset>[];
/// The deletions waiting for the time to undo them to be over, by the
/// studyset's id.
final _pendingDeletions = <String, Timer>{};
@override
Future<void> close() {
for (final timer in _pendingDeletions.values) {
timer.cancel();
}
_pendingDeletions.clear();
return super.close();
}
/// The studysets the library shows: in its order, without those whose
/// deletion waits.
List<Studyset> get _shown => [
for (final studyset in _inLibraryOrder(_studysetsRead))
if (!_pendingDeletions.containsKey(studyset.id)) studyset,
];
/// The last progress worked out.
Map<String, StudyProgress> get _progress => switch (state) {
StudysetListStateLoaded(:final progress) => progress,
_ => const {},
};
Future<void> _onStarted(
StudysetListEventStarted event,
Emitter<StudysetListState> emit,
@ -153,15 +215,10 @@ class StudysetListBloc(
emit.forEach(
_studysets.watchAll(),
onData: (studysets) {
_studysetsRead = studysets;
add(const StudysetListEventProgressRefreshRequested());
return StudysetListStateLoaded(
_inLibraryOrder(studysets),
// Until the new progress is worked out, the last one stands.
progress: switch (state) {
StudysetListStateLoaded(:final progress) => progress,
_ => const {},
},
);
// Until the new progress is worked out, the last one stands.
return StudysetListStateLoaded(_shown, progress: _progress);
},
onError: (_, _) => const StudysetListStateFailed(),
),
@ -172,10 +229,37 @@ class StudysetListBloc(
),
]);
Future<void> _onDeleteRequested(
void _onDeleteRequested(
StudysetListEventDeleteRequested event,
Emitter<StudysetListState> emit,
) {
_pendingDeletions[event.id]?.cancel();
_pendingDeletions[event.id] = Timer(
_undoWindow,
() => add(_StudysetListEventDeletionDue(event.id)),
);
if (state is StudysetListStateLoaded) {
emit(StudysetListStateLoaded(_shown, progress: _progress));
}
}
void _onDeleteUndone(
StudysetListEventDeleteUndone event,
Emitter<StudysetListState> emit,
) {
_pendingDeletions.remove(event.id)?.cancel();
if (state is StudysetListStateLoaded) {
emit(StudysetListStateLoaded(_shown, progress: _progress));
}
}
Future<void> _onDeletionDue(
_StudysetListEventDeletionDue event,
Emitter<StudysetListState> emit,
) async {
if (_pendingDeletions.remove(event.id) == null) {
return;
}
// Its cards first, each blanked, so no card content outlives the
// studyset (ADR 0002, rule 2).
await _cards.deleteAllOf(event.id);

View file

@ -311,14 +311,22 @@ class _GridState() extends State<_Grid> {
bloc.add(StudysetListEventArchiveRequested(studyset.id)),
);
},
// Asks, then offers to undo for a moment before the deletion happens.
onDelete: () async {
if (await confirmDestructiveAction(
context,
title: l10n.libraryDeleteTitle,
message: l10n.libraryDeleteMessage,
confirmLabel: l10n.libraryDeleteConfirm,
)) {
context,
title: l10n.libraryDeleteTitle,
message: l10n.libraryDeleteMessage,
confirmLabel: l10n.libraryDeleteConfirm,
) &&
mounted) {
bloc.add(StudysetListEventDeleteRequested(studyset.id));
// The grid's context, not the card's: the card is about to go.
showUndoSnackBar(
this.context,
message: l10n.libraryStudysetDeleted,
onUndo: () => bloc.add(StudysetListEventDeleteUndone(studyset.id)),
);
}
},
);

View file

@ -25,11 +25,6 @@ void main() {
studysets = StudysetRepository(gateway);
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
bloc = StudysetListBloc(
studysets,
cards,
ReviewRepository(FakeReviewGateway()),
);
});
tearDown(() async {
await bloc.close();
@ -37,6 +32,16 @@ void main() {
await cards.dispose();
});
/// Opens the library, where a deletion can be undone for [undoWindow].
void start({Duration undoWindow = const Duration(milliseconds: 1)}) {
bloc = StudysetListBloc(
studysets,
cards,
ReviewRepository(FakeReviewGateway()),
undoWindow: undoWindow,
)..add(const StudysetListEventStarted());
}
/// The next state that lists [titles], each studyset with its progress.
Future<StudysetListStateLoaded> listing(List<String> titles) async {
bool matches(StudysetListState state) =>
@ -62,7 +67,7 @@ void main() {
final german = await studysets.create(StudysetContent.titled('German'));
await cards.create(japanese, newCard());
bloc.add(const StudysetListEventStarted());
start();
// German changed last: it was created after Japanese.
final state = await listing(['German', 'Japanese']);
@ -77,7 +82,7 @@ void main() {
final german = await studysets.create(StudysetContent.titled('German'));
await studysets.star(german);
bloc.add(const StudysetListEventStarted());
start();
await listing(['German', 'Japanese']);
});
@ -108,7 +113,7 @@ void main() {
updatedAt: at(9),
);
bloc.add(const StudysetListEventStarted());
start();
await listing(['Starred', 'Later', 'Earlier', 'Old']);
},
@ -122,13 +127,13 @@ void main() {
parentId: 'studyset-1',
);
bloc.add(const StudysetListEventStarted());
start();
await listing(['Japanese']);
});
test('follows what is created, and the progress as cards change', () async {
bloc.add(const StudysetListEventStarted());
start();
await listing([]);
bloc.add(
@ -148,7 +153,7 @@ void main() {
test("changes a studyset's content", () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted());
start();
await listing(['Japanese']);
bloc.add(
@ -160,7 +165,7 @@ void main() {
test('says so when the studysets cannot be read, then recovers', () async {
gateway.failReads = Exception('Disk unavailable');
bloc.add(const StudysetListEventStarted());
start();
await expectLater(bloc.stream, emits(const StudysetListStateFailed()));
gateway.failReads = null;
@ -171,7 +176,7 @@ void main() {
test('stars a studyset and takes the star away', () async {
await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted());
start();
final studyset = (await listing(['Japanese'])).studysets.single;
bloc.add(StudysetListEventStarToggled(studyset));
@ -193,7 +198,7 @@ void main() {
test('archives a studyset and takes it out again', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted());
start();
await listing(['Japanese']);
bloc.add(StudysetListEventArchiveRequested(id));
@ -210,16 +215,48 @@ void main() {
);
});
test('deletes a studyset with its cards, each blanked first', () async {
test('deleting hides a studyset at once, and deletes it with its cards, '
'each blanked first, once the time to undo is over', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
final card = await cards.create(id, newCard());
bloc.add(const StudysetListEventStarted());
start(undoWindow: const Duration(milliseconds: 50));
await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id));
await listing([]);
expect(gateway.deleted, isEmpty);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
expect(gateway.deleted, [id]);
await listing([]);
});
test('undoing in time keeps the studyset', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
start(undoWindow: const Duration(hours: 1));
await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id));
await listing([]);
bloc.add(StudysetListEventDeleteUndone(id));
await listing(['Japanese']);
expect(gateway.deleted, isEmpty);
});
test('a deletion still waiting when the library closes is dropped, and '
'the studyset stays', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
start(undoWindow: const Duration(milliseconds: 50));
await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id));
await listing([]);
await bloc.close();
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(gateway.deleted, isEmpty);
expect(await studysets.all(), hasLength(1));
});
}

View file

@ -336,9 +336,8 @@ void main() {
expect(gateway.deleted, isEmpty);
});
testWidgets('a confirmed delete removes the studyset with its cards', (
tester,
) async {
testWidgets('a confirmed delete hides the studyset, offers to undo, and '
'then deletes it with its cards', (tester) async {
stored('japanese', 'Japanese');
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
@ -351,12 +350,35 @@ void main() {
await choose(tester, 'Japanese', 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Studyset deleted'), findsOneWidget);
expect(find.text('No studysets yet'), findsOneWidget);
expect(gateway.deleted, isEmpty);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(gateway.deleted, ['japanese']);
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
});
testWidgets('Undo brings a deleted studyset back, and nothing is deleted', (
tester,
) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
expect(gateway.deleted, isEmpty);
});
testWidgets('a card invites studying its due cards, and counts anew after '
'studying', (tester) async {
stored('japanese', 'Japanese');