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:
parent
5d47efd7c5
commit
59fb9c4d6d
2 changed files with 1511 additions and 62 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue