halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps

What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

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-19 15:18:49 +02:00
commit e23cc77f7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 10761 additions and 10 deletions

View file

@ -0,0 +1,68 @@
import 'dart:async';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:test/test.dart';
void main() {
test('asked again while it runs, it runs once more after, however often '
'asked; every caller waits for that too', () async {
final log = <String>[];
var gate = Completer<void>();
var runs = 0;
final runner = CoalescingRunner(() async {
final run = ++runs;
log.add('run $run starts');
await gate.future;
log.add('run $run ends');
}, onError: (_, _) {});
final first = runner.run();
final second = runner.run();
final third = runner.run();
await pumpEventQueue();
expect(log, ['run 1 starts']);
final firstGate = gate;
gate = Completer<void>()..complete();
firstGate.complete();
await Future.wait([first, second, third]);
expect(log, ['run 1 starts', 'run 1 ends', 'run 2 starts', 'run 2 ends']);
expect(runs, 2);
});
test('a run that fails is reported, and one asked for meanwhile still '
'follows', () async {
final errors = <Object>[];
final gate = Completer<void>();
var runs = 0;
final runner = CoalescingRunner(() async {
runs++;
if (runs == 1) {
await gate.future;
throw StateError('disk unavailable');
}
}, onError: (error, _) => errors.add(error));
final first = runner.run();
final again = runner.run();
gate.complete();
await Future.wait([first, again]);
expect(errors, [isA<StateError>()]);
expect(runs, 2);
});
test('idle completes once nothing runs; afterwards a run starts '
'afresh', () async {
var runs = 0;
final runner = CoalescingRunner(() async => runs++, onError: (_, _) {});
await runner.idle;
unawaited(runner.run());
await runner.idle;
await runner.run();
expect(runs, 2);
});
}

View file

@ -0,0 +1,37 @@
import 'dart:async';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:test/test.dart';
void main() {
late SerialQueue queue;
setUp(() => queue = SerialQueue());
test('runs one action after another, in the order queued', () async {
final log = <String>[];
final first = Completer<void>();
final a = queue.run(() async {
log.add('a starts');
await first.future;
log.add('a ends');
});
final b = queue.run(() async => log.add('b'));
await Future<void>.delayed(Duration.zero);
expect(log, ['a starts']);
first.complete();
await Future.wait([a, b]);
expect(log, ['a starts', 'a ends', 'b']);
});
test('completes with what the action returns, or its error, and goes on '
'after an error', () async {
final failing = queue.run<int>(() async => throw StateError('broken'));
final next = queue.run(() async => 42);
await expectLater(failing, throwsStateError);
expect(await next, 42);
});
}