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
7 changed files with 206 additions and 44 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue