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