feat(app,ui): sync waits on mobile data and asks first
The developer's request of 10 Nov 2026, decided in ADR 0042: on mobile
data every app holds its sync back, rows and files alike, and asks.
halkyon_app becomes an Android plugin, its first native code, registered
in every app by Flutter: HalkyonAppPlugin.java says which network the
device is on and whether it is metered as Android has it (no
NET_CAPABILITY_NOT_METERED: cellular, a phone's hotspot, Wi-Fi marked
metered). Java, so it applies no Kotlin Gradle Plugin.
MobileDataGate, one per device, holds the setting (Ask first, Sync
without files, Sync everything; kept in <app support>/mobile_data.json),
the network, and the person's answer, which holds until the device moves
to another network or off a metered one. SyncService asks it before each
sync: held back, nothing goes, the status counts what waits and asks; an
answer or Wi-Fi syncs at once, and syncing now while held back is Sync
now. Each app's frame shows the banner above every tab, in halkyon_ui's
new NavigationFrame.banner slot (On mobile data · 3 changes waiting, with
Not now, Without files and Sync now); the Sync card in Settings chooses
the setting on a phone and says when files wait for Wi-Fi. Learn's files
neither go up nor come down while held back.
Whats-New: On mobile data the app no longer syncs by itself: a banner asks first, and Settings › Sync chooses whether to ask, sync without files, or sync everything.
Whats-New-De: Mit mobilen Daten synchronisiert die App nicht mehr von selbst: Ein Hinweis fragt zuerst, und unter Einstellungen › Synchronisierung wählst du, ob sie fragt, ohne Dateien oder alles synchronisiert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 23:38:17 +01:00
|
|
|
import 'dart:async';
|
|
|
|
|
|
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>
2026-10-19 15:18:49 +02:00
|
|
|
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);
|
|
|
|
|
});
|
feat(app,ui): sync waits on mobile data and asks first
The developer's request of 10 Nov 2026, decided in ADR 0042: on mobile
data every app holds its sync back, rows and files alike, and asks.
halkyon_app becomes an Android plugin, its first native code, registered
in every app by Flutter: HalkyonAppPlugin.java says which network the
device is on and whether it is metered as Android has it (no
NET_CAPABILITY_NOT_METERED: cellular, a phone's hotspot, Wi-Fi marked
metered). Java, so it applies no Kotlin Gradle Plugin.
MobileDataGate, one per device, holds the setting (Ask first, Sync
without files, Sync everything; kept in <app support>/mobile_data.json),
the network, and the person's answer, which holds until the device moves
to another network or off a metered one. SyncService asks it before each
sync: held back, nothing goes, the status counts what waits and asks; an
answer or Wi-Fi syncs at once, and syncing now while held back is Sync
now. Each app's frame shows the banner above every tab, in halkyon_ui's
new NavigationFrame.banner slot (On mobile data · 3 changes waiting, with
Not now, Without files and Sync now); the Sync card in Settings chooses
the setting on a phone and says when files wait for Wi-Fi. Learn's files
neither go up nor come down while held back.
Whats-New: On mobile data the app no longer syncs by itself: a banner asks first, and Settings › Sync chooses whether to ask, sync without files, or sync everything.
Whats-New-De: Mit mobilen Daten synchronisiert die App nicht mehr von selbst: Ein Hinweis fragt zuerst, und unter Einstellungen › Synchronisierung wählst du, ob sie fragt, ohne Dateien oder alles synchronisiert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 23:38:17 +01:00
|
|
|
|
|
|
|
|
group('on mobile data (ADR 0042)', () {
|
|
|
|
|
late FakeNetworkWatch network;
|
|
|
|
|
late int syncs;
|
|
|
|
|
|
|
|
|
|
/// Shows the banner above the Sync card, on a phone ([offered]) on
|
|
|
|
|
/// mobile data, sync started; [changes] made here wait.
|
|
|
|
|
Future<(SyncService, MobileDataGate)> showPhone(
|
|
|
|
|
WidgetTester tester, {
|
|
|
|
|
bool offered = true,
|
|
|
|
|
int changes = 0,
|
|
|
|
|
}) async {
|
|
|
|
|
final gate = MobileDataGate(network: network, offered: offered);
|
|
|
|
|
addTearDown(gate.dispose);
|
|
|
|
|
final sync = SyncService(({required onMerge}) async {
|
|
|
|
|
syncs++;
|
|
|
|
|
}, mobileData: gate)..start();
|
|
|
|
|
for (var change = 0; change < changes; change++) {
|
|
|
|
|
sync.requestSync();
|
|
|
|
|
}
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
BlocProvider(
|
|
|
|
|
create: (context) =>
|
|
|
|
|
SyncStatusBloc(sync)..add(const SyncStatusEventStarted()),
|
|
|
|
|
child: testApp(
|
|
|
|
|
Scaffold(
|
|
|
|
|
body: Builder(
|
|
|
|
|
builder: (context) => Column(
|
|
|
|
|
children: [
|
|
|
|
|
?mobileDataBannerOf(context),
|
|
|
|
|
const Expanded(
|
|
|
|
|
child: SingleChildScrollView(child: SyncStatusCard()),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
dependencies: TestAppDependencies(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump(const Duration(seconds: 3));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
return (sync, gate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
network = FakeNetworkWatch()..state = FakeNetworkWatch.mobile;
|
|
|
|
|
syncs = 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the banner counts the changes waiting; Not now puts it off '
|
|
|
|
|
'until another waits', (tester) async {
|
|
|
|
|
final (sync, _) = await showPhone(tester, changes: 2);
|
|
|
|
|
expect(find.text('On mobile data · 2 changes waiting'), findsOneWidget);
|
|
|
|
|
expect(find.text('Held back on mobile data'), findsOneWidget);
|
|
|
|
|
expect(syncs, 0);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Not now'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.byType(MobileDataBanner), findsNothing);
|
|
|
|
|
|
|
|
|
|
sync.requestSync();
|
|
|
|
|
await tester.pump(const Duration(seconds: 3));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('On mobile data · 3 changes waiting'), findsOneWidget);
|
|
|
|
|
expect(syncs, 0);
|
|
|
|
|
unawaited(sync.dispose());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('Sync now syncs and the banner goes', (tester) async {
|
|
|
|
|
final (sync, _) = await showPhone(tester);
|
|
|
|
|
expect(find.text('On mobile data · sync paused'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Sync now').first);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(syncs, 1);
|
|
|
|
|
expect(find.byType(MobileDataBanner), findsNothing);
|
|
|
|
|
unawaited(sync.dispose());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('Without files syncs, and the Sync card says the files wait', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final (sync, _) = await showPhone(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Without files'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(syncs, 1);
|
|
|
|
|
expect(find.byType(MobileDataBanner), findsNothing);
|
|
|
|
|
expect(find.text('Files wait for Wi-Fi'), findsOneWidget);
|
|
|
|
|
unawaited(sync.dispose());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the Sync card chooses what goes unasked; Sync everything '
|
|
|
|
|
"syncs and doesn't ask", (tester) async {
|
|
|
|
|
final (sync, gate) = await showPhone(tester);
|
|
|
|
|
expect(find.text('Ask first'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Ask first'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Sync everything').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(gate.mobileData, MobileData.everything);
|
|
|
|
|
expect(syncs, 1);
|
|
|
|
|
expect(find.byType(MobileDataBanner), findsNothing);
|
|
|
|
|
unawaited(sync.dispose());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a desktop shows no banner and no choice', (tester) async {
|
|
|
|
|
network.state = FakeNetworkWatch.wifi;
|
|
|
|
|
final (sync, _) = await showPhone(tester, offered: false);
|
|
|
|
|
expect(find.byType(MobileDataBanner), findsNothing);
|
|
|
|
|
expect(find.text('On mobile data'), findsNothing);
|
|
|
|
|
expect(syncs, 1);
|
|
|
|
|
unawaited(sync.dispose());
|
|
|
|
|
});
|
|
|
|
|
});
|
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>
2026-10-19 15:18:49 +02:00
|
|
|
}
|