import "dart:io" show Platform; import "dart:convert"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_inappwebview/flutter_inappwebview.dart"; import "package:url_launcher/url_launcher.dart"; import "app_config.dart"; import "services/lock_service.dart"; import "services/push_service.dart"; const _navy = Color(0xFF03143F); class WebViewScreen extends StatefulWidget { const WebViewScreen({super.key}); @override State createState() => _WebViewScreenState(); } class _WebViewScreenState extends State with WidgetsBindingObserver { InAppWebViewController? _controller; late final PullToRefreshController _pullToRefresh; String? _ua; // 기본 UA + spinApp 태그 bool _loading = true; bool _offline = false; bool _locked = false; DateTime? _pausedAt; bool _registeredToken = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _pullToRefresh = PullToRefreshController( settings: PullToRefreshSettings(color: _navy), onRefresh: () async { if (Platform.isAndroid) { _controller?.reload(); } else { final url = await _controller?.getUrl(); if (url != null) _controller?.loadUrl(urlRequest: URLRequest(url: url)); } }, ); _initUA(); _maybeLockOnStart(); // 토큰이 늦게 도착하면 등록 주입 PushService.instance.token.addListener(_injectTokenRegistration); // 푸시 탭 이동 PushService.instance.pendingLink.addListener(_consumePendingLink); } Future _initUA() async { String base = ""; try { base = await InAppWebViewController.getDefaultUserAgent(); } catch (_) {} setState(() => _ua = "$base ${AppConfig.userAgentTag}".trim()); } Future _maybeLockOnStart() async { if (await LockService.instance.isEnabled()) { setState(() => _locked = true); _tryUnlock(); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); PushService.instance.token.removeListener(_injectTokenRegistration); PushService.instance.pendingLink.removeListener(_consumePendingLink); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) async { if (state == AppLifecycleState.paused) { _pausedAt = DateTime.now(); } else if (state == AppLifecycleState.resumed) { final enabled = await LockService.instance.isEnabled(); final elapsed = _pausedAt == null ? Duration.zero : DateTime.now().difference(_pausedAt!); if (enabled && elapsed > AppConfig.lockGracePeriod && !_locked) { setState(() => _locked = true); _tryUnlock(); } } } Future _tryUnlock() async { final ok = await LockService.instance.authenticate(); if (ok && mounted) setState(() => _locked = false); } // FCM 토큰을 웹 세션(쿠키 보유)으로 전달해 /api/devices 에 등록. void _injectTokenRegistration() { final t = PushService.instance.token.value; if (t == null || _controller == null) return; final platform = Platform.isIOS ? "ios" : "android"; final body = jsonEncode({"token": t, "platform": platform}); _controller!.evaluateJavascript(source: """ try { fetch('/api/devices', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: ${jsonEncode(body)} }); } catch(e) {} """); _registeredToken = true; } void _consumePendingLink() { final link = PushService.instance.pendingLink.value; if (link == null || _controller == null) return; _controller!.loadUrl(urlRequest: URLRequest(url: WebUri("${AppConfig.baseUrl}$link"))); PushService.instance.pendingLink.value = null; } Future _reload() async { setState(() { _offline = false; _loading = true; }); _controller?.loadUrl(urlRequest: URLRequest(url: WebUri(AppConfig.baseUrl))); } @override Widget build(BuildContext context) { if (_ua == null) { return const Scaffold(backgroundColor: _navy, body: Center(child: CircularProgressIndicator(color: Colors.white))); } return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; final messenger = ScaffoldMessenger.of(context); // capture before await if (_controller != null && await _controller!.canGoBack()) { _controller!.goBack(); } else { // 더블탭 종료 final now = DateTime.now(); if (_lastBack == null || now.difference(_lastBack!) > const Duration(seconds: 2)) { _lastBack = now; messenger.showSnackBar( const SnackBar(content: Text("한 번 더 누르면 종료됩니다"), duration: Duration(seconds: 2)), ); } else { SystemNavigator.pop(); } } }, child: Scaffold( backgroundColor: _navy, body: SafeArea( top: false, // 상·하단 모두 웹의 safe-area CSS(env)가 처리 — 셸은 inset을 잡지 않는다 bottom: false, child: Stack( children: [ InAppWebView( initialUrlRequest: URLRequest(url: WebUri(AppConfig.baseUrl)), initialSettings: InAppWebViewSettings( userAgent: _ua, javaScriptEnabled: true, cacheEnabled: true, clearCache: false, transparentBackground: true, useOnDownloadStart: true, mediaPlaybackRequiresUserGesture: false, allowsInlineMediaPlayback: true, supportZoom: false, useHybridComposition: true, allowFileAccess: true, javaScriptCanOpenWindowsAutomatically: false, ), pullToRefreshController: _pullToRefresh, onWebViewCreated: (c) => _controller = c, onLoadStart: (_, __) => setState(() => _loading = true), onProgressChanged: (_, p) { if (p == 100) _pullToRefresh.endRefreshing(); }, onLoadStop: (_, __) { _pullToRefresh.endRefreshing(); setState(() => _loading = false); if (!_registeredToken) _injectTokenRegistration(); _consumePendingLink(); }, onReceivedError: (_, req, __) { if (req.isForMainFrame ?? true) setState(() => _offline = true); _pullToRefresh.endRefreshing(); }, onPermissionRequest: (_, req) async => PermissionResponse(resources: req.resources, action: PermissionResponseAction.GRANT), shouldOverrideUrlLoading: (_, action) async { final uri = action.request.url; if (uri == null) return NavigationActionPolicy.ALLOW; final scheme = uri.scheme.toLowerCase(); // 외부 스킴은 시스템에서 (전화/메일/지도 등) if (scheme != "http" && scheme != "https") { if (await canLaunchUrl(uri)) launchUrl(uri, mode: LaunchMode.externalApplication); return NavigationActionPolicy.CANCEL; } // 타 호스트(외부 사이트)는 시스템 브라우저로 — 단 Keycloak 등 인증 호스트는 인앱 유지 if (uri.host != AppConfig.baseUri.host && !_isAuthHost(uri.host)) { if (await canLaunchUrl(uri)) launchUrl(uri, mode: LaunchMode.externalApplication); return NavigationActionPolicy.CANCEL; } return NavigationActionPolicy.ALLOW; }, ), if (_loading && !_offline) Container(color: _navy, child: const Center(child: CircularProgressIndicator(color: Colors.white))), if (_offline) _OfflineView(onRetry: _reload), if (_locked) _LockOverlay(onUnlock: _tryUnlock), ], ), ), ), ); } DateTime? _lastBack; // 로그인 리다이렉트가 Keycloak/oauth2-proxy 등 타 호스트로 가더라도 인앱 유지. bool _isAuthHost(String host) { return host.contains("keycloak") || host.contains("auth") || host.contains("oauth2") || host.endsWith("special-partners.com"); } } class _OfflineView extends StatelessWidget { const _OfflineView({required this.onRetry}); final VoidCallback onRetry; @override Widget build(BuildContext context) { return Container( color: const Color(0xFFF5F6F8), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off_rounded, size: 44, color: Color(0xFF98A2B3)), const SizedBox(height: 12), const Text("연결할 수 없습니다", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF101828))), const SizedBox(height: 4), const Text("네트워크 상태를 확인해 주세요", style: TextStyle(fontSize: 13, color: Color(0xFF667085))), const SizedBox(height: 16), FilledButton( style: FilledButton.styleFrom(backgroundColor: _navy), onPressed: onRetry, child: const Text("다시 시도"), ), ], ), ), ); } } class _LockOverlay extends StatelessWidget { const _LockOverlay({required this.onUnlock}); final VoidCallback onUnlock; @override Widget build(BuildContext context) { return Container( color: _navy, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.lock_rounded, size: 48, color: Colors.white), const SizedBox(height: 16), const Text("잠금 상태입니다", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 16), OutlinedButton( style: OutlinedButton.styleFrom(foregroundColor: Colors.white, side: const BorderSide(color: Colors.white54)), onPressed: onUnlock, child: const Text("잠금 해제"), ), ], ), ), ); } }