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:
parent
dd778cb2cd
commit
39b43217f6
8 changed files with 24 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
105
packages/halkyon_app/lib/src/async/delayed_deletions.dart
Normal file
105
packages/halkyon_app/lib/src/async/delayed_deletions.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/halkyon_app/test/async/delayed_deletions_test.dart
Normal file
49
packages/halkyon_app/test/async/delayed_deletions_test.dart
Normal 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 {});
|
||||
});
|
||||
}
|
||||
17
packages/halkyon_app/test/support/eventually.dart
Normal file
17
packages/halkyon_app/test/support/eventually.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue