refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent

ADR 0044's D49 (amended in building it): what reads and seals Learn's
content moves out of the Flutter packages, so Learn's agent, which runs
without Flutter, shares it rather than repeating the privacy rules.

packages/halkyon_content (new, pure Dart) takes from halkyon_app the
envelope and its cipher, SealedFile, UnlockedAccountKeys, shared
spaces' gateway with SharedKeys and KeyPins, FileCache and
FileServerGateway, with their fakes and tests; halkyon_app exports it,
so no app changes. Its rich_text.dart takes RichContent from
halkyon_editor, which exports it. newAccountKeys makes an agent's key
pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a
synced gateway asks of the sync, which SyncService implements.

apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy,
sync, payloads and the models, codecs, gateways and repositories of
studysets, cards, notes, tags and feedback, with what they reach of
files, learning paths, citations and study options; the app imports them
from there. A subject's icon, colour and name stay in the app
(SubjectLook); what only the app shows (progress, exam coverage, the
card browser, deletions' waits) stays too. Nothing changes for people
using the apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 21:24:30 +01:00
commit d09625d43e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 3517 additions and 2007 deletions

View file

@ -16,7 +16,9 @@ library;
export 'package:clock/clock.dart' show Clock, clock;
export 'src/async/coalescing_runner.dart';
export 'src/async/delayed_deletions.dart';
export 'src/async/serial_queue.dart';
export 'src/async/sync_requests.dart';
export 'src/bip39_english.dart';
export 'src/exceptions.dart';
export 'src/logger.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,11 @@
/// What a synced gateway asks of the sync that carries its writes (Learn's
/// and the agent's alike, Halkyon's ADR 0044, D49): a sync soon after each
/// write, and word whenever one merged changes from elsewhere. halkyon_app's
/// `SyncService` is one; the agent's sync is another.
abstract interface class SyncRequests() {
/// Asks for a sync soon; several asked for together run as one.
void requestSync();
/// An event each time a sync merged changes from elsewhere.
Stream<void> get merges;
}

View file

@ -0,0 +1,49 @@
import 'package:halkyon_core/halkyon_core.dart';
import 'package:test/test.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:test/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));
}
}