prod 웹앱(spin.special-partners.com)을 감싸는 네이티브 셸. 화면 개발 없음. - InAppWebView: 쿠키/캐시 영속·UA(spinApp) 태그·풀투리프레시·외부링크 분기·오프라인 화면 - Android 하드웨어 뒤로가기(웹 히스토리→더블탭 종료), navy 스플래시/상태바 - 파일/카메라 업로드 권한(Android/iOS), 생체인증 잠금(local_auth) - FCM 푸시(firebase_messaging) — 설정 전 자동 비활성, 토큰은 웹 세션으로 /api/devices 등록 - prod URL 고정(app_config.dart) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
10 KiB
Dart
281 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가 처리
|
|
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("잠금 해제"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|