diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5543d2e..1276eb7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 332073c..967c781 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Firebase/CoreOnly (11.6.0): - FirebaseCore (~> 11.6.0) - Firebase/Messaging (11.6.0): @@ -95,6 +97,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) @@ -123,6 +126,8 @@ SPEC REPOS: - PromisesObjC EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: @@ -151,6 +156,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Firebase: 374a441a91ead896215703a674d58cdb3e9d772b firebase_core: 2337982fb78ee4d8d91e608b0a3d4f44346a93c8 firebase_messaging: f3bddfa28c2cad70b3341bf461e987a24efd28d6 diff --git a/lib/ui/offline_screen.dart b/lib/ui/offline_screen.dart new file mode 100644 index 0000000..707c484 --- /dev/null +++ b/lib/ui/offline_screen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../main.dart'; + +class OfflineScreen extends StatelessWidget { + final VoidCallback onRetry; + + const OfflineScreen({ + super.key, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + + return Container( + color: Colors.white, + child: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_off_rounded, + size: 80, + color: colors.brightGray, + ), + const SizedBox(height: 24), + Text( + '인터넷 연결 없음', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colors.mainBlack, + ), + ), + const SizedBox(height: 12), + Text( + '네트워크 연결 상태를 확인하고\n다시 시도해 주세요.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: colors.mainGray, + height: 1.5, + ), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: colors.subCoral, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 48, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '다시 시도', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/utils/network/connectivity_service.dart b/lib/utils/network/connectivity_service.dart new file mode 100644 index 0000000..3f6ea10 --- /dev/null +++ b/lib/utils/network/connectivity_service.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:logger/logger.dart'; + +enum NetworkStatus { online, offline } + +class ConnectivityService { + final Connectivity _connectivity = Connectivity(); + final Logger _logger; + + final StreamController _networkStatusController = + StreamController.broadcast(); + + Stream get networkStatusStream => + _networkStatusController.stream; + NetworkStatus _currentStatus = NetworkStatus.online; + NetworkStatus get currentStatus => _currentStatus; + + StreamSubscription>? _subscription; + bool _isDisposed = false; + + ConnectivityService({required Logger logger}) : _logger = logger; + + Future initialize() async { + final results = await _connectivity.checkConnectivity(); + _updateStatus(results); + + _subscription = _connectivity.onConnectivityChanged.listen((results) { + _updateStatus(results); + }); + } + + void _updateStatus(List results) { + if (_isDisposed) return; + + final isConnected = + results.any((result) => result != ConnectivityResult.none); + + final newStatus = isConnected ? NetworkStatus.online : NetworkStatus.offline; + + if (newStatus != _currentStatus) { + _currentStatus = newStatus; + if (!_networkStatusController.isClosed) { + _networkStatusController.add(newStatus); + } + _logger.d('Network status changed: $newStatus'); + } + } + + Future checkConnectivity() async { + final results = await _connectivity.checkConnectivity(); + return results.any((result) => result != ConnectivityResult.none); + } + + void dispose() { + _isDisposed = true; + _subscription?.cancel(); + _networkStatusController.close(); + } +} diff --git a/lib/web_view/web_view.dart b/lib/web_view/web_view.dart index 03970d2..4a2561d 100644 --- a/lib/web_view/web_view.dart +++ b/lib/web_view/web_view.dart @@ -1,6 +1,9 @@ +import 'dart:async'; import 'dart:collection'; import 'package:app/bridge/web_view_bridge_handler.dart'; +import 'package:app/utils/network/connectivity_service.dart'; +import 'package:app/ui/offline_screen.dart'; import 'package:app/bridge/web_view_navigation_handler.dart'; import 'package:app/main.dart'; import 'package:app/utils/env/env.dart'; @@ -40,12 +43,25 @@ class BottleNoteWebViewState extends State bool _isAppLoading = false; late String _url = ''; + bool _isWebViewCreated = false; + + // Network status + late ConnectivityService _connectivityService; + StreamSubscription? _networkSubscription; + bool _isOffline = false; + bool _initialLoadCompleted = false; + + // Lifecycle + DateTime? _backgroundTime; + static const Duration _refreshThreshold = Duration(minutes: 5); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _navigationHandler = WebViewNavigationHandler(logger: logger); + _connectivityService = ConnectivityService(logger: logger); + _initializeConnectivity(); _permissionWithNotification(); _setupPullToRefresh(); _initialUserScripts = UnmodifiableListView([ @@ -56,6 +72,66 @@ class BottleNoteWebViewState extends State ]); } + Future _initializeConnectivity() async { + await _connectivityService.initialize(); + + if (!mounted) return; + + final isConnected = await _connectivityService.checkConnectivity(); + if (!mounted) return; + + if (!isConnected) { + setState(() { + _isOffline = true; + }); + } + + _networkSubscription = + _connectivityService.networkStatusStream.listen((status) { + if (!mounted) return; + + final wasOffline = _isOffline; + setState(() { + _isOffline = status == NetworkStatus.offline; + }); + + // Offline -> Online: auto reload + if (wasOffline && + status == NetworkStatus.online && + _initialLoadCompleted && + _isWebViewCreated) { + _webviewController.reload(); + } + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.paused: + _backgroundTime = DateTime.now(); + break; + case AppLifecycleState.resumed: + _handleAppResumed(); + break; + default: + break; + } + } + + void _handleAppResumed() { + if (_backgroundTime != null && _initialLoadCompleted && _isWebViewCreated) { + final duration = DateTime.now().difference(_backgroundTime!); + if (duration > _refreshThreshold) { + logger.d('Long background duration: ${duration.inSeconds}s. Reloading WebView.'); + _webviewController.reload(); + } + _backgroundTime = null; + } + } + void _setupPullToRefresh() { _pullToRefreshController = PullToRefreshController( settings: PullToRefreshSettings( @@ -63,6 +139,8 @@ class BottleNoteWebViewState extends State backgroundColor: Colors.white, ), onRefresh: () async { + if (!_isWebViewCreated) return; + if (Platform.isAndroid) { _webviewController.reload(); } else if (Platform.isIOS) { @@ -77,11 +155,37 @@ class BottleNoteWebViewState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); + // Cancel network subscription first to prevent callbacks on disposed controller + _networkSubscription?.cancel(); + _connectivityService.dispose(); _pullToRefreshController.dispose(); - _webviewController.dispose(); + if (_isWebViewCreated) { + _webviewController.dispose(); + } super.dispose(); } + Future _retryConnection() async { + final isConnected = await _connectivityService.checkConnectivity(); + if (!mounted) return; + + if (isConnected) { + setState(() { + _isOffline = false; + }); + if (_initialLoadCompleted && _isWebViewCreated) { + _webviewController.reload(); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('인터넷 연결을 확인해 주세요.'), + duration: Duration(seconds: 2), + ), + ); + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; @@ -106,7 +210,9 @@ class BottleNoteWebViewState extends State }, child: Scaffold( backgroundColor: Colors.white, - body: _buildBody(colors), + body: _isOffline && !_initialLoadCompleted + ? OfflineScreen(onRetry: _retryConnection) + : _buildBody(colors), ), ); } @@ -131,6 +237,7 @@ class BottleNoteWebViewState extends State pullToRefreshController: _pullToRefreshController, onWebViewCreated: (controller) { _webviewController = controller; + _isWebViewCreated = true; _webViewBridgeHandler = WebViewBridgeHandler( controller: controller, logger: logger, @@ -153,6 +260,7 @@ class BottleNoteWebViewState extends State onLoadStart: (controller, url) {}, onLoadStop: (controller, url) async { _pullToRefreshController.endRefreshing(); + _initialLoadCompleted = true; // 웹뷰가 완전히 로드된 후 JavaScript 초기화 if (_webViewBridgeHandler != null) { diff --git a/pubspec.lock b/pubspec.lock index 0bae6c9..2fc179f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + 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: @@ -848,6 +864,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fe465a7..ac13787 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: kakao_flutter_sdk: ^1.9.7+3 sign_in_with_apple: ^6.1.4 url_launcher: ^6.3.1 + connectivity_plus: ^6.0.5 envied: ^1.1.1 flutter_svg: ^2.0.10+1 svg_path_parser: ^1.1.2