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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 23:38:17 +01:00
commit 59fb9c4d6d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -676,6 +676,65 @@ void main() {
}
});
testWidgets('a banner stands above every tab, its screen below it '
"without the system's top inset, which the banner keeps clear of "
'itself', (tester) async {
for (final width in const [400.0, 800.0]) {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(top: 24);
addTearDown(tester.view.reset);
Widget frame({Widget? banner}) => testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
banner: banner,
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Scaffold(
appBar: AppBar(title: const Text('The tab')),
body: const SizedBox.expand(),
),
),
);
await tester.pumpWidget(frame());
await tester.pumpAndSettle();
final appBarAlone = tester.getTopLeft(find.byType(AppBar)).dy;
await tester.pumpWidget(
frame(
banner: const SafeArea(
bottom: false,
child: SizedBox(height: 48, child: Text('On mobile data')),
),
),
);
await tester.pumpAndSettle();
final banner = tester.getRect(find.text('On mobile data'));
// On a phone right below the status bar; wider, at the top of the
// screen's panel, inset in the frame.
expect(
banner.top,
width < 600 ? 24 : greaterThanOrEqualTo(24),
reason: '$width: below the status bar',
);
final appBar = tester.getRect(find.byType(AppBar));
expect(appBar.top, banner.bottom, reason: '$width');
expect(appBar.top, greaterThan(appBarAlone), reason: '$width');
expect(
tester.getTopLeft(find.text('The tab')).dy - appBar.top,
lessThan(24),
reason:
"$width: the app bar doesn't keep clear of the status bar again",
);
}
});
testWidgets("the keyboard leaves the frame's tab its size: the tab's own "
'screen decides whether it makes way, as one with a field floating '
"over it doesn't", (tester) async {