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

View file

@ -0,0 +1,171 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// Where an app puts the sync's widgets: a banner on its home, a dot on its
/// way to Settings, and the Sync card in Settings, which More leads to.
class const _Home() extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
body: Column(
children: [
AppMismatchBanner(onMore: () => _openSettings(context)),
SyncTroubleBadge(
child: IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings),
onPressed: () => _openSettings(context),
),
),
],
),
);
static void _openSettings(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: const SingleChildScrollView(child: SyncStatusCard()),
),
),
);
}
void main() {
/// What the next sync does: null goes through, anything else is thrown.
late Object? nextSync;
/// Shows the app on test.example.org once a first sync ran, with
/// [pending] changes kept from before that haven't reached the server.
/// (The service isn't started: its timers would outlive the test.)
Future<void> showApp(WidgetTester tester, {int pending = 0}) async {
final sync = SyncService(
({required onMerge}) => switch (nextSync) {
final Object error => Future<void>.error(error),
null => Future<void>.value(),
},
pendingChanges: MemoryPendingChangesStore(pending),
)..syncNow();
addTearDown(sync.dispose);
await tester.pumpWidget(
BlocProvider(
create: (context) =>
SyncStatusBloc(sync)..add(const SyncStatusEventStarted()),
child: testApp(const _Home(), dependencies: TestAppDependencies()),
),
);
await tester.pumpAndSettle();
}
Finder trouble() => find.bySemanticsLabel(RegExp('Sync needs attention'));
Future<void> openSettings(WidgetTester tester) async {
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
}
setUp(() => nextSync = null);
testWidgets('shows nothing while sync goes through', (tester) async {
await showApp(tester);
expect(trouble(), findsNothing);
expect(find.byType(MaterialBanner), findsNothing);
});
testWidgets('a failed sync puts a dot on the way to Settings, and the '
'Sync card says what is wrong', (tester) async {
nextSync = Exception('No connection');
await showApp(tester);
expect(trouble(), findsOneWidget);
await openSettings(tester);
expect(find.text('Sync is failing'), findsOneWidget);
expect(find.textContaining('Last attempt failed at'), findsOneWidget);
expect(
find.text('No sync has completed since the app started'),
findsOneWidget,
);
});
testWidgets('the Sync card counts the changes that wait to sync, until a '
'sync takes them', (tester) async {
nextSync = Exception('No connection');
await showApp(tester, pending: 2);
await openSettings(tester);
expect(find.text('Sync is failing'), findsOneWidget);
expect(find.text('2 changes wait to sync'), findsOneWidget);
nextSync = null;
await tester.tap(find.text('Sync now'));
await tester.pumpAndSettle();
expect(find.text('Everything synced'), findsOneWidget);
expect(find.textContaining('wait to sync'), findsNothing);
});
testWidgets('Sync now syncs, and the trouble goes once a sync went '
'through', (tester) async {
nextSync = Exception('No connection');
await showApp(tester);
await openSettings(tester);
nextSync = null;
await tester.tap(find.text('Sync now'));
await tester.pumpAndSettle();
expect(find.text('Everything synced'), findsOneWidget);
expect(find.textContaining('Last completed at'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(trouble(), findsNothing);
});
testWidgets("when the app and the server don't match, the banner says so "
'and More leads to the Sync card, until a sync goes through', (
tester,
) async {
nextSync = const AppServerMismatchException();
await showApp(tester);
const banner =
"This app can't sync with test.example.org any more. Update it to "
'keep syncing.';
expect(find.text(banner), findsOneWidget);
await tester.tap(
find.descendant(
of: find.byType(MaterialBanner),
matching: find.text('More'),
),
);
await tester.pumpAndSettle();
expect(
find.text("This app can't sync with test.example.org"),
findsOneWidget,
);
expect(
find.text(
'Update the app. If it is up to date, the server needs an update. '
'What you do meanwhile stays on this device and syncs afterwards.',
),
findsOneWidget,
);
nextSync = null;
await tester.tap(find.text('Sync now'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text(banner), findsNothing);
});
testWidgets('an ordinary failure shows no banner', (tester) async {
nextSync = Exception('No connection');
await showApp(tester);
expect(find.textContaining('Update it to keep syncing'), findsNothing);
});
}