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

@ -92,6 +92,12 @@ class const NavigationFrame({
/// while null.
final Widget? status,
/// What stands above every tab while it matters (halkyon_app's banner on
/// mobile data, Halkyon's ADR 0042): its screens below it without the
/// system's top inset, which the banner keeps clear of itself (a
/// `SafeArea` without its bottom); none while null.
final Widget? banner,
/// Whether the rail is open into the sidebar, from expanded windows on.
final bool sidebarOpen = false,
@ -224,15 +230,18 @@ class _NavigationFrameState() extends State<NavigationFrame> {
),
),
),
child: switch (widget.status) {
final status? => Column(
children: [
Expanded(child: widget.child),
Padding(padding: const EdgeInsets.all(12), child: status),
],
),
null => widget.child,
},
child: _underBanner(
context,
switch (widget.status) {
final status? => Column(
children: [
Expanded(child: widget.child),
Padding(padding: const EdgeInsets.all(12), child: status),
],
),
null => widget.child,
},
),
),
);
}
@ -269,6 +278,24 @@ class _NavigationFrameState() extends State<NavigationFrame> {
);
}
/// [screens] below the banner, where there is one.
Widget _underBanner(BuildContext context, Widget screens) =>
switch (widget.banner) {
final banner? => Column(
children: [
banner,
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: screens,
),
),
],
),
null => screens,
};
/// The frame below medium windows, its bar in [colour], the screens on
/// [background].
Widget _phone(
@ -314,7 +341,10 @@ class _NavigationFrameState() extends State<NavigationFrame> {
showsBar: !widget.hidesNavigationBar,
child: _FrameFadeColour(
background: background,
child: ScaffoldMessenger(key: _phoneMessenger, child: widget.child),
child: ScaffoldMessenger(
key: _phoneMessenger,
child: _underBanner(context, widget.child),
),
),
),
// Without the bar, a strip as high as the system's own bar, in the