Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</queries>

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Expand Down
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions lib/ui/offline_screen.dart
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,
),
),
),
],
),
),
),
);
}
}
60 changes: 60 additions & 0 deletions lib/utils/network/connectivity_service.dart
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();
}
}
112 changes: 110 additions & 2 deletions lib/web_view/web_view.dart
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';
Expand Down Expand Up @@ -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([
Expand All @@ -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();
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] _webviewControllerlate로 선언되어 있지만 WebView 생성 전(onWebViewCreated 콜백 실행 전)에 네트워크 상태 변경 또는 앱 복귀 시 reload()가 호출될 수 있습니다. 이 경우 LateInitializationError로 앱이 크래시됩니다.

Suggested change
_webviewController.reload();
try {
_webviewController.reload();
} on LateInitializationError catch (e, stackTrace) {
logger.w('WebViewController not initialized yet on connectivity change', e, stackTrace);
}

Copilot uses AI. Check for mistakes.
}
});
}

@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
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] _handleAppResumed()에서 _webviewController.reload() 호출 시 컨트롤러 초기화 여부를 확인하지 않습니다. 앱 시작 직후(WebView 생성 전) 백그라운드→포그라운드 전환이 발생하면 LateInitializationError로 크래시됩니다.

Copilot uses AI. Check for mistakes.

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) {
Expand All @@ -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
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] _retryConnection()에서 비동기 작업 완료 후 setState()_webviewController.reload() 호출 전 mounted 체크가 없습니다. 재시도 중 위젯이 dispose되면 크래시가 발생합니다.

Copilot uses AI. Check for mistakes.

@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<BottleNoteColors>()!;
Expand All @@ -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
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 오프라인 화면 표시 조건이 _isOffline && !_initialLoadCompleted로 제한되어 있습니다. 이미 로드가 완료된 후(_initialLoadCompleted == true) 오프라인 상태가 되면 사용자에게 네트워크 끊김을 알릴 방법이 없습니다. 온라인 복구 시 자동 새로고침만 되고 오프라인 상태에 대한 피드백이 없습니다.

Copilot uses AI. Check for mistakes.
),
);
}
Expand All @@ -131,6 +237,7 @@ class BottleNoteWebViewState extends State<BottleNoteWebView>
pullToRefreshController: _pullToRefreshController,
onWebViewCreated: (controller) {
_webviewController = controller;
_isWebViewCreated = true;
_webViewBridgeHandler = WebViewBridgeHandler(
controller: controller,
logger: logger,
Expand All @@ -153,6 +260,7 @@ class BottleNoteWebViewState extends State<BottleNoteWebView>
onLoadStart: (controller, url) {},
onLoadStop: (controller, url) async {
_pullToRefreshController.endRefreshing();
_initialLoadCompleted = true;

// 웹뷰가 완전히 로드된 후 JavaScript 초기화
if (_webViewBridgeHandler != null) {
Expand Down
24 changes: 24 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading