feat: spin-mobile — Flutter WebView 셸 (Android·iOS)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d8acb18e7b
commit
266d7983d0
@@ -0,0 +1,14 @@
|
||||
/// 앱 전역 설정.
|
||||
class AppConfig {
|
||||
/// 웹뷰가 로드할 prod 웹앱 URL (고정).
|
||||
static const String baseUrl = "https://spin.special-partners.com";
|
||||
|
||||
/// 웹앱이 "앱 안에서 실행 중"임을 감지하도록 UA에 덧붙이는 토큰.
|
||||
/// (웹: navigator.userAgent 에 spinApp 포함 → safe-area 등 보정)
|
||||
static const String userAgentTag = "spinApp/1.0";
|
||||
|
||||
/// 백그라운드 후 이 시간 이상 지나 재진입하면 생체인증 잠금을 요구.
|
||||
static const Duration lockGracePeriod = Duration(minutes: 1);
|
||||
|
||||
static Uri get baseUri => Uri.parse(baseUrl);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
|
||||
import "services/push_service.dart";
|
||||
import "webview_screen.dart";
|
||||
|
||||
const navy = Color(0xFF03143F);
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 상태바: navy 위 밝은 아이콘 (몰입형 느낌).
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
));
|
||||
await PushService.instance.init(); // Firebase 미설정이면 비활성으로 진행
|
||||
runApp(const SpinApp());
|
||||
}
|
||||
|
||||
class SpinApp extends StatelessWidget {
|
||||
const SpinApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: "spin",
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorSchemeSeed: navy,
|
||||
scaffoldBackgroundColor: navy,
|
||||
),
|
||||
home: const WebViewScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:local_auth/local_auth.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
/// 생체인증 잠금 설정/검증.
|
||||
class LockService {
|
||||
LockService._();
|
||||
static final LockService instance = LockService._();
|
||||
|
||||
static const _key = "spin.bioLock";
|
||||
final _auth = LocalAuthentication();
|
||||
|
||||
Future<bool> isEnabled() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getBool(_key) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool v) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_key, v);
|
||||
}
|
||||
|
||||
Future<bool> canCheck() async {
|
||||
try {
|
||||
return await _auth.isDeviceSupported() && await _auth.canCheckBiometrics;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> authenticate() async {
|
||||
try {
|
||||
return await _auth.authenticate(
|
||||
localizedReason: "spin 잠금을 해제하려면 인증하세요",
|
||||
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("lock: auth 오류 $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:firebase_core/firebase_core.dart";
|
||||
import "package:firebase_messaging/firebase_messaging.dart";
|
||||
|
||||
/// FCM 푸시 서비스.
|
||||
///
|
||||
/// Firebase 설정(google-services.json / GoogleService-Info.plist + firebase_options)
|
||||
/// 이 아직 없으면 `Firebase.initializeApp()` 이 실패하므로 try/catch로 감싸고 비활성
|
||||
/// 상태로 동작한다 (앱은 정상 실행). 설정이 추가되면 자동으로 토큰을 얻는다.
|
||||
class PushService {
|
||||
PushService._();
|
||||
static final PushService instance = PushService._();
|
||||
|
||||
bool enabled = false;
|
||||
|
||||
/// 현재 FCM 토큰 (없으면 null). 웹뷰 로드 후 web으로 전달해 /api/devices 에 등록.
|
||||
final ValueNotifier<String?> token = ValueNotifier<String?>(null);
|
||||
|
||||
/// 푸시 탭으로 진입했을 때 이동할 in-app 경로(예: "/inbox"). 웹뷰가 소비.
|
||||
final ValueNotifier<String?> pendingLink = ValueNotifier<String?>(null);
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (e) {
|
||||
debugPrint("push: Firebase 미설정 — 푸시 비활성 ($e)");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
enabled = true;
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
try {
|
||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
token.value = await messaging.getToken();
|
||||
messaging.onTokenRefresh.listen((t) => token.value = t);
|
||||
|
||||
// 앱이 종료 상태에서 푸시 탭으로 실행된 경우
|
||||
final initial = await messaging.getInitialMessage();
|
||||
if (initial != null) _consume(initial);
|
||||
// 백그라운드에서 푸시 탭으로 복귀
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_consume);
|
||||
} catch (e) {
|
||||
debugPrint("push: init 오류 $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _consume(RemoteMessage m) {
|
||||
final link = m.data["link"];
|
||||
if (link is String && link.isNotEmpty) pendingLink.value = link;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
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("잠금 해제"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user