refactor(app): delayed deletions move into halkyon_app (ADR 0030, D24)

Learn's DelayedDeletions, unchanged, with its test, so that Train's
workouts can be deleted with an undo as Learn's items are. Learn's
deletions import it from halkyon_app now.

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-28 13:52:50 +01:00
commit 39b43217f6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 24 additions and 6 deletions

View file

@ -1,4 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
/// The person's card deletions, which can be undone for a moment (see

View file

@ -1,4 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
/// The person's deletions of learning paths, which can be undone for a

View file

@ -1,4 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
/// The person's note deletions, which can be undone for a moment (see

View file

@ -1,4 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
/// The person's deletions of PDFs, which can be undone for a moment (see

View file

@ -72,6 +72,7 @@ export 'src/assistant/widget/assistant_provider_view.dart';
export 'src/assistant/widget/assistant_use_view.dart';
export 'src/assistant/widget/assistant_words.dart';
export 'src/assistant/widget/assistants_card.dart';
export 'src/async/delayed_deletions.dart';
export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/bloc/wait_cubit.dart';

View file

@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:developer';
/// Deletions that can be undone for a moment (decided 2026-10-01): what is
/// deleted leaves every list at once ([pending]), and only once the time to
/// undo is over (for a deletion held, counted from when [delete] lets it go)
/// does [_deleteNow] run, since blanking can't be undone (ADR 0002, rule 2).
///
/// It belongs to the person's data, so a deletion outlives the screen it
/// started on, and it closes with the person's data: a deletion still
/// waiting then is dropped, and what it would have deleted stays.
class DelayedDeletions(
/// Deletes an item, given its id, for good.
final Future<void> Function(String id) _deleteNow, {
/// Names the log a failed deletion goes to.
final String _logName = 'Deletions',
/// 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),
}) {
/// The deletions that can still be undone, by the item's id: with the
/// timer that starts it, or none while it is held.
final _waiting = <String, Timer?>{};
/// The deletions under way, too late to undo.
final _deleting = <String, Future<void>>{};
final _changes = StreamController<Set<String>>.broadcast();
/// The items whose deletion waits or is under way: lists leave them out.
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
/// [pending], now and after every change.
Stream<Set<String>> watchPending() => Stream.multi((listener) {
listener.add(pending);
final subscription = _changes.stream.listen(listener.add);
listener.onCancel = subscription.cancel;
});
/// Deletes the item [id] once the time to undo that is over.
void delete(String id) {
if (_deleting.containsKey(id)) {
return;
}
_waiting[id]?.cancel();
_waiting[id] = Timer(_undoWindow, () => _start(id));
_changed();
}
/// Takes the item [id] out of every list as [delete] does, but deletes it
/// only once [delete] is called for it, however long that takes: e.g. at
/// the end of a study session, which can undo it until then. Closing drops
/// it as any deletion still waiting, and the item stays.
void hold(String id) {
if (_deleting.containsKey(id)) {
return;
}
_waiting[id]?.cancel();
_waiting[id] = null;
_changed();
}
/// Keeps the item [id] after all, while its deletion still waits.
void undo(String id) {
if (_waiting.containsKey(id)) {
_waiting.remove(id)?.cancel();
_changed();
}
}
/// Drops the deletions still waiting, and lets those under way finish.
Future<void> dispose() async {
for (final timer in _waiting.values) {
timer?.cancel();
}
_waiting.clear();
await Future.wait(_deleting.values);
await _changes.close();
}
void _start(String id) {
_waiting.remove(id);
_deleting[id] = _guarded(id).whenComplete(() {
_deleting.remove(id);
_changed();
});
}
Future<void> _guarded(String id) async {
try {
await _deleteNow(id);
} on Object catch (error) {
// What is left shows again; deleting it once more retries.
log('Deleting $id failed', name: _logName, error: error);
}
}
void _changed() {
if (!_changes.isClosed) {
_changes.add(pending);
}
}
}

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import '../support/eventually.dart';
void main() {
late List<String> deleted;
late DelayedDeletions deletions;
setUp(() {
deleted = [];
deletions = DelayedDeletions(
(id) async => deleted.add(id),
undoWindow: const Duration(milliseconds: 10),
);
});
tearDown(() => deletions.dispose());
test('a held deletion waits past the time to undo, until it is let go: '
'then it takes its time to undo', () async {
deletions.hold('a');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deletions.pending, {'a'});
expect(deleted, isEmpty);
deletions.delete('a');
expect(deletions.pending, {'a'});
await eventually(() => deletions.pending.isEmpty);
expect(deleted, ['a']);
});
test('a held deletion can be undone', () async {
deletions
..hold('a')
..undo('a');
expect(deletions.pending, isEmpty);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
});
test('closing drops a held deletion: the item stays', () async {
deletions.hold('a');
await deletions.dispose();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(deleted, isEmpty);
deletions = DelayedDeletions((id) async {});
});
}

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
/// Waits until [condition] holds, checking every few milliseconds, and fails
/// after [timeout]: for what follows a real timer, without betting on how
/// fast a busy machine runs.
Future<void> eventually(
bool Function() condition, {
Duration timeout = const Duration(seconds: 10),
}) async {
final stopwatch = Stopwatch()..start();
while (!condition()) {
if (stopwatch.elapsed > timeout) {
fail('Still not so after $timeout.');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}