-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 앱 Lifecycle 관리 및 네트워크 상태 체크 추가 #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BottleNoteColors>()!; | ||
|
|
||
| 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, | ||
| ), | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NetworkStatus> _networkStatusController = | ||
| StreamController<NetworkStatus>.broadcast(); | ||
|
|
||
| Stream<NetworkStatus> get networkStatusStream => | ||
| _networkStatusController.stream; | ||
| NetworkStatus _currentStatus = NetworkStatus.online; | ||
| NetworkStatus get currentStatus => _currentStatus; | ||
|
|
||
| StreamSubscription<List<ConnectivityResult>>? _subscription; | ||
| bool _isDisposed = false; | ||
|
|
||
| ConnectivityService({required Logger logger}) : _logger = logger; | ||
|
|
||
| Future<void> initialize() async { | ||
| final results = await _connectivity.checkConnectivity(); | ||
| _updateStatus(results); | ||
|
|
||
| _subscription = _connectivity.onConnectivityChanged.listen((results) { | ||
| _updateStatus(results); | ||
| }); | ||
| } | ||
|
|
||
| void _updateStatus(List<ConnectivityResult> 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<bool> checkConnectivity() async { | ||
| final results = await _connectivity.checkConnectivity(); | ||
| return results.any((result) => result != ConnectivityResult.none); | ||
| } | ||
|
|
||
| void dispose() { | ||
| _isDisposed = true; | ||
| _subscription?.cancel(); | ||
| _networkStatusController.close(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BottleNoteWebView> | |
|
|
||
| bool _isAppLoading = false; | ||
| late String _url = ''; | ||
| bool _isWebViewCreated = false; | ||
|
|
||
| // Network status | ||
| late ConnectivityService _connectivityService; | ||
| StreamSubscription<NetworkStatus>? _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,13 +72,75 @@ class BottleNoteWebViewState extends State<BottleNoteWebView> | |
| ]); | ||
| } | ||
|
|
||
| Future<void> _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; | ||
| } | ||
| } | ||
|
Comment on lines
+124
to
+133
|
||
|
|
||
| void _setupPullToRefresh() { | ||
| _pullToRefreshController = PullToRefreshController( | ||
| settings: PullToRefreshSettings( | ||
| color: const Color(0xffe58257), | ||
| 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<BottleNoteWebView> | |
| @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<void> _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), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+168
to
+187
|
||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final colors = Theme.of(context).extension<BottleNoteColors>()!; | ||
|
|
@@ -106,7 +210,9 @@ class BottleNoteWebViewState extends State<BottleNoteWebView> | |
| }, | ||
| child: Scaffold( | ||
| backgroundColor: Colors.white, | ||
| body: _buildBody(colors), | ||
| body: _isOffline && !_initialLoadCompleted | ||
| ? OfflineScreen(onRetry: _retryConnection) | ||
| : _buildBody(colors), | ||
|
Comment on lines
+213
to
+215
|
||
| ), | ||
| ); | ||
| } | ||
|
|
@@ -131,6 +237,7 @@ class BottleNoteWebViewState extends State<BottleNoteWebView> | |
| pullToRefreshController: _pullToRefreshController, | ||
| onWebViewCreated: (controller) { | ||
| _webviewController = controller; | ||
| _isWebViewCreated = true; | ||
| _webViewBridgeHandler = WebViewBridgeHandler( | ||
| controller: controller, | ||
| logger: logger, | ||
|
|
@@ -153,6 +260,7 @@ class BottleNoteWebViewState extends State<BottleNoteWebView> | |
| onLoadStart: (controller, url) {}, | ||
| onLoadStop: (controller, url) async { | ||
| _pullToRefreshController.endRefreshing(); | ||
| _initialLoadCompleted = true; | ||
|
|
||
| // 웹뷰가 완전히 로드된 후 JavaScript 초기화 | ||
| if (_webViewBridgeHandler != null) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P0]
_webviewController가late로 선언되어 있지만 WebView 생성 전(onWebViewCreated콜백 실행 전)에 네트워크 상태 변경 또는 앱 복귀 시reload()가 호출될 수 있습니다. 이 경우LateInitializationError로 앱이 크래시됩니다.