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
2 changed files with 206 additions and 44 deletions

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');