From 308b2b86f159f1b4de656bc63bb189e1a43b7087 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 14:07:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=9B=B9=EB=B7=B0=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=B0=94=EC=99=80=20=EC=95=88=EC=A0=84=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/web_view/web_view.dart | 581 +++++++++++++++++++++++++++---------- 1 file changed, 435 insertions(+), 146 deletions(-) diff --git a/lib/web_view/web_view.dart b/lib/web_view/web_view.dart index 30af8b2..8d95b29 100644 --- a/lib/web_view/web_view.dart +++ b/lib/web_view/web_view.dart @@ -31,6 +31,127 @@ class BottleNoteWebView extends StatefulWidget { class BottleNoteWebViewState extends State with WidgetsBindingObserver { + static const String _systemUiThemeHandlerName = 'SystemUiTheme'; + static const Color _refreshIndicatorColor = Color(0xffe58257); + static const String _nativeSafeAreaOverrideScript = r''' + (function() { + function applyNativeSafeAreaOverrides() { + const root = document.documentElement; + if (!root) return; + + if (!document.getElementById('bottle-note-native-safe-area')) { + const style = document.createElement('style'); + style.id = 'bottle-note-native-safe-area'; + style.textContent = ` + html { --navbar-margin-bottom: 8px !important; } + .pt-safe-header { padding-top: 0 !important; } + `; + (document.head || root).appendChild(style); + } + } + + applyNativeSafeAreaOverrides(); + if (document.readyState === 'loading') { + document.addEventListener( + 'DOMContentLoaded', + applyNativeSafeAreaOverrides, + { once: true }, + ); + } + })(); + '''; + static const String _systemUiThemeObserverScript = r''' + (function() { + function isDarkTheme() { + const root = document.documentElement; + const body = document.body; + const rootDark = root.classList.contains('dark') || root.dataset.theme === 'dark'; + const bodyDark = body && + (body.classList.contains('dark') || body.dataset.theme === 'dark'); + + return rootDark || bodyDark; + } + + function opaqueBackgroundOf(element) { + if (!element) return null; + + const color = getComputedStyle(element).backgroundColor; + const channels = color.match(/[\d.]+/g); + if (!channels || channels.length < 3) return null; + + const alpha = channels.length >= 4 ? Number(channels[3]) : 1; + return alpha > 0 ? channels.slice(0, 3).map(Number) : null; + } + + function documentBackground() { + return opaqueBackgroundOf(document.documentElement) || + opaqueBackgroundOf(document.body); + } + + let lastSystemUiState; + + function notifyFlutter() { + const topBackground = documentBackground(); + const systemUiState = JSON.stringify([ + isDarkTheme(), + topBackground, + ]); + + if (systemUiState === lastSystemUiState) return; + lastSystemUiState = systemUiState; + + window.flutter_inappwebview.callHandler( + 'SystemUiTheme', + isDarkTheme(), + topBackground, + ); + } + + let notifyScheduled = false; + + function scheduleNotify() { + if (notifyScheduled) return; + notifyScheduled = true; + + window.requestAnimationFrame(function() { + notifyScheduled = false; + notifyFlutter(); + }); + } + + const observerOptions = { + attributes: true, + attributeFilter: ['class', 'data-theme', 'style'], + }; + + function observeThemeTargets(observer) { + observer.observe(document.documentElement, observerOptions); + if (document.body) { + observer.observe(document.body, observerOptions); + } + } + + if (window.__bottleNoteSystemUiThemeObserver) { + observeThemeTargets(window.__bottleNoteSystemUiThemeObserver); + notifyFlutter(); + return; + } + + const observer = new MutationObserver(scheduleNotify); + window.__bottleNoteSystemUiThemeObserver = observer; + observeThemeTargets(observer); + + if (!document.body && document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + observeThemeTargets(observer); + scheduleNotify(); + }, { once: true }); + } + + notifyFlutter(); + })(); + '''; + final Logger logger = Logger( printer: PrettyPrinter(colors: false), ); @@ -45,6 +166,9 @@ class BottleNoteWebViewState extends State bool _isAppLoading = false; late String _url = ''; bool _isWebViewCreated = false; + bool _hasWebViewTheme = false; + bool _isWebViewDark = false; + Color? _webViewTopColor; // Network status late ConnectivityService _connectivityService; @@ -66,6 +190,11 @@ class BottleNoteWebViewState extends State _permissionWithNotification(); _setupPullToRefresh(); _initialUserScripts = UnmodifiableListView([ + if (Platform.isAndroid) + UserScript( + source: _nativeSafeAreaOverrideScript, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), UserScript( source: BridgeSetupScript.script, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, @@ -116,17 +245,45 @@ class BottleNoteWebViewState extends State break; case AppLifecycleState.resumed: _handleAppResumed(); + _refreshSystemUiAfterConfigurationChange(); break; default: break; } } + @override + void didChangeMetrics() { + super.didChangeMetrics(); + _refreshSystemUiAfterConfigurationChange(); + } + + @override + void didChangePlatformBrightness() { + super.didChangePlatformBrightness(); + _refreshSystemUiAfterConfigurationChange(); + } + + void _refreshSystemUiAfterConfigurationChange() { + if (!mounted) return; + + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _applySystemUiOverlayStyle( + isDark: _effectiveDarkMode, + topColor: _webViewTopColor ?? _fallbackSystemBarColor, + bottomColor: _fallbackSystemBarColor, + ); + }); + } + 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.'); + logger.d( + 'Long background duration: ${duration.inSeconds}s. Reloading WebView.'); _webviewController.reload(); } _backgroundTime = null; @@ -136,7 +293,7 @@ class BottleNoteWebViewState extends State void _setupPullToRefresh() { _pullToRefreshController = PullToRefreshController( settings: PullToRefreshSettings( - color: const Color(0xffe58257), + color: _refreshIndicatorColor, backgroundColor: Colors.white, ), onRefresh: () async { @@ -190,164 +347,296 @@ class BottleNoteWebViewState extends State @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; - - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - systemNavigationBarColor: Colors.white, - systemNavigationBarDividerColor: Colors.transparent, - systemNavigationBarIconBrightness: Brightness.dark, - statusBarColor: Colors.white, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - ), + final isDark = _hasWebViewTheme + ? _isWebViewDark + : MediaQuery.platformBrightnessOf(context) == Brightness.dark; + final fallbackSystemBarColor = isDark ? Colors.black : Colors.white; + final topSystemBarColor = _webViewTopColor ?? fallbackSystemBarColor; + final bottomSystemBarColor = fallbackSystemBarColor; + final systemPadding = MediaQuery.paddingOf(context); + final webViewPadding = Platform.isAndroid ? systemPadding : EdgeInsets.zero; + final systemUiOverlayStyle = _systemUiOverlayStyle( + isDark: isDark, + topColor: topSystemBarColor, + bottomColor: bottomSystemBarColor, ); - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (_backActionHandler != null) { - await _backActionHandler!.action(); - } - }, - child: Scaffold( - backgroundColor: Colors.white, - body: _isOffline && !_initialLoadCompleted - ? OfflineScreen(onRetry: _retryConnection) - : _buildBody(colors), + SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); + + return AnnotatedRegion( + value: systemUiOverlayStyle, + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (_backActionHandler != null) { + await _backActionHandler!.action(); + } + }, + child: Scaffold( + backgroundColor: bottomSystemBarColor, + body: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: webViewPadding, + child: _isOffline && !_initialLoadCompleted + ? OfflineScreen(onRetry: _retryConnection) + : _buildBody(colors), + ), + if (Platform.isAndroid && systemPadding.top > 0) + Positioned( + left: 0, + right: 0, + top: 0, + height: systemPadding.top, + child: IgnorePointer( + child: ColoredBox(color: topSystemBarColor), + ), + ), + if (Platform.isAndroid && systemPadding.bottom > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: systemPadding.bottom, + child: IgnorePointer( + child: ColoredBox(color: bottomSystemBarColor), + ), + ), + ], + ), + ), ), ); } Widget _buildBody(BottleNoteColors colors) { final content = Stack( - children: [ - InAppWebView( - initialUrlRequest: URLRequest( - url: WebUri(widget.initialUrl ?? Env.webViewUrl), - ), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - useShouldOverrideUrlLoading: true, - useShouldInterceptAjaxRequest: true, - mediaPlaybackRequiresUserGesture: false, - allowsInlineMediaPlayback: true, - iframeAllowFullscreen: true, - isInspectable: !kReleaseMode, + children: [ + InAppWebView( + initialUrlRequest: URLRequest( + url: WebUri(widget.initialUrl ?? Env.webViewUrl), + ), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + useShouldOverrideUrlLoading: true, + useShouldInterceptAjaxRequest: true, + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + iframeAllowFullscreen: true, + isInspectable: !kReleaseMode, + ), + initialUserScripts: _initialUserScripts, + pullToRefreshController: _pullToRefreshController, + onWebViewCreated: (controller) { + _webviewController = controller; + _isWebViewCreated = true; + _setupSystemUiThemeHandler(controller); + _webViewBridgeHandler = WebViewBridgeHandler( + controller: controller, + logger: logger, + context: context, + onShowLoading: _showAppLoading, + onHideLoading: _hideAppLoading, + ); + _backActionHandler = BackActionHandler( + webViewController: controller, + context: context, + ); + + _webViewBridgeHandler!.setupJavaScriptChannels(); + }, + onUpdateVisitedHistory: (controller, url, isReload) { + setState(() { + _url = url.toString(); + }); + }, + onLoadStart: (controller, url) {}, + onLoadStop: (controller, url) async { + _pullToRefreshController.endRefreshing(); + _initialLoadCompleted = true; + + if (Platform.isAndroid) { + await controller.evaluateJavascript( + source: _nativeSafeAreaOverrideScript, + ); + } + await controller.evaluateJavascript( + source: _systemUiThemeObserverScript, + ); + + // 웹뷰가 완전히 로드된 후 JavaScript 초기화 + if (_webViewBridgeHandler != null) { + await _webViewBridgeHandler!.setupJavaScriptChannels(); + } + // onLoaded 콜백 호출 + if (widget.onLoaded != null) { + widget.onLoaded!(); + } + }, + onReceivedHttpError: (controller, request, errorResponse) { + _pullToRefreshController.endRefreshing(); + logger.w( + '[HTTP Error] url=${request.url} status=${errorResponse.statusCode} description=${errorResponse.reasonPhrase}', + ); + }, + onReceivedError: (controller, request, error) { + logger.e('WebView error: ${error.description}'); + _pullToRefreshController.endRefreshing(); + }, + onProgressChanged: (controller, progress) {}, + onConsoleMessage: (controller, consoleMessage) { + if (consoleMessage.messageLevel == ConsoleMessageLevel.ERROR) { + logger.e('[WebView Console] ${consoleMessage.message}'); + } + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + return await _navigationHandler + .handleNavigationAction(navigationAction); + }, + shouldInterceptAjaxRequest: (controller, ajaxRequest) async { + return ajaxRequest; + }, + onAjaxReadyStateChange: (controller, ajaxRequest) async { + final isDone = ajaxRequest.readyState == AjaxRequestReadyState.DONE; + final status = ajaxRequest.status; + if (isDone && status != null && status >= 400) { + logger.w( + '[AjaxError] status=$status url=${ajaxRequest.url}', + ); + } + return AjaxRequestAction.PROCEED; + }, + onAjaxProgress: (controller, ajaxRequest) async { + return AjaxRequestAction.PROCEED; + }, + onReceivedServerTrustAuthRequest: (controller, challenge) async { + // Surface SSL challenges to diagnose certificate issues during loading. + logger.w( + '[SSL] host=${challenge.protectionSpace.host} protocol=${challenge.protectionSpace.protocol} error=${challenge.protectionSpace.sslError}', + ); + if (kReleaseMode) { + return ServerTrustAuthResponse( + action: ServerTrustAuthResponseAction.PROCEED, + ); + } + return ServerTrustAuthResponse( + action: ServerTrustAuthResponseAction.PROCEED, + ); + }, + ), + if (_isAppLoading) + LoadingWidget( + isLoading: _isAppLoading, + waveColor: colors.subCoral, + bottleColor: Colors.white, + ), + if (_url.contains('development') || _url.contains('192.')) + Container( + width: double.infinity, + height: 120, + alignment: Alignment.centerLeft, + child: const Padding( + padding: EdgeInsets.only(left: 16.0), + child: Text( + 'development', + style: TextStyle(color: Colors.black12, fontSize: 10), ), - initialUserScripts: _initialUserScripts, - pullToRefreshController: _pullToRefreshController, - onWebViewCreated: (controller) { - _webviewController = controller; - _isWebViewCreated = true; - _webViewBridgeHandler = WebViewBridgeHandler( - controller: controller, - logger: logger, - context: context, - onShowLoading: _showAppLoading, - onHideLoading: _hideAppLoading, - ); - _backActionHandler = BackActionHandler( - webViewController: controller, - context: context, - ); - - _webViewBridgeHandler!.setupJavaScriptChannels(); - }, - onUpdateVisitedHistory: (controller, url, isReload) { - setState(() { - _url = url.toString(); - }); - }, - onLoadStart: (controller, url) {}, - onLoadStop: (controller, url) async { - _pullToRefreshController.endRefreshing(); - _initialLoadCompleted = true; - - // 웹뷰가 완전히 로드된 후 JavaScript 초기화 - if (_webViewBridgeHandler != null) { - await _webViewBridgeHandler!.setupJavaScriptChannels(); - } - // onLoaded 콜백 호출 - if (widget.onLoaded != null) { - widget.onLoaded!(); - } - }, - onReceivedHttpError: (controller, request, errorResponse) { - logger.w( - '[HTTP Error] url=${request.url} status=${errorResponse.statusCode} description=${errorResponse.reasonPhrase}', - ); - }, - onReceivedError: (controller, request, error) { - logger.e('WebView error: ${error.description}'); - _pullToRefreshController.endRefreshing(); - }, - onProgressChanged: (controller, progress) {}, - onConsoleMessage: (controller, consoleMessage) { - if (consoleMessage.messageLevel == ConsoleMessageLevel.ERROR) { - logger.e('[WebView Console] ${consoleMessage.message}'); - } - }, - shouldOverrideUrlLoading: (controller, navigationAction) async { - return await _navigationHandler - .handleNavigationAction(navigationAction); - }, - shouldInterceptAjaxRequest: (controller, ajaxRequest) async { - return ajaxRequest; - }, - onAjaxReadyStateChange: (controller, ajaxRequest) async { - final isDone = - ajaxRequest.readyState == AjaxRequestReadyState.DONE; - final status = ajaxRequest.status; - if (isDone && status != null && status >= 400) { - logger.w( - '[AjaxError] status=$status url=${ajaxRequest.url}', - ); - } - return AjaxRequestAction.PROCEED; - }, - onAjaxProgress: (controller, ajaxRequest) async { - return AjaxRequestAction.PROCEED; - }, - onReceivedServerTrustAuthRequest: (controller, challenge) async { - // Surface SSL challenges to diagnose certificate issues during loading. - logger.w( - '[SSL] host=${challenge.protectionSpace.host} protocol=${challenge.protectionSpace.protocol} error=${challenge.protectionSpace.sslError}', - ); - if (kReleaseMode) { - return ServerTrustAuthResponse( - action: ServerTrustAuthResponseAction.PROCEED, - ); - } - return ServerTrustAuthResponse( - action: ServerTrustAuthResponseAction.PROCEED, - ); - }, ), - if (_isAppLoading) - LoadingWidget( - isLoading: _isAppLoading, - waveColor: colors.subCoral, - bottleColor: Colors.white, - ), - if (_url.contains('development') || _url.contains('192.')) - Container( - width: double.infinity, - height: 120, - alignment: Alignment.centerLeft, - child: const Padding( - padding: EdgeInsets.only(left: 16.0), - child: Text( - 'development', - style: TextStyle(color: Colors.black12, fontSize: 10), - ), - ), - ), - ], - ); + ), + ], + ); return content; } + void _setupSystemUiThemeHandler(InAppWebViewController controller) { + controller.addJavaScriptHandler( + handlerName: _systemUiThemeHandlerName, + callback: (arguments) { + final isDark = arguments.isNotEmpty && arguments.first == true; + final webViewTopColor = + _colorFromJavaScript(arguments.length > 1 ? arguments[1] : null); + if (!mounted || + (_hasWebViewTheme && + isDark == _isWebViewDark && + webViewTopColor == _webViewTopColor)) { + return; + } + + setState(() { + _hasWebViewTheme = true; + _isWebViewDark = isDark; + _webViewTopColor = webViewTopColor; + }); + + unawaited( + _pullToRefreshController.setBackgroundColor( + webViewTopColor ?? (isDark ? Colors.black : Colors.white), + ), + ); + }, + ); + } + + bool get _effectiveDarkMode => _hasWebViewTheme + ? _isWebViewDark + : WidgetsBinding.instance.platformDispatcher.platformBrightness == + Brightness.dark; + + Color get _fallbackSystemBarColor => + _effectiveDarkMode ? Colors.black : Colors.white; + + void _applySystemUiOverlayStyle({ + required bool isDark, + required Color topColor, + required Color bottomColor, + }) { + SystemChrome.setSystemUIOverlayStyle( + _systemUiOverlayStyle( + isDark: isDark, + topColor: topColor, + bottomColor: bottomColor, + ), + ); + } + + SystemUiOverlayStyle _systemUiOverlayStyle({ + required bool isDark, + required Color topColor, + required Color bottomColor, + }) { + final iconBrightness = isDark ? Brightness.light : Brightness.dark; + + return SystemUiOverlayStyle( + systemNavigationBarColor: bottomColor, + systemNavigationBarDividerColor: Colors.transparent, + systemNavigationBarIconBrightness: iconBrightness, + systemNavigationBarContrastEnforced: false, + statusBarColor: topColor, + statusBarIconBrightness: iconBrightness, + statusBarBrightness: iconBrightness == Brightness.light + ? Brightness.dark + : Brightness.light, + ); + } + + Color? _colorFromJavaScript(Object? value) { + if (value is! List || value.length < 3) return null; + + final red = value[0]; + final green = value[1]; + final blue = value[2]; + if (red is! num || green is! num || blue is! num) return null; + + return Color.fromARGB( + 255, + red.round().clamp(0, 255), + green.round().clamp(0, 255), + blue.round().clamp(0, 255), + ); + } + _permissionWithNotification() async { if (await Permission.notification.isDenied && !await Permission.notification.isPermanentlyDenied) { From 628f0c63f7a226bf9ddf29d35f28077ebde80858 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 15:05:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 51964b6..a996877 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,17 @@ ## 변경 사항 - + + + - -## 테스트 -- [ ] 로컬 테스트 완료 -- [ ] iOS 빌드/실행 확인 -- [ ] Android 빌드/실행 확인 +## 검증 + + -## 스크린샷 (UI 변경 시) - +- ## 관련 이슈 - - + + + +- 없음