diff --git a/docs/SETUP.md b/docs/SETUP.md index 5015df2..db7b850 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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 diff --git a/docs/progress.md b/docs/progress.md index f51f5ea..779a4f4 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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. diff --git a/lib/core/network/reachability/network_reachability.dart b/lib/core/network/reachability/network_reachability.dart index 39a5401..aa90655 100644 --- a/lib/core/network/reachability/network_reachability.dart +++ b/lib/core/network/reachability/network_reachability.dart @@ -6,6 +6,9 @@ import 'package:relationship_saver/core/network/reachability/network_reachabilit abstract class NetworkReachability { /// Returns `true` when network looks reachable. Future isReachable({Duration timeout = const Duration(seconds: 2)}); + + /// Emits reachability updates when platform network status changes. + Stream watch({Duration timeout = const Duration(seconds: 2)}); } /// Returns platform implementation for network reachability probing. diff --git a/lib/core/network/reachability/network_reachability_io.dart b/lib/core/network/reachability/network_reachability_io.dart index e616181..063a2ed 100644 --- a/lib/core/network/reachability/network_reachability_io.dart +++ b/lib/core/network/reachability/network_reachability_io.dart @@ -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 isReachable({ Duration timeout = const Duration(seconds: 2), @@ -19,6 +25,35 @@ class IoNetworkReachability implements NetworkReachability { return false; } } + + @override + Stream watch({Duration timeout = const Duration(seconds: 2)}) { + final Stream changes = + _connectivity.onConnectivityChanged as Stream; + return changes + .asyncMap((dynamic event) async { + final Iterable results = + _normalizeConnectivityResults(event); + final bool hasTransport = results.any( + (ConnectivityResult result) => result != ConnectivityResult.none, + ); + if (!hasTransport) { + return false; + } + return isReachable(timeout: timeout); + }) + .distinct(); + } + + Iterable _normalizeConnectivityResults(dynamic event) { + if (event is ConnectivityResult) { + return [event]; + } + if (event is List) { + return event; + } + return const []; + } } NetworkReachability createPlatformNetworkReachability() => diff --git a/lib/core/network/reachability/network_reachability_stub.dart b/lib/core/network/reachability/network_reachability_stub.dart index 8926156..9bfac69 100644 --- a/lib/core/network/reachability/network_reachability_stub.dart +++ b/lib/core/network/reachability/network_reachability_stub.dart @@ -7,6 +7,11 @@ class StubNetworkReachability implements NetworkReachability { }) async { return true; } + + @override + Stream watch({Duration timeout = const Duration(seconds: 2)}) { + return const Stream.empty(); + } } NetworkReachability createPlatformNetworkReachability() => diff --git a/lib/core/network/reachability/network_reachability_web.dart b/lib/core/network/reachability/network_reachability_web.dart index 4842d88..a4e66be 100644 --- a/lib/core/network/reachability/network_reachability_web.dart +++ b/lib/core/network/reachability/network_reachability_web.dart @@ -8,6 +8,11 @@ class WebNetworkReachability implements NetworkReachability { // Web environments vary in available probes; keep optimistic fallback. return true; } + + @override + Stream watch({Duration timeout = const Duration(seconds: 2)}) { + return const Stream.empty(); + } } NetworkReachability createPlatformNetworkReachability() => diff --git a/lib/features/sync/sync_background_runner.dart b/lib/features/sync/sync_background_runner.dart index 9686dea..6eae24d 100644 --- a/lib/features/sync/sync_background_runner.dart +++ b/lib/features/sync/sync_background_runner.dart @@ -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 with WidgetsBindingObserver { late final SyncAutoTriggerController _controller; Timer? _periodicTimer; + StreamSubscription? _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 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 void dispose() { WidgetsBinding.instance.removeObserver(this); _periodicTimer?.cancel(); + _reachabilitySubscription?.cancel(); super.dispose(); } @@ -80,4 +86,22 @@ class _SyncBackgroundRunnerState extends ConsumerState 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 _primeReachabilityState(NetworkReachability reachability) async { + _lastReachable = await reachability.isReachable(); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 157bb5d..4ce058e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index d7e7dc6..71ed454 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index ecce72e..548dd88 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/features/sync/sync_background_runner_test.dart b/test/features/sync/sync_background_runner_test.dart new file mode 100644 index 0000000..65e7ae2 --- /dev/null +++ b/test/features/sync/sync_background_runner_test.dart @@ -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 Function() onSyncNow; + + @override + Future syncNow() => onSyncNow(); +} + +class _TestReachability implements NetworkReachability { + _TestReachability({required bool initial}) : _current = initial; + + final StreamController _controller = StreamController.broadcast(); + bool _current; + + @override + Future isReachable({Duration timeout = const Duration(seconds: 2)}) async { + return _current; + } + + @override + Stream watch({Duration timeout = const Duration(seconds: 2)}) { + return _controller.stream; + } + + void emit(bool reachable) { + _current = reachable; + _controller.add(reachable); + } + + Future dispose() async { + await _controller.close(); + } +} + +SyncRunResult _okResult() { + return const SyncRunResult( + success: true, + operation: 'sync', + message: 'ok', + pendingAfter: 0, + ); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..af1f996 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 197d43d..71d65ea 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus flutter_secure_storage_windows )