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
25 changed files with 1511 additions and 62 deletions
|
|
@ -6,6 +6,7 @@ analyzer:
|
|||
- build/**
|
||||
# Generated by gen-l10n (see l10n.yaml).
|
||||
- lib/l10n/halkyon_app_localizations*.dart
|
||||
- android/**
|
||||
|
||||
formatter:
|
||||
trailing_commas: preserve
|
||||
|
|
|
|||
22
packages/halkyon_app/android/build.gradle.kts
Normal file
22
packages/halkyon_app/android/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
158
packages/halkyon_app/lib/src/sync/network/mobile_data_gate.dart
Normal file
158
packages/halkyon_app/lib/src/sync/network/mobile_data_gate.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
59
packages/halkyon_app/lib/src/sync/network/network_watch.dart
Normal file
59
packages/halkyon_app/lib/src/sync/network/network_watch.dart
Normal 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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
packages/halkyon_app/lib/src/testing/fake_network_watch.dart
Normal file
29
packages/halkyon_app/lib/src/testing/fake_network_watch.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue