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
25 changed files with 1511 additions and 62 deletions

View file

@ -6,6 +6,7 @@ analyzer:
- build/**
# Generated by gen-l10n (see l10n.yaml).
- lib/l10n/halkyon_app_localizations*.dart
- android/**
formatter:
trailing_commas: preserve

View file

@ -0,0 +1,22 @@
// halkyon_app's Android half (Halkyon's ADR 0042): Java alone, built by each
// app's Gradle with the app's Android Gradle Plugin.
group = "app.halkyon.halkyon_app"
version = "1.0"
plugins {
id("com.android.library")
}
android {
namespace = "app.halkyon.halkyon_app"
compileSdk = flutter.compileSdkVersion
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

View file

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Which network the device is on, and whether it is metered (ADR 0042). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>

View file

@ -0,0 +1,119 @@
package app.halkyon.halkyon_app;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import java.util.HashMap;
import java.util.Map;
/**
* What every Halkyon app needs from Android (Halkyon's ADR 0042): the default
* network and whether it is metered, as `AndroidNetworkWatch` in Dart reads
* it. Metered is what lacks NET_CAPABILITY_NOT_METERED (cellular, a phone's
* hotspot, Wi-Fi marked metered), unless it is temporarily not metered (an
* unmetered 5G plan, Android 11 on).
*/
public final class HalkyonAppPlugin
implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private MethodChannel methods;
private EventChannel events;
private ConnectivityManager connectivity;
private ConnectivityManager.NetworkCallback callback;
private final Handler main = new Handler(Looper.getMainLooper());
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
connectivity =
(ConnectivityManager)
binding.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
methods = new MethodChannel(binding.getBinaryMessenger(), "app.halkyon/network");
methods.setMethodCallHandler(this);
events = new EventChannel(binding.getBinaryMessenger(), "app.halkyon/network/changes");
events.setStreamHandler(this);
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
methods.setMethodCallHandler(null);
events.setStreamHandler(null);
onCancel(null);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if ("current".equals(call.method)) {
Network network = connectivity.getActiveNetwork();
result.success(stateOf(network, network == null ? null : capabilitiesOf(network)));
} else {
result.notImplemented();
}
}
@Override
public void onListen(Object arguments, EventChannel.EventSink sink) {
onCancel(null);
callback =
new ConnectivityManager.NetworkCallback() {
@Override
public void onCapabilitiesChanged(
@NonNull Network network, @NonNull NetworkCapabilities capabilities) {
send(sink, stateOf(network, capabilities));
}
@Override
public void onLost(@NonNull Network network) {
send(sink, stateOf(null, null));
}
};
connectivity.registerDefaultNetworkCallback(callback);
}
@Override
public void onCancel(Object arguments) {
if (callback != null) {
try {
connectivity.unregisterNetworkCallback(callback);
} catch (IllegalArgumentException alreadyGone) {
// Not registered any more: nothing to undo.
}
callback = null;
}
}
/** The callbacks come on the system's thread; the channel takes the main one. */
private void send(EventChannel.EventSink sink, Map<String, Object> state) {
main.post(() -> sink.success(state));
}
private NetworkCapabilities capabilitiesOf(Network network) {
try {
return connectivity.getNetworkCapabilities(network);
} catch (SecurityException refused) {
return null;
}
}
private static Map<String, Object> stateOf(Network network, NetworkCapabilities capabilities) {
Map<String, Object> state = new HashMap<>();
state.put("id", network == null ? "none" : String.valueOf(network.getNetworkHandle()));
state.put("metered", capabilities != null && isMetered(capabilities));
return state;
}
private static boolean isMetered(NetworkCapabilities capabilities) {
if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
return false;
}
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|| !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED);
}
}

View file

@ -190,10 +190,13 @@ export 'src/speech/speech_input.dart';
export 'src/speech/voice_input_app.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';
export 'src/sync/network/mobile_data_gate.dart';
export 'src/sync/network/network_watch.dart';
export 'src/sync/pending_changes_store.dart';
export 'src/sync/sync_service.dart';
export 'src/sync/sync_status.dart';
export 'src/sync/widget/app_mismatch_banner.dart';
export 'src/sync/widget/mobile_data_banner.dart';
export 'src/sync/widget/sync_and_wait.dart';
export 'src/sync/widget/sync_status_card.dart';
export 'src/sync/widget/sync_trouble_badge.dart';

View file

@ -522,5 +522,14 @@
"lookAppearanceLight": "Hell",
"lookAppearanceDark": "Dunkel",
"lookLanguage": "Sprache",
"lookLanguageDevice": "Gerätesprache"
"lookLanguageDevice": "Gerätesprache",
"syncStateHeldBack": "Mit mobilen Daten angehalten",
"syncFilesHeld": "Dateien warten auf WLAN",
"mobileDataTitle": "Mit mobilen Daten",
"mobileDataAsk": "Erst fragen",
"mobileDataWithoutFiles": "Ohne Dateien synchronisieren",
"mobileDataEverything": "Alles synchronisieren",
"mobileDataBanner": "Mobile Daten · {count, plural, =0{Synchronisierung pausiert} =1{1 Änderung wartet} other{{count} Änderungen warten}}",
"mobileDataBannerWithoutFiles": "Ohne Dateien",
"mobileDataBannerNotNow": "Nicht jetzt"
}

View file

@ -1591,5 +1591,23 @@
"lookAppearanceLight": "Light",
"lookAppearanceDark": "Dark",
"lookLanguage": "Language",
"lookLanguageDevice": "Device language"
"lookLanguageDevice": "Device language",
"syncStateHeldBack": "Held back on mobile data",
"@syncStateHeldBack": {"description": "The Sync card's state while sync waits for the person's answer or Wi-Fi (Halkyon's ADR 0042)."},
"syncFilesHeld": "Files wait for Wi-Fi",
"@syncFilesHeld": {"description": "The Sync card's line while rows sync on mobile data and files wait (ADR 0042)."},
"mobileDataTitle": "On mobile data",
"@mobileDataTitle": {"description": "The Sync card's setting: what the device syncs on a metered network unasked (ADR 0042, D5)."},
"mobileDataAsk": "Ask first",
"@mobileDataAsk": {"description": "A choice of mobileDataTitle: sync waits until the person answers the banner."},
"mobileDataWithoutFiles": "Sync without files",
"@mobileDataWithoutFiles": {"description": "A choice of mobileDataTitle: rows sync, files wait for Wi-Fi."},
"mobileDataEverything": "Sync everything",
"@mobileDataEverything": {"description": "A choice of mobileDataTitle."},
"mobileDataBanner": "On mobile data · {count, plural, =0{sync paused} =1{1 change waiting} other{{count} changes waiting}}",
"@mobileDataBanner": {"description": "The banner above every tab while sync is held back on mobile data (ADR 0042, D3); count is the changes made here not synced yet.", "placeholders": {"count": {"type": "int"}}},
"mobileDataBannerWithoutFiles": "Without files",
"@mobileDataBannerWithoutFiles": {"description": "The banner's action syncing the rows alone, files waiting for Wi-Fi."},
"mobileDataBannerNotNow": "Not now",
"@mobileDataBannerNotNow": {"description": "The banner's action hiding it until another change waits."}
}

View file

@ -3240,6 +3240,60 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Device language'**
String get lookLanguageDevice;
/// The Sync card's state while sync waits for the person's answer or Wi-Fi (Halkyon's ADR 0042).
///
/// In en, this message translates to:
/// **'Held back on mobile data'**
String get syncStateHeldBack;
/// The Sync card's line while rows sync on mobile data and files wait (ADR 0042).
///
/// In en, this message translates to:
/// **'Files wait for Wi-Fi'**
String get syncFilesHeld;
/// The Sync card's setting: what the device syncs on a metered network unasked (ADR 0042, D5).
///
/// In en, this message translates to:
/// **'On mobile data'**
String get mobileDataTitle;
/// A choice of mobileDataTitle: sync waits until the person answers the banner.
///
/// In en, this message translates to:
/// **'Ask first'**
String get mobileDataAsk;
/// A choice of mobileDataTitle: rows sync, files wait for Wi-Fi.
///
/// In en, this message translates to:
/// **'Sync without files'**
String get mobileDataWithoutFiles;
/// A choice of mobileDataTitle.
///
/// In en, this message translates to:
/// **'Sync everything'**
String get mobileDataEverything;
/// The banner above every tab while sync is held back on mobile data (ADR 0042, D3); count is the changes made here not synced yet.
///
/// In en, this message translates to:
/// **'On mobile data · {count, plural, =0{sync paused} =1{1 change waiting} other{{count} changes waiting}}'**
String mobileDataBanner(int count);
/// The banner's action syncing the rows alone, files waiting for Wi-Fi.
///
/// In en, this message translates to:
/// **'Without files'**
String get mobileDataBannerWithoutFiles;
/// The banner's action hiding it until another change waits.
///
/// In en, this message translates to:
/// **'Not now'**
String get mobileDataBannerNotNow;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -2033,4 +2033,40 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get lookLanguageDevice => 'Gerätesprache';
@override
String get syncStateHeldBack => 'Mit mobilen Daten angehalten';
@override
String get syncFilesHeld => 'Dateien warten auf WLAN';
@override
String get mobileDataTitle => 'Mit mobilen Daten';
@override
String get mobileDataAsk => 'Erst fragen';
@override
String get mobileDataWithoutFiles => 'Ohne Dateien synchronisieren';
@override
String get mobileDataEverything => 'Alles synchronisieren';
@override
String mobileDataBanner(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Änderungen warten',
one: '1 Änderung wartet',
zero: 'Synchronisierung pausiert',
);
return 'Mobile Daten · $_temp0';
}
@override
String get mobileDataBannerWithoutFiles => 'Ohne Dateien';
@override
String get mobileDataBannerNotNow => 'Nicht jetzt';
}

View file

@ -2001,6 +2001,42 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get lookLanguageDevice => 'Device language';
@override
String get syncStateHeldBack => 'Held back on mobile data';
@override
String get syncFilesHeld => 'Files wait for Wi-Fi';
@override
String get mobileDataTitle => 'On mobile data';
@override
String get mobileDataAsk => 'Ask first';
@override
String get mobileDataWithoutFiles => 'Sync without files';
@override
String get mobileDataEverything => 'Sync everything';
@override
String mobileDataBanner(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count changes waiting',
one: '1 change waiting',
zero: 'sync paused',
);
return 'On mobile data · $_temp0';
}
@override
String get mobileDataBannerWithoutFiles => 'Without files';
@override
String get mobileDataBannerNotNow => 'Not now';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,10 +1,12 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/sync/network/mobile_data_gate.dart';
import 'package:halkyon_app/src/sync/sync_service.dart';
import 'package:halkyon_app/src/sync/sync_status.dart';
/// Where sync stands on this device, for the trouble dot, the Settings card
/// and the banner on Home; and syncing now when the person asks.
/// Where sync stands on this device, for the trouble dot, the Settings card,
/// the banner on Home and the one on mobile data; and syncing now, or as
/// the person answered on mobile data, when they ask.
class SyncStatusBloc(final SyncService _sync)
extends Bloc<SyncStatusEvent, SyncStatus> {
this : super(_sync.status) {
@ -15,6 +17,13 @@ class SyncStatusBloc(final SyncService _sync)
),
);
on<SyncStatusEventSyncRequested>((event, emit) => _sync.syncNow());
on<SyncStatusEventAnswered>(
(event, emit) => _sync.answer(event.allowance),
);
on<SyncStatusEventNotNow>((event, emit) => _sync.notNow());
on<SyncStatusEventMobileDataChosen>(
(event, emit) => _sync.chooseMobileData(event.choice),
);
}
}
@ -29,3 +38,20 @@ final class const SyncStatusEventStarted() extends SyncStatusEvent;
/// The person asked to sync now.
final class const SyncStatusEventSyncRequested() extends SyncStatusEvent;
/// The person answered the banner on mobile data (ADR 0042, D3).
final class const SyncStatusEventAnswered(final SyncAllowance allowance)
extends SyncStatusEvent {
@override
List<Object?> get props => [allowance];
}
/// The person put the banner on mobile data off.
final class const SyncStatusEventNotNow() extends SyncStatusEvent;
/// The person chose what the device syncs on mobile data unasked (D5).
final class const SyncStatusEventMobileDataChosen(final MobileData choice)
extends SyncStatusEvent {
@override
List<Object?> get props => [choice];
}

View file

@ -0,0 +1,158 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/src/sync/network/network_watch.dart';
/// What a device syncs on mobile data unasked (Halkyon's ADR 0042, D5): it
/// asks, syncs its rows without files, or syncs everything.
enum MobileData() {
ask,
withoutFiles,
everything,
}
/// What may go over the network now.
enum SyncAllowance() {
/// Nothing: sync is held back, and the person asked.
nothing,
/// The rows, without files.
rows,
/// Everything.
everything,
}
/// Whether sync may run on the network the device is on (ADR 0042): on a
/// metered one, as the device's setting ([mobileData]) says, or, where it
/// asks, as the person answered ([answer]) for this connection: the answer
/// holds until the device moves to another network or off a metered one
/// (D4), and goes with the app. The device's, whoever is signed in: one gate
/// for every person's sync; the setting is kept in [_file] where there is
/// one, neither sealed nor synced.
class MobileDataGate({
/// Where the device says which network it is on.
final NetworkWatch _network = const NeverMetered(),
/// Whether this device can be on a metered network at all, so Settings
/// offers the choice: a phone's (D2).
final bool offered = false,
final File? _file,
}) extends ChangeNotifier {
var _setting = MobileData.ask;
NetworkState? _state;
({String network, SyncAllowance allowance})? _answer;
StreamSubscription<NetworkState>? _changes;
/// Opens the setting [file] holds (asking where it can't be read), on
/// [network].
static Future<MobileDataGate> open(
File file, {
required NetworkWatch network,
required bool offered,
}) async {
final gate = MobileDataGate(network: network, offered: offered, file: file);
try {
if (file.existsSync()) {
final json = jsonDecode(await file.readAsString());
if (json case {'mobileData': final String name}) {
gate._setting = MobileData.values.firstWhere(
(value) => value.name == name,
orElse: () => MobileData.ask,
);
}
}
} on Object catch (error) {
log('Mobile data not read: ${error.runtimeType}', name: 'Sync');
}
return gate;
}
/// The device's own: its [deviceNetworkWatch], offered on Android.
static Future<MobileDataGate> forDevice(File file) => open(
file,
network: deviceNetworkWatch(),
offered: Platform.isAndroid,
);
/// Done once the network is known: before then nothing syncs, so a phone
/// on mobile data never syncs at start before it knows. A watch that
/// doesn't answer in [_patience] counts as not metered.
late final Future<void> ready = _watch();
static const _patience = Duration(seconds: 3);
Future<void> _watch() async {
_changes = _network.changes.listen(
_moved,
onError: (Object error) =>
log('Network not watched: $error', name: 'Sync'),
);
try {
_moved(await _network.current().timeout(_patience));
} on Object catch (error) {
log('Network not known: ${error.runtimeType}', name: 'Sync');
}
}
void _moved(NetworkState state) {
if (state == _state) {
return;
}
_state = state;
if (!state.metered || _answer?.network != state.id) {
_answer = null;
}
notifyListeners();
}
/// What the device syncs on mobile data unasked.
MobileData get mobileData => _setting;
/// Whether the device is on a metered network now.
bool get metered => _state?.metered ?? false;
/// What may go over the network now.
SyncAllowance get allowance => !metered
? SyncAllowance.everything
: switch (_setting) {
MobileData.everything => SyncAllowance.everything,
MobileData.withoutFiles => SyncAllowance.rows,
MobileData.ask => _answer?.allowance ?? SyncAllowance.nothing,
};
/// Whether files may go up and come down now.
bool get filesAllowed => allowance == SyncAllowance.everything;
/// The person answered, for this connection: sync now ([allowance]
/// everything), or without files (rows).
void answer(SyncAllowance allowance) {
if (_state case final state? when state.metered) {
_answer = (network: state.id, allowance: allowance);
notifyListeners();
}
}
/// Chooses what the device syncs on mobile data unasked, and keeps it.
Future<void> choose(MobileData mobileData) async {
if (mobileData == _setting) {
return;
}
_setting = mobileData;
notifyListeners();
try {
await _file?.writeAsString(jsonEncode({'mobileData': mobileData.name}));
} on Object catch (error) {
log('Mobile data not kept: ${error.runtimeType}', name: 'Sync');
}
}
@override
void dispose() {
unawaited(_changes?.cancel());
super.dispose();
}
}

View file

@ -0,0 +1,59 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
/// The network the device is on: `id` tells one connection from the next
/// (Android's network handle), `metered` whether data on it costs, as the
/// system says (Halkyon's ADR 0042, D2).
typedef NetworkState = ({String id, bool metered});
/// Where the device says which network it is on and whether it is metered.
abstract interface class NetworkWatch() {
/// The network now.
Future<NetworkState> current();
/// Every change of network, or of whether it is metered; may repeat one.
Stream<NetworkState> get changes;
}
/// A device that is never on a metered network: a desktop's (ADR 0042, D2),
/// and every test that doesn't say.
class const NeverMetered() implements NetworkWatch {
static const NetworkState _state = (id: 'unmetered', metered: false);
@override
Future<NetworkState> current() async => _state;
@override
Stream<NetworkState> get changes => const Stream.empty();
}
/// Android's, through halkyon_app's plugin (`HalkyonAppPlugin.java`): the
/// default network and whether it lacks `NET_CAPABILITY_NOT_METERED`, which
/// takes in cellular, a phone's hotspot and Wi-Fi marked metered.
class AndroidNetworkWatch() implements NetworkWatch {
static const _methods = MethodChannel('app.halkyon/network');
static const _events = EventChannel('app.halkyon/network/changes');
@override
Future<NetworkState> current() async =>
_stateOf(await _methods.invokeMethod<Object?>('current'));
@override
Stream<NetworkState> get changes =>
_events.receiveBroadcastStream().map(_stateOf);
static NetworkState _stateOf(Object? value) => switch (value) {
{'id': final String id, 'metered': final bool metered} => (
id: id,
metered: metered,
),
_ => throw FormatException('Not a network: $value'),
};
}
/// The device's own: Android's, else [NeverMetered]; iOS joins once its
/// builds ship (ADR 0042, D2).
NetworkWatch deviceNetworkWatch() =>
Platform.isAndroid ? AndroidNetworkWatch() : const NeverMetered();

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/sync/app_compatibility.dart';
import 'package:halkyon_app/src/sync/network/mobile_data_gate.dart';
import 'package:halkyon_app/src/sync/pending_changes_store.dart';
import 'package:halkyon_app/src/sync/sync_status.dart';
import 'package:halkyon_core/halkyon_core.dart';
@ -22,6 +23,11 @@ typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
/// those made before it started. The engine itself can't say, as the server
/// only tells it what it has at the start of each sync. [pendingChanges]
/// keeps the count while the app is closed.
///
/// On mobile data it syncs as the device's [mobileData] gate allows (ADR
/// 0042): held back, nothing runs, and the status asks until the person
/// answers ([answer], [syncNow]) or puts it off ([notNow]); Wi-Fi, or an
/// answer, syncs at once.
class SyncService(
final SyncOnce _syncOnce, {
@ -38,16 +44,36 @@ class SyncService(
/// Whether a sync went through on this device before, as [pendingChanges]
/// kept it: read as the person's data opens, so the first status has it.
final bool syncedBefore = false,
/// What the device's network allows: the device's, shared by every
/// person's sync; everything goes where null.
final MobileDataGate? mobileData,
}) {
final _merges = StreamController<void>.broadcast();
final _synced = StreamController<void>.broadcast();
final _statusChanges = StreamController<SyncStatus>.broadcast();
late var _status = SyncStatus(syncedHere: syncedBefore);
Timer? _debounceTimer;
Timer? _periodic;
AppLifecycleListener? _lifecycle;
bool _stopping = false;
// What the status is made of, with the gate's.
var _syncing = false;
DateTime? _lastSyncedAt;
SyncFailure? _failure;
late var _syncedHere = syncedBefore;
/// The changes waiting when *Not now* was chosen; the banner asks again
/// once more wait. Null while it wasn't, or the network changed since.
int? _notNowAt;
/// What the gate allowed when last seen, to sync as soon as it allows
/// more: from [start] on.
var _allowed = SyncAllowance.everything;
/// The status last published; made anew until one is.
SyncStatus? _published;
/// One sync at a time; a trigger during a sync runs one more afterwards.
late final _syncs = CoalescingRunner(
_syncTriggered,
@ -73,7 +99,7 @@ class SyncService(
() async {
// Once stopping, the person's folder may be about to go.
if (pendingChanges case final store? when !_stopping) {
await store.write((pending: _pending, synced: _status.syncedHere));
await store.write((pending: _pending, synced: _syncedHere));
}
},
onError: (error, _) =>
@ -88,14 +114,23 @@ class SyncService(
Stream<void> get synced => _synced.stream;
/// Where sync stands on this device since the app started.
SyncStatus get status => _status;
SyncStatus get status => _published ?? _make();
/// Every change of [status].
Stream<SyncStatus> get statusChanges => _statusChanges.stream;
/// Whether files may go up and come down now: not on mobile data unless
/// the setting or the person's answer says so (ADR 0042).
bool get filesAllowed => mobileData?.filesAllowed ?? true;
SyncAllowance get _allowance =>
mobileData?.allowance ?? SyncAllowance.everything;
/// Syncs now, then on the triggers.
void start() {
unawaited(_restoring);
_allowed = _allowance;
mobileData?.addListener(_gateChanged);
_periodic = Timer.periodic(interval, (_) => _run());
_lifecycle = AppLifecycleListener(onResume: _run);
_run();
@ -108,10 +143,34 @@ class SyncService(
_debounceTimer = Timer(debounce, _run);
}
/// Syncs now, without waiting for local changes to settle: the person asked.
/// Syncs now, without waiting for local changes to settle: the person
/// asked, which on mobile data held back is *Sync now* (ADR 0042, D3).
void syncNow() {
_debounceTimer?.cancel();
_run();
if (_allowance == SyncAllowance.nothing && mobileData != null) {
answer(SyncAllowance.everything);
} else {
_run();
}
}
/// The person answered the banner on mobile data: *Sync now*
/// ([SyncAllowance.everything]) or *Without files* ([SyncAllowance.rows]),
/// for this connection.
void answer(SyncAllowance allowance) {
_notNowAt = null;
mobileData?.answer(allowance);
}
/// The person put the banner off until more changes wait.
void notNow() {
_notNowAt = _pending;
_publish();
}
/// Chooses what the device syncs on mobile data unasked.
Future<void> chooseMobileData(MobileData choice) async {
await mobileData?.choose(choice);
}
/// Stops syncing. A sync in progress finishes first, so the database can be
@ -121,12 +180,27 @@ class SyncService(
_debounceTimer?.cancel();
_periodic?.cancel();
_lifecycle?.dispose();
mobileData?.removeListener(_gateChanged);
await _syncs.idle;
await _merges.close();
await _synced.close();
await _statusChanges.close();
}
void _gateChanged() {
final allowed = _allowance;
if (allowed != _allowed) {
// Another connection, or an answer: Not now no longer stands.
_notNowAt = null;
}
final more = allowed.index > _allowed.index;
_allowed = allowed;
_publish();
if (more) {
_run();
}
}
void _run() {
if (!_stopping) {
unawaited(_syncs.run());
@ -135,51 +209,63 @@ class SyncService(
Future<void> _syncTriggered() async {
await _restoring;
await mobileData?.ready;
if (_stopping) {
return;
}
_report(
SyncStatus(
syncing: true,
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: _pending,
syncedHere: _status.syncedHere,
),
);
if (_allowance == SyncAllowance.nothing) {
// Held back: the status says so and asks.
_publish();
return;
}
_syncing = true;
_publish();
// Those saved before the sync starts go with it; those saved while it
// runs, perhaps too late for it, wait for the next.
final sending = _pending;
try {
await _syncOnce(onMerge: () => _merges.add(null));
_pending -= sending;
_report(
SyncStatus(
lastSyncedAt: clock.now(),
pending: _pending,
syncedHere: true,
),
);
_lastSyncedAt = clock.now();
_failure = null;
_syncedHere = true;
_syncing = false;
_publish();
_persistPending();
_synced.add(null);
} on Object catch (error, st) {
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
_report(
SyncStatus(
lastSyncedAt: _status.lastSyncedAt,
failure: (
at: clock.now(),
appMismatch: error is AppServerMismatchException,
),
pending: _pending,
syncedHere: _status.syncedHere,
),
_failure = (
at: clock.now(),
appMismatch: error is AppServerMismatchException,
);
_syncing = false;
_publish();
}
}
void _report(SyncStatus status) {
_status = status;
SyncStatus _make() {
final gate = mobileData;
final heldBack = !_syncing && _allowance == SyncAllowance.nothing;
return SyncStatus(
syncing: _syncing,
lastSyncedAt: _lastSyncedAt,
failure: _failure,
pending: _pending,
syncedHere: _syncedHere,
heldBack: heldBack,
asking: heldBack && (_notNowAt == null || _pending > _notNowAt!),
filesHeld: _allowance == SyncAllowance.rows,
mobileData: gate != null && gate.offered ? gate.mobileData : null,
);
}
void _publish() {
final status = _make();
if (status == _published) {
return;
}
_published = status;
if (!_statusChanges.isClosed) {
_statusChanges.add(status);
}
@ -194,15 +280,7 @@ class SyncService(
void _setPending(int pending) {
_pending = pending;
_report(
SyncStatus(
syncing: _status.syncing,
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: pending,
syncedHere: _status.syncedHere,
),
);
_publish();
_persistPending();
}

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_app/src/sync/network/mobile_data_gate.dart';
/// A failed sync: when, and whether because this app and the server don't
/// match.
@ -24,6 +25,21 @@ final class const SyncStatus({
/// Whether a sync ever went through on this device for this person, this
/// run or before: until one has, their library may be on its way.
final bool syncedHere = false,
/// Whether sync is held back on mobile data, the device asking (ADR
/// 0042): nothing goes until the person answers or Wi-Fi comes.
final bool heldBack = false,
/// Whether the banner asks: held back, and not put off by *Not now*
/// since the last change waiting.
final bool asking = false,
/// Whether files wait for an unmetered network while rows sync.
final bool filesHeld = false,
/// What the device syncs on mobile data unasked; null where it is never on
/// mobile data (a desktop), so Settings offers nothing.
final MobileData? mobileData,
}) with Equatable {
/// Whether sync needs the person's attention: its last outcome was a
/// failure. A sync running meanwhile doesn't hide it.
@ -40,5 +56,9 @@ final class const SyncStatus({
failure,
pending,
syncedHere,
heldBack,
asking,
filesHeld,
mobileData,
];
}

View file

@ -0,0 +1,78 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_app/src/sync/network/mobile_data_gate.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The banner while sync is held back on mobile data and asks (Halkyon's
/// ADR 0042, D3), for halkyon_ui's `NavigationFrame.banner`, above every
/// tab; null while it doesn't ask. Needs the [SyncStatusBloc] above.
Widget? mobileDataBannerOf(BuildContext context) =>
context.select<SyncStatusBloc, bool>((bloc) => bloc.state.asking)
? const MobileDataBanner()
: null;
/// On mobile data, how many changes wait, with *Not now*, *Without files*
/// and *Sync now*; it keeps clear of the system's status bar itself, as the
/// frame's banner does.
class const MobileDataBanner({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
final pending = context.select<SyncStatusBloc, int>(
(bloc) => bloc.state.pending,
);
void send(SyncStatusEvent event) =>
context.read<SyncStatusBloc>().add(event);
return Material(
color: colors.secondaryContainer,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(LucideIcons.signal, color: colors.onSecondaryContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.mobileDataBanner(pending),
style: TextStyle(color: colors.onSecondaryContainer),
),
),
],
),
Wrap(
alignment: WrapAlignment.end,
spacing: 4,
children: [
TextButton(
onPressed: () => send(const SyncStatusEventNotNow()),
child: Text(l10n.mobileDataBannerNotNow),
),
TextButton(
onPressed: () => send(
const SyncStatusEventAnswered(SyncAllowance.rows),
),
child: Text(l10n.mobileDataBannerWithoutFiles),
),
TextButton(
onPressed: () => send(
const SyncStatusEventAnswered(SyncAllowance.everything),
),
child: Text(l10n.syncNow),
),
],
),
],
),
),
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_app/src/sync/network/mobile_data_gate.dart';
import 'package:halkyon_app/src/sync/sync_status.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
@ -34,6 +35,10 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
l10n.syncStateMismatch(server),
[l10n.syncMismatchDetail, ?pending, completed],
),
SyncStatus(heldBack: true, :final lastSyncedAt) => (
l10n.syncStateHeldBack,
[?pending, if (lastSyncedAt != null) completed],
),
SyncStatus(syncing: true, :final lastSyncedAt) => (
l10n.syncStateSyncing,
[if (lastSyncedAt != null) completed],
@ -83,7 +88,10 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
: null,
),
),
for (final line in details)
for (final line in [
...details,
if (status.filesHeld) l10n.syncFilesHeld,
])
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
@ -93,6 +101,10 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
),
),
),
if (status.mobileData case final mobileData?) ...[
const SizedBox(height: 16),
_MobileDataField(mobileData),
],
],
),
);
@ -100,3 +112,40 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
);
}
}
/// What the device syncs on mobile data unasked (ADR 0042, D5), on a device
/// that can be on mobile data alone.
class const _MobileDataField(final MobileData _mobileData)
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return DropdownButtonFormField<MobileData>(
initialValue: _mobileData,
decoration: InputDecoration(
labelText: l10n.mobileDataTitle,
prefixIcon: const Icon(LucideIcons.signal),
border: const OutlineInputBorder(),
),
icon: const Icon(LucideIcons.chevronDown),
items: [
for (final choice in MobileData.values)
DropdownMenuItem(
value: choice,
child: Text(switch (choice) {
MobileData.ask => l10n.mobileDataAsk,
MobileData.withoutFiles => l10n.mobileDataWithoutFiles,
MobileData.everything => l10n.mobileDataEverything,
}),
),
],
onChanged: (choice) {
if (choice != null) {
context.read<SyncStatusBloc>().add(
SyncStatusEventMobileDataChosen(choice),
);
}
},
);
}
}

View file

@ -0,0 +1,29 @@
import 'dart:async';
import 'package:halkyon_app/src/sync/network/network_watch.dart';
/// A [NetworkWatch] a test moves between networks: on Wi-Fi at first.
class FakeNetworkWatch() implements NetworkWatch {
/// The home Wi-Fi.
static const NetworkState wifi = (id: 'wifi', metered: false);
/// A phone's mobile data.
static const NetworkState mobile = (id: 'mobile', metered: true);
final _changes = StreamController<NetworkState>.broadcast(sync: true);
/// The network now.
NetworkState state = wifi;
@override
Future<NetworkState> current() async => state;
@override
Stream<NetworkState> get changes => _changes.stream;
/// The device moves to [network].
void moveTo(NetworkState network) {
state = network;
_changes.add(network);
}
}

View file

@ -15,6 +15,7 @@ export 'src/testing/fake_content_cipher.dart';
export 'src/testing/fake_file_picker.dart';
export 'src/testing/fake_instance_checker.dart';
export 'src/testing/fake_instance_operator_gateway.dart';
export 'src/testing/fake_network_watch.dart';
export 'src/testing/fake_oidc_browser.dart';
export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_public_profile_gateway.dart';

View file

@ -88,6 +88,15 @@ dev_dependencies:
flutter:
# Generates HalkyonAppLocalizations from lib/l10n (see l10n.yaml).
generate: true
# Android's half of what every app needs from the system, registered in each
# app by Flutter itself (decided 10 Nov 2026, Halkyon's ADR 0042): whether
# the network is metered (AndroidNetworkWatch). In Java, so it applies no
# Kotlin Gradle Plugin, which AGP 9's built-in Kotlin refuses.
plugin:
platforms:
android:
package: app.halkyon.halkyon_app
pluginClass: HalkyonAppPlugin
# Material's icon font, which serverpod_auth_idp_flutter's widgets ask for
# (it sets this itself): without it every test run here warns, and its
# widgets' icons would draw as boxes in them (each app sets it for itself).

Some files were not shown because too many files have changed in this diff Show more