perf(learn): Delete now leaves Recently deleted at once
Deleting a studyset for good blanks and deletes every card, note, path and PDF of its subtree before the studyset's row goes, which took about 3 s while the studyset stayed in Recently deleted. Now the repository's Recently deleted leaves it out at once (StudysetRepository.leaving and stay, applied to the value last read without reading again), the deletion runs in the background, still one at a time in StudysetDeletions' queue, and where it fails the studyset shows there again, read anew with what is left of it, and a snack bar says so. Whats-New: Delete now in Recently deleted takes a studyset away at once. Whats-New-De: „Jetzt löschen“ unter „Zuletzt gelöscht“ entfernt ein Lernset sofort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c954e844f7
commit
920e1c6c13
11 changed files with 190 additions and 7 deletions
|
|
@ -36,6 +36,8 @@
|
|||
"libraryDeleteForGoodMessage": "„{title}“ wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"@libraryDeleteForGoodMessage": {"placeholders": {"title": {"type": "String"}}},
|
||||
"libraryDeleteForGoodConfirm": "Endgültig löschen",
|
||||
"libraryDeleteForGoodFailed": "„{title}“ ließ sich nicht löschen. Es ist wieder unter „Zuletzt gelöscht“.",
|
||||
"@libraryDeleteForGoodFailed": {"placeholders": {"title": {"type": "String"}}},
|
||||
|
||||
"studysetSheetCreateTitle": "Lernset erstellen",
|
||||
"studysetSheetEditTitle": "Lernset bearbeiten",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@
|
|||
}
|
||||
},
|
||||
"libraryDeleteForGoodConfirm": "Delete for good",
|
||||
"libraryDeleteForGoodFailed": "“{title}” couldn't be deleted. It's back in Recently deleted.",
|
||||
"@libraryDeleteForGoodFailed": {
|
||||
"description": "A snack bar when deleting a studyset for good, which left Recently deleted at once, failed and it shows there again.",
|
||||
"placeholders": {
|
||||
"title": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"studysetSheetCreateTitle": "Create studyset",
|
||||
"studysetSheetEditTitle": "Edit studyset",
|
||||
|
|
|
|||
|
|
@ -280,6 +280,12 @@ abstract class AppLocalizations {
|
|||
/// **'Delete for good'**
|
||||
String get libraryDeleteForGoodConfirm;
|
||||
|
||||
/// A snack bar when deleting a studyset for good, which left Recently deleted at once, failed and it shows there again.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'“{title}” couldn\'t be deleted. It\'s back in Recently deleted.'**
|
||||
String libraryDeleteForGoodFailed(String title);
|
||||
|
||||
/// No description provided for @studysetSheetCreateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -115,6 +115,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Endgültig löschen';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodFailed(String title) {
|
||||
return '„$title“ ließ sich nicht löschen. Es ist wieder unter „Zuletzt gelöscht“.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Lernset erstellen';
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Delete for good';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodFailed(String title) {
|
||||
return '“$title” couldn\'t be deleted. It\'s back in Recently deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Create studyset';
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,20 @@ class StudysetDeletions(
|
|||
/// Brings the studyset [id] back from Recently deleted.
|
||||
Future<void> restore(String id) => _studysets.restore(id);
|
||||
|
||||
/// Deletes the studyset [id] for good, with everything inside it.
|
||||
Future<void> deleteForGood(String id) => _queue.run(() => _deleteSubtree(id));
|
||||
/// Deletes the studyset [id] for good, with everything inside it, which
|
||||
/// takes a moment: Recently deleted leaves it out at once, and shows it
|
||||
/// again where deleting it fails, with what is left of it; then this
|
||||
/// throws as the deletion did.
|
||||
Future<void> deleteForGood(String id) async {
|
||||
_studysets.leaving(id);
|
||||
try {
|
||||
await _queue.run(() => _deleteSubtree(id));
|
||||
} finally {
|
||||
// Read again before it may show: gone, or as much of it as is left.
|
||||
await _studysets.reread();
|
||||
_studysets.stay(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes for good the studysets whose time in Recently deleted is over,
|
||||
/// now and whenever [checks] fires (a sync that went through, which may have
|
||||
|
|
|
|||
|
|
@ -42,9 +42,41 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
Stream<List<Studyset>> watchAll() => _studysets.stream.map(notDeleted);
|
||||
|
||||
/// The studysets waiting in Recently deleted ([recentlyDeleted]), now and
|
||||
/// after every change.
|
||||
Stream<List<Studyset>> watchDeleted() =>
|
||||
_studysets.stream.map(recentlyDeleted);
|
||||
/// after every change, but those being deleted for good ([leaving]).
|
||||
Stream<List<Studyset>> watchDeleted() => _studysets.stream.map(
|
||||
(all) => [
|
||||
for (final studyset in recentlyDeleted(all))
|
||||
if (!_leaving.contains(studyset.id)) studyset,
|
||||
],
|
||||
);
|
||||
|
||||
/// The studysets being deleted for good right now, which Recently deleted
|
||||
/// leaves out at once ([leaving]).
|
||||
final _leaving = <String>{};
|
||||
|
||||
/// Leaves the studyset [id] out of Recently deleted at once, while it is
|
||||
/// deleted for good, which takes a moment, until [stay] (optimistic
|
||||
/// state, decided 2026-11-07).
|
||||
void leaving(String id) {
|
||||
if (_leaving.add(id)) {
|
||||
_showAgain();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the studyset [id] in Recently deleted again where it is still
|
||||
/// there: deleting it for good is over, done or failed.
|
||||
void stay(String id) {
|
||||
if (_leaving.remove(id)) {
|
||||
_showAgain();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the studysets last read on again, for [_leaving] to apply.
|
||||
void _showAgain() {
|
||||
if (_studysets.value case final studysets? when !_studysets.isClosed) {
|
||||
_studysets.add(studysets);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the studysets again for those following them ([watchAll]), as
|
||||
/// after a change: to try once more after a read failed.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
|
|
@ -34,12 +35,15 @@ void deleteStudysetWithUndo(
|
|||
}
|
||||
|
||||
/// Asks before deleting [studyset], waiting in Recently deleted, for good
|
||||
/// with everything inside it, which can't be undone; then does.
|
||||
/// with everything inside it, which can't be undone; then does, leaving
|
||||
/// Recently deleted at once while that runs in the background, and saying
|
||||
/// so where it fails, the studyset shown there again.
|
||||
Future<void> deleteStudysetForGood(
|
||||
BuildContext context,
|
||||
Studyset studyset,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final deletions = PersonDependencies.of(context).deletions;
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
|
|
@ -47,6 +51,14 @@ Future<void> deleteStudysetForGood(
|
|||
message: l10n.libraryDeleteForGoodMessage(studyset.content.title),
|
||||
confirmLabel: l10n.libraryDeleteForGoodConfirm,
|
||||
)) {
|
||||
await deletions.deleteForGood(studyset.id);
|
||||
final failed = l10n.libraryDeleteForGoodFailed(studyset.content.title);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await deletions.deleteForGood(studyset.id);
|
||||
} on Object catch (error) {
|
||||
log('Deleting ${studyset.id} for good failed', error: error);
|
||||
messenger.showSnackBar(SnackBar(content: Text(failed)));
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,59 @@ void main() {
|
|||
expect([for (final s in await studysets.withDeleted()) s.id], [greek]);
|
||||
});
|
||||
|
||||
test('Delete now takes the studyset off Recently deleted at once, while '
|
||||
'it is deleted in the background', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
await card(japanese);
|
||||
await deleteAt(japanese);
|
||||
final shown = <List<String>>[];
|
||||
final following = studysets.watchDeleted().listen(
|
||||
(deleted) => shown.add([for (final s in deleted) s.id]),
|
||||
);
|
||||
addTearDown(following.cancel);
|
||||
await eventually(() => shown.isNotEmpty && shown.last.contains(japanese));
|
||||
final hold = cardGateway.holdDeletes = Completer<void>();
|
||||
|
||||
final deleting = deletions.deleteForGood(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(shown.last, isEmpty, reason: 'at once');
|
||||
expect(studysetGateway.deleted, isEmpty, reason: 'not done yet');
|
||||
|
||||
hold.complete();
|
||||
await deleting;
|
||||
await pumpEventQueue();
|
||||
expect(studysetGateway.deleted, [japanese]);
|
||||
expect(shown.last, isEmpty);
|
||||
});
|
||||
|
||||
test('a studyset Delete now fails on shows in Recently deleted again, and '
|
||||
'the failure reaches the caller', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
await card(japanese);
|
||||
await deleteAt(japanese);
|
||||
final shown = <List<String>>[];
|
||||
final following = studysets.watchDeleted().listen(
|
||||
(deleted) => shown.add([for (final s in deleted) s.id]),
|
||||
);
|
||||
addTearDown(following.cancel);
|
||||
await eventually(() => shown.isNotEmpty && shown.last.contains(japanese));
|
||||
cardGateway.failDeletes = Exception('The disk is full');
|
||||
|
||||
await expectLater(deletions.deleteForGood(japanese), throwsException);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
shown,
|
||||
containsAllInOrder([
|
||||
<String>[],
|
||||
[japanese],
|
||||
]),
|
||||
);
|
||||
expect(shown.last, [japanese]);
|
||||
});
|
||||
|
||||
test('a studyset deleted inside one deleted later goes on its own '
|
||||
'clock, and with the other at the latest', () async {
|
||||
open();
|
||||
|
|
|
|||
|
|
@ -792,6 +792,45 @@ void main() {
|
|||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Delete now takes the studyset off Recently deleted at once; '
|
||||
'where deleting it fails, it comes back and a snack bar says so', (
|
||||
tester,
|
||||
) async {
|
||||
gateway.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
deletedAt: clock.now().toUtc(),
|
||||
);
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
final hold = cardGateway.holdDeletes = Completer<void>();
|
||||
cardGateway.failDeletes = Exception('The disk is full');
|
||||
|
||||
await tester.tap(onDeletedCard('Japanese', find.byTooltip('Options')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete now'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete for good'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recently deleted'), findsNothing, reason: 'at once');
|
||||
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recently deleted'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
"“Japanese” couldn't be deleted. It's back in Recently deleted.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a card invites studying its due cards, and counts anew after '
|
||||
'studying', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ class FakeCardGateway() implements CardGateway {
|
|||
/// When set, storing a card or a revision fails with it.
|
||||
Exception? failWrites;
|
||||
|
||||
/// When set, deleting a card fails with it, before anything is blanked.
|
||||
Exception? failDeletes;
|
||||
|
||||
/// When set, deleting a card waits until it completes.
|
||||
Completer<void>? holdDeletes;
|
||||
|
||||
/// The payload each revision holds now, by revision id.
|
||||
Map<String, ByteData> get payloads => {
|
||||
for (final revision in _revisions.values) revision.id: revision.payload,
|
||||
|
|
@ -159,6 +165,10 @@ class FakeCardGateway() implements CardGateway {
|
|||
|
||||
@override
|
||||
Future<void> blankRevisions(String cardId) async {
|
||||
await holdDeletes?.future;
|
||||
if (failDeletes case final error?) {
|
||||
throw error;
|
||||
}
|
||||
deletions.add('blank $cardId');
|
||||
for (final revision in [..._revisions.values]) {
|
||||
if (revision.cardId == cardId) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue