feat(app): deleting a studyset can be undone for a moment
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:
parent
85c028fe3c
commit
41f536bbe2
1 changed files with 206 additions and 44 deletions
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue