Trigger background sync on connectivity recovery
This commit is contained in:
@@ -30,6 +30,9 @@ enabled by default.
|
||||
In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
|
||||
attempts while offline.
|
||||
|
||||
When connectivity returns (offline -> online), sync now triggers immediately in
|
||||
addition to startup/resume/periodic ticks.
|
||||
|
||||
Control flags:
|
||||
|
||||
```bash
|
||||
|
||||
+30
-2
@@ -2,6 +2,34 @@
|
||||
|
||||
Updated: 2026-02-17
|
||||
|
||||
## Latest Milestone (2026-02-17): Connectivity Event-Driven Background Sync
|
||||
|
||||
Implemented online-transition-triggered sync so background sync reacts faster
|
||||
when network returns:
|
||||
|
||||
- Added connectivity stream support to reachability abstraction:
|
||||
- `lib/core/network/reachability/network_reachability.dart`
|
||||
- new `watch(...)` API for reachability change events
|
||||
- Added IO implementation using connectivity plugin + internet probe:
|
||||
- `lib/core/network/reachability/network_reachability_io.dart`
|
||||
- `connectivity_plus` integrated as transport signal source
|
||||
- Updated background runner:
|
||||
- `lib/features/sync/sync_background_runner.dart`
|
||||
- now listens for offline -> online transitions and triggers immediate sync
|
||||
- keeps existing startup/resume/periodic behavior
|
||||
- Added coverage:
|
||||
- `test/features/sync/sync_background_runner_test.dart`
|
||||
- verifies transition-trigger behavior and avoids duplicate triggers when
|
||||
already online
|
||||
- Updated setup docs:
|
||||
- `docs/SETUP.md` background sync section now includes connectivity-return
|
||||
behavior note
|
||||
|
||||
Validation for this milestone:
|
||||
|
||||
- `flutter analyze` -> pass
|
||||
- `flutter test` -> pass
|
||||
|
||||
## Latest Milestone (2026-02-17): Sync Repair Test Stability + Mobile CRUD/Sync Flow Coverage
|
||||
|
||||
Completed two test-depth upgrades:
|
||||
@@ -313,8 +341,8 @@ Validation for this milestone:
|
||||
- future enhancement: auto-select and highlight the exact row in main feature
|
||||
screens after save.
|
||||
3. Sync trigger maturity:
|
||||
- implemented via reachability gating; future improvement is explicit OS
|
||||
connectivity event subscription.
|
||||
- implemented via reachability gating + online-transition event triggers.
|
||||
- future: add richer platform-specific network quality awareness if needed.
|
||||
4. Test depth:
|
||||
- expanded with mobile compact CRUD+sync flow and rejected-repair flow.
|
||||
- future: add desktop + web-specific end-to-end sync error/retry scenario.
|
||||
|
||||
@@ -6,6 +6,9 @@ import 'package:relationship_saver/core/network/reachability/network_reachabilit
|
||||
abstract class NetworkReachability {
|
||||
/// Returns `true` when network looks reachable.
|
||||
Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)});
|
||||
|
||||
/// Emits reachability updates when platform network status changes.
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)});
|
||||
}
|
||||
|
||||
/// Returns platform implementation for network reachability probing.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
|
||||
|
||||
class IoNetworkReachability implements NetworkReachability {
|
||||
IoNetworkReachability({Connectivity? connectivity})
|
||||
: _connectivity = connectivity ?? Connectivity();
|
||||
|
||||
final Connectivity _connectivity;
|
||||
|
||||
@override
|
||||
Future<bool> isReachable({
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
@@ -19,6 +25,35 @@ class IoNetworkReachability implements NetworkReachability {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
|
||||
final Stream<dynamic> changes =
|
||||
_connectivity.onConnectivityChanged as Stream<dynamic>;
|
||||
return changes
|
||||
.asyncMap((dynamic event) async {
|
||||
final Iterable<ConnectivityResult> results =
|
||||
_normalizeConnectivityResults(event);
|
||||
final bool hasTransport = results.any(
|
||||
(ConnectivityResult result) => result != ConnectivityResult.none,
|
||||
);
|
||||
if (!hasTransport) {
|
||||
return false;
|
||||
}
|
||||
return isReachable(timeout: timeout);
|
||||
})
|
||||
.distinct();
|
||||
}
|
||||
|
||||
Iterable<ConnectivityResult> _normalizeConnectivityResults(dynamic event) {
|
||||
if (event is ConnectivityResult) {
|
||||
return <ConnectivityResult>[event];
|
||||
}
|
||||
if (event is List<ConnectivityResult>) {
|
||||
return event;
|
||||
}
|
||||
return const <ConnectivityResult>[];
|
||||
}
|
||||
}
|
||||
|
||||
NetworkReachability createPlatformNetworkReachability() =>
|
||||
|
||||
@@ -7,6 +7,11 @@ class StubNetworkReachability implements NetworkReachability {
|
||||
}) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
|
||||
return const Stream<bool>.empty();
|
||||
}
|
||||
}
|
||||
|
||||
NetworkReachability createPlatformNetworkReachability() =>
|
||||
|
||||
@@ -8,6 +8,11 @@ class WebNetworkReachability implements NetworkReachability {
|
||||
// Web environments vary in available probes; keep optimistic fallback.
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
|
||||
return const Stream<bool>.empty();
|
||||
}
|
||||
}
|
||||
|
||||
NetworkReachability createPlatformNetworkReachability() =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
|
||||
import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
|
||||
@@ -22,10 +23,13 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
|
||||
with WidgetsBindingObserver {
|
||||
late final SyncAutoTriggerController _controller;
|
||||
Timer? _periodicTimer;
|
||||
StreamSubscription<bool>? _reachabilitySubscription;
|
||||
bool? _lastReachable;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final reachability = ref.read(networkReachabilityProvider);
|
||||
_controller = SyncAutoTriggerController(
|
||||
syncNow: () => ref.read(syncCoordinatorProvider).syncNow(),
|
||||
now: DateTime.now,
|
||||
@@ -36,11 +40,12 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
|
||||
if (AppConfig.useFakeBackend) {
|
||||
return true;
|
||||
}
|
||||
return ref.read(networkReachabilityProvider).isReachable();
|
||||
return reachability.isReachable();
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_configurePeriodicRunner();
|
||||
_configureReachabilityRunner(reachability);
|
||||
if (AppConfig.enableBackgroundSync) {
|
||||
unawaited(_controller.trigger(ignoreCooldown: true));
|
||||
}
|
||||
@@ -60,6 +65,7 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_periodicTimer?.cancel();
|
||||
_reachabilitySubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -80,4 +86,22 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
|
||||
unawaited(_controller.trigger());
|
||||
});
|
||||
}
|
||||
|
||||
void _configureReachabilityRunner(NetworkReachability reachability) {
|
||||
if (!AppConfig.enableBackgroundSync) {
|
||||
return;
|
||||
}
|
||||
_reachabilitySubscription = reachability.watch().listen((bool reachable) {
|
||||
final bool? previous = _lastReachable;
|
||||
_lastReachable = reachable;
|
||||
if (reachable && previous == false) {
|
||||
unawaited(_controller.trigger(ignoreCooldown: true));
|
||||
}
|
||||
});
|
||||
unawaited(_primeReachabilityState(reachability));
|
||||
}
|
||||
|
||||
Future<void> _primeReachabilityState(NetworkReachability reachability) async {
|
||||
_lastReachable = await reachability.isReachable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
@@ -145,6 +145,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -560,6 +576,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies:
|
||||
flutter_riverpod: ^3.2.1
|
||||
shared_preferences: ^2.5.4
|
||||
hive_flutter: ^1.1.0
|
||||
connectivity_plus: ^7.0.0
|
||||
timezone: ^0.10.1
|
||||
flutter_local_notifications: ^20.1.0
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
|
||||
import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_background_runner.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('triggers sync when reachability transitions from offline to online', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _TestReachability reachability = _TestReachability(initial: false);
|
||||
addTearDown(reachability.dispose);
|
||||
|
||||
int syncCalls = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
networkReachabilityProvider.overrideWithValue(reachability),
|
||||
syncCoordinatorProvider.overrideWith(
|
||||
(Ref ref) =>
|
||||
_TestSyncCoordinator(ref, onSyncNow: () async {
|
||||
syncCalls += 1;
|
||||
return _okResult();
|
||||
}),
|
||||
),
|
||||
],
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SyncBackgroundRunner(child: SizedBox()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
|
||||
// Startup trigger always runs once when background sync is enabled.
|
||||
expect(syncCalls, 1);
|
||||
|
||||
reachability.emit(false);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 40));
|
||||
expect(syncCalls, 1);
|
||||
|
||||
reachability.emit(true);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
expect(syncCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('does not trigger extra sync while connectivity stays online', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _TestReachability reachability = _TestReachability(initial: true);
|
||||
addTearDown(reachability.dispose);
|
||||
|
||||
int syncCalls = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
networkReachabilityProvider.overrideWithValue(reachability),
|
||||
syncCoordinatorProvider.overrideWith(
|
||||
(Ref ref) =>
|
||||
_TestSyncCoordinator(ref, onSyncNow: () async {
|
||||
syncCalls += 1;
|
||||
return _okResult();
|
||||
}),
|
||||
),
|
||||
],
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SyncBackgroundRunner(child: SizedBox()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
|
||||
expect(syncCalls, 1);
|
||||
|
||||
reachability.emit(true);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
|
||||
expect(syncCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestSyncCoordinator extends SyncCoordinator {
|
||||
_TestSyncCoordinator(super.ref, {required this.onSyncNow});
|
||||
|
||||
final Future<SyncRunResult> Function() onSyncNow;
|
||||
|
||||
@override
|
||||
Future<SyncRunResult> syncNow() => onSyncNow();
|
||||
}
|
||||
|
||||
class _TestReachability implements NetworkReachability {
|
||||
_TestReachability({required bool initial}) : _current = initial;
|
||||
|
||||
final StreamController<bool> _controller = StreamController<bool>.broadcast();
|
||||
bool _current;
|
||||
|
||||
@override
|
||||
Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)}) async {
|
||||
return _current;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
|
||||
return _controller.stream;
|
||||
}
|
||||
|
||||
void emit(bool reachable) {
|
||||
_current = reachable;
|
||||
_controller.add(reachable);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
SyncRunResult _okResult() {
|
||||
return const SyncRunResult(
|
||||
success: true,
|
||||
operation: 'sync',
|
||||
message: 'ok',
|
||||
pendingAfter: 0,
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user