From fcee659d1dd7e8087e73b74dbe994704d8d85677 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 19 Dec 2025 13:06:45 +0700 Subject: [PATCH 1/3] feat(network): add NetworkService for network availability monitoring --- CHANGELOG.md | 1 + android/app/src/main/AndroidManifest.xml | 2 + lib/core/di/di.dart | 10 ++ .../services/network/network_service.dart | 17 ++ .../network/network_service_impl.dart | 53 ++++++ pubspec.lock | 32 ++++ pubspec.yaml | 1 + .../network/network_service_test.dart | 165 ++++++++++++++++++ 8 files changed, 281 insertions(+) create mode 100644 lib/core/services/network/network_service.dart create mode 100644 lib/core/services/network/network_service_impl.dart create mode 100644 test/core/services/network/network_service_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e3dd1..544c109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `.dockerignore` for optimized build context - GitHub Actions workflow: code analysis, Android (Docker + DockerHub), iOS (macOS runner) - Automated quality checks, artifact uploads, and build summaries +- Added `NetworkService` for monitoring network availability with `connectivity_plus` integration and unit tests. ### Changed diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e10f231..ccaa432 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + ()) : DebugAnalyticsImpl(di()), ); + + /// Connectivity + di.registerLazySingleton(() => Connectivity()); + di.registerLazySingleton( + () => NetworkServiceImpl(di()), + dispose: (param) => param.dispose(), + ); } diff --git a/lib/core/services/network/network_service.dart b/lib/core/services/network/network_service.dart new file mode 100644 index 0000000..3553987 --- /dev/null +++ b/lib/core/services/network/network_service.dart @@ -0,0 +1,17 @@ +/// Abstract interface for monitoring network availability. +abstract interface class NetworkService { + /// Stream that emits network availability changes. + Stream get connectionStream; + + /// Latest known network availability value. + bool get isConnected; + + /// Initializes the service, performing the initial connectivity check. + Future init(); + + /// Performs a one-shot check of current network availability. + Future hasNetwork(); + + /// Releases resources (stream subscription, controllers). + Future dispose(); +} diff --git a/lib/core/services/network/network_service_impl.dart b/lib/core/services/network/network_service_impl.dart new file mode 100644 index 0000000..742277b --- /dev/null +++ b/lib/core/services/network/network_service_impl.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'network_service.dart'; + +/// Implementation of [NetworkService] using connectivity_plus package. +final class NetworkServiceImpl implements NetworkService { + final Connectivity _connectivity; + + final _controller = StreamController.broadcast(); + late final StreamSubscription> _subscription; + bool? _lastHasNetwork; + + /// Creates an instance of [NetworkServiceImpl]. + NetworkServiceImpl(this._connectivity) { + _subscription = _connectivity.onConnectivityChanged.listen(_onConnectivityChanged); + } + + void _onConnectivityChanged(List results) { + _emit(!results.contains(ConnectivityResult.none)); + } + + /// Emits [hasNetwork] to [connectionStream] when it changes. + void _emit(bool hasNetwork) { + if (_lastHasNetwork == hasNetwork) return; + _lastHasNetwork = hasNetwork; + if (!_controller.isClosed) _controller.add(hasNetwork); + } + + @override + Future init() async { + _emit(await hasNetwork()); + } + + @override + Future hasNetwork() async { + final results = await _connectivity.checkConnectivity(); + return !results.contains(ConnectivityResult.none); + } + + @override + Stream get connectionStream => _controller.stream; + + @override + bool get isConnected => _lastHasNetwork ?? false; + + @override + Future dispose() async { + await _subscription.cancel(); + await _controller.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock index dfdb3df..605d50b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,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: @@ -249,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" diff_match_patch: dependency: transitive description: @@ -757,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + 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 2b846de..1d27e02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: bloc_test: ^10.0.0 cached_network_image: ^3.4.1 cloud_firestore: ^6.0.3 + connectivity_plus: ^7.0.0 dio: ^5.9.0 equatable: ^2.0.7 firebase_analytics: ^12.0.4 diff --git a/test/core/services/network/network_service_test.dart b/test/core/services/network/network_service_test.dart new file mode 100644 index 0000000..5392946 --- /dev/null +++ b/test/core/services/network/network_service_test.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_starter_template/core/services/network/network_service_impl.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'network_service_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late Connectivity connectivity; + + setUp(() { + connectivity = MockConnectivity(); + + when( + connectivity.onConnectivityChanged, + ).thenAnswer((_) => const Stream>.empty()); + }); + + group('NetworkServiceImpl', () { + test('init sets isConnected=true when network is available (wifi)', () async { + // Arrange + when( + connectivity.checkConnectivity(), + ).thenAnswer((_) async => [ConnectivityResult.wifi]); + + final service = NetworkServiceImpl(connectivity); + addTearDown(() async => await service.dispose()); + + // Act + await service.init(); + + // Assert + expect(service.isConnected, isTrue); + verify(connectivity.checkConnectivity()).called(1); + }); + + test('init sets isConnected=false when no network is available (none)', () async { + // Arrange + when( + connectivity.checkConnectivity(), + ).thenAnswer((_) async => [ConnectivityResult.none]); + + final service = NetworkServiceImpl(connectivity); + addTearDown(() async => await service.dispose()); + + // Act + await service.init(); + + // Assert + expect(service.isConnected, isFalse); + verify(connectivity.checkConnectivity()).called(1); + }); + + test( + 'connectionStream emits true when connectivity changes to wifi', + () async { + // Arrange + final controller = StreamController>(); + addTearDown(() async => await controller.close()); + + when(connectivity.onConnectivityChanged).thenAnswer((_) => controller.stream); + + final service = NetworkServiceImpl(connectivity); + addTearDown(() async => await service.dispose()); + + final events = []; + final sub = service.connectionStream.listen(events.add); + addTearDown(() async => await sub.cancel()); + + // Act + controller.add([ConnectivityResult.wifi]); + await pumpEventQueue(); + + // Assert + expect(events, [true]); + }, + ); + + test( + 'connectionStream emits values in order for multiple connectivity changes', + () async { + // Arrange + final controller = StreamController>(); + + addTearDown(() async => await controller.close()); + when(connectivity.onConnectivityChanged).thenAnswer((_) => controller.stream); + + final service = NetworkServiceImpl(connectivity); + addTearDown(() async => await service.dispose()); + + final events = []; + final sub = service.connectionStream.listen(events.add); + addTearDown(() async => await sub.cancel()); + + // Act + controller.add([ConnectivityResult.none]); + await pumpEventQueue(); + + controller.add([ConnectivityResult.wifi]); + await pumpEventQueue(); + + controller.add([ConnectivityResult.none]); + await pumpEventQueue(); + + // Assert + expect(events, [false, true, false]); + }, + ); + + test('connectionStream does not emit duplicates when state does not change', () async { + // Arrange + final controller = StreamController>(); + addTearDown(() async => await controller.close()); + + when(connectivity.onConnectivityChanged).thenAnswer((_) => controller.stream); + + final service = NetworkServiceImpl(connectivity); + addTearDown(() async => await service.dispose()); + + final events = []; + final sub = service.connectionStream.listen(events.add); + addTearDown(() async => await sub.cancel()); + + // Act + controller.add([ConnectivityResult.wifi]); + await pumpEventQueue(); + + controller.add([ConnectivityResult.mobile]); + await pumpEventQueue(); + + controller.add([ConnectivityResult.wifi]); + await pumpEventQueue(); + + // Assert + expect(events, [true]); + }); + + test('dispose cancels subscription and stops emitting further updates', () async { + // Arrange + final controller = StreamController>(); + addTearDown(() async => await controller.close()); + + when(connectivity.onConnectivityChanged).thenAnswer((_) => controller.stream); + + final service = NetworkServiceImpl(connectivity); + + final events = []; + final subscription = service.connectionStream.listen(events.add); + addTearDown(() async => await subscription.cancel()); + + // Act + await service.dispose(); + + controller.add([ConnectivityResult.wifi]); + await pumpEventQueue(); + + // Assert + expect(events, isEmpty); + }); + }); +} From 4097551e716771b51f624c372c056af6b0fb579e Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 19 Dec 2025 13:13:45 +0700 Subject: [PATCH 2/3] chore(ios): update Podfile.lock and project.pbxproj for dependency version upgrades --- ios/Podfile.lock | 22 ++++++++++++++-------- ios/Runner.xcodeproj/project.pbxproj | 4 ---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c50f2a..293e77e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1194,10 +1194,12 @@ PODS: - BoringSSL-GRPC/Implementation (0.0.37): - BoringSSL-GRPC/Interface (= 0.0.37) - BoringSSL-GRPC/Interface (0.0.37) - - cloud_firestore (6.0.3): + - cloud_firestore (6.1.0): - Firebase/Firestore (= 12.4.0) - firebase_core - Flutter + - connectivity_plus (0.0.1): + - Flutter - Firebase/Auth (12.4.0): - Firebase/CoreOnly - FirebaseAuth (~> 12.4.0) @@ -1216,18 +1218,18 @@ PODS: - firebase_core - FirebaseAnalytics (= 12.4.0) - Flutter - - firebase_auth (6.1.1): + - firebase_auth (6.1.2): - Firebase/Auth (= 12.4.0) - firebase_core - Flutter - firebase_core (4.2.1): - Firebase/CoreOnly (= 12.4.0) - Flutter - - firebase_crashlytics (5.0.4): + - firebase_crashlytics (5.0.5): - Firebase/Crashlytics (= 12.4.0) - firebase_core - Flutter - - firebase_storage (13.0.3): + - firebase_storage (13.0.4): - Firebase/Storage (= 12.4.0) - firebase_core - Flutter @@ -1493,6 +1495,7 @@ PODS: DEPENDENCIES: - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -1539,6 +1542,8 @@ SPEC REPOS: EXTERNAL SOURCES: cloud_firestore: :path: ".symlinks/plugins/cloud_firestore/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" firebase_analytics: :path: ".symlinks/plugins/firebase_analytics/ios" firebase_auth: @@ -1561,13 +1566,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: abseil: a05cc83bf02079535e17169a73c5be5ba47f714b BoringSSL-GRPC: dded2a44897e45f28f08ae87a55ee4bcd19bc508 - cloud_firestore: 79014bb3b303d451717ed5fe69fded8a2b2e8dc2 + cloud_firestore: 7a6d8a533ec7418a7fe46b3a5dabf55661a5b298 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf - firebase_auth: c2b8be95d602d4e8a9148fae72333ef78e69cc20 + firebase_auth: 9225db04db5d8e3b46dc8940e04bc6aec6833e27 firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 - firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464 - firebase_storage: 0ba617a05b24aec050395e4d5d3773c0d7518a15 + firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081 + firebase_storage: d558bbfa99449fff39352db83c466c8515fa1afa FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f FirebaseAppCheckInterop: f734c802f21fe1da0837708f0f9a27218c8a4ed0 FirebaseAuth: 4a2aed737c84114a9d9b33d11ae1b147d6b94889 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0439783..fd06209 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -341,14 +341,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; From 75b14dad51f7f568784f9bc6db453536d0573f6c Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 19 Dec 2025 14:23:49 +0700 Subject: [PATCH 3/3] fix(ci): free disk space before docker build --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 34a1348..8917208 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,6 +90,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + + - name: Free disk space + uses: endersonmenezes/free-disk-space@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3