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';
|
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
|
/// 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';
|
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
|
/// 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';
|
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
|
/// 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';
|
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
|
/// 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_use_view.dart';
|
||||||
export 'src/assistant/widget/assistant_words.dart';
|
export 'src/assistant/widget/assistant_words.dart';
|
||||||
export 'src/assistant/widget/assistants_card.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/session_bloc.dart';
|
||||||
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
||||||
export 'src/auth/bloc/wait_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