spin-mobile/lib/webview_screen.dart
theorose49 e6c7ba9552 fix(mobile): edge-to-edge로 전환해 헤더/푸터 안전영역 정렬
- main: SystemUiMode.edgeToEdge + 내비게이션바 투명 → WebView가 시스템바 뒤까지
  전체 화면 차지 → 웹의 env(safe-area-inset-*) (viewport-fit=cover)가 실제값 수신
- webview: SafeArea top·bottom 모두 false (셸은 inset을 잡지 않고 웹 CSS가 처리)
  → 헤더 눌림/푸터 과다 여백 해소

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:26:58 +09:00

282 lines
10 KiB
Dart

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<WebViewScreen> createState() => _WebViewScreenState();
}
class _WebViewScreenState extends State<WebViewScreen> 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<void> _initUA() async {
String base = "";
try {
base = await InAppWebViewController.getDefaultUserAgent();
} catch (_) {}
setState(() => _ua = "$base ${AppConfig.userAgentTag}".trim());
}
Future<void> _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<void> _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<void> _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("잠금 해제"),
),
],
),
),
);
}
}