diff --git a/lib/src/app.dart b/lib/src/app.dart index fd51c6a..d902bdd 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/l10n/app_localizations.dart'; import 'package:asciineverdie/src/core/audio/audio_service.dart'; +import 'package:asciineverdie/src/core/engine/ad_service.dart'; import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; +import 'package:asciineverdie/src/core/engine/iap_service.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.dart'; import 'package:asciineverdie/src/core/engine/progress_service.dart'; @@ -46,7 +48,8 @@ class SavedGamePreview { final String actName; } -class _AskiiNeverDieAppState extends State { +class _AskiiNeverDieAppState extends State + with WidgetsBindingObserver { late final GameSessionController _controller; late final NotificationService _notificationService; late final SettingsRepository _settingsRepository; @@ -57,12 +60,15 @@ class _AskiiNeverDieAppState extends State { bool _isCheckingSave = true; bool _hasSave = false; SavedGamePreview? _savedGamePreview; - ThemeMode _themeMode = ThemeMode.system; HallOfFame _hallOfFame = HallOfFame.empty(); + Locale? _locale; // 사용자 선택 로케일 (null이면 시스템 기본값) + bool _isAdRemovalPurchased = false; + String? _removeAdsPrice; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); const config = PqConfig(); final mutations = GameMutations(config); final rewards = RewardService(mutations, config); @@ -83,14 +89,33 @@ class _AskiiNeverDieAppState extends State { // 초기 설정 및 오디오 서비스 로드 _loadSettings(); _audioService.init(); - // 디버그 설정 서비스 초기화 (Phase 8) - DebugSettingsService.instance.initialize(); + // IAP 서비스 초기화 + _initIAP(); // 세이브 파일 존재 여부 확인 _checkForExistingSave(); // 명예의 전당 로드 _loadHallOfFame(); } + /// IAP 및 광고 서비스 초기화 + Future _initIAP() async { + await IAPService.instance.initialize(); + await AdService.instance.initialize(); + _updateIAPState(); + } + + /// IAP 상태 업데이트 (구매 여부, 가격) + void _updateIAPState() { + if (mounted) { + setState(() { + _isAdRemovalPurchased = IAPService.instance.isAdRemovalPurchased; + _removeAdsPrice = IAPService.instance.isStoreAvailable + ? IAPService.instance.removeAdsPrice + : null; + }); + } + } + /// 명예의 전당 로드 Future _loadHallOfFame() async { final hallOfFame = await _hallOfFameStorage.load(); @@ -103,16 +128,26 @@ class _AskiiNeverDieAppState extends State { /// 저장된 설정 불러오기 Future _loadSettings() async { - final themeMode = await _settingsRepository.loadThemeMode(); + // 디버그 설정 먼저 초기화 (광고/IAP 시뮬레이션 설정 동기화) + await DebugSettingsService.instance.initialize(); + + final localeCode = await _settingsRepository.loadLocale(); if (mounted) { - setState(() => _themeMode = themeMode); + setState(() { + // 저장된 로케일이 있으면 적용 + if (localeCode != null) { + _locale = Locale(localeCode); + game_l10n.setGameLocale(localeCode); + } + }); } } - /// 테마 모드 변경 - void _changeThemeMode(ThemeMode mode) { - setState(() => _themeMode = mode); - _settingsRepository.saveThemeMode(mode); + /// 로케일 변경 + void _changeLocale(String localeCode) { + setState(() { + _locale = Locale(localeCode); + }); } /// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드 @@ -139,8 +174,11 @@ class _AskiiNeverDieAppState extends State { _savedGamePreview = preview; _isCheckingSave = false; }); - // 세이브 확인 완료 후 타이틀 BGM 재생 - _audioService.playBgm('title'); + // 세이브 확인 완료 후 타이틀 BGM 재생 (앱이 포그라운드일 때만) + final lifecycleState = WidgetsBinding.instance.lifecycleState; + if (lifecycleState == AppLifecycleState.resumed) { + _audioService.playBgm('title'); + } } } @@ -159,148 +197,32 @@ class _AskiiNeverDieAppState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _notificationService.dispose(); _audioService.dispose(); super.dispose(); } - /// 라이트 테마 (Classic Parchment 스타일) - ThemeData get _lightTheme => ThemeData( - colorScheme: RetroColors.lightColorScheme, - scaffoldBackgroundColor: const Color(0xFFFAF4ED), - useMaterial3: true, - // 카드/다이얼로그 레트로 배경 - cardColor: const Color(0xFFF2E8DC), - dialogTheme: const DialogThemeData( - backgroundColor: Color(0xFFF2E8DC), - titleTextStyle: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 15, - color: Color(0xFFB8860B), - ), - ), - // 앱바 레트로 스타일 - appBarTheme: const AppBarTheme( - backgroundColor: Color(0xFFF2E8DC), - foregroundColor: Color(0xFF1F1F28), - titleTextStyle: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 15, - color: Color(0xFFB8860B), - ), - ), - // 버튼 테마 - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFFE8DDD0), - foregroundColor: const Color(0xFF1F1F28), - textStyle: const TextStyle( - inherit: false, - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFB8860B), - side: const BorderSide(color: Color(0xFFB8860B), width: 2), - textStyle: const TextStyle( - inherit: false, - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFFB8860B), - ), - ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF4A4458), - textStyle: const TextStyle( - inherit: false, - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF4A4458), - ), - ), - ), - // 텍스트 테마 - textTheme: const TextTheme( - headlineLarge: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 20, - color: Color(0xFFB8860B), - ), - headlineMedium: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 16, - color: Color(0xFFB8860B), - ), - headlineSmall: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 15, - color: Color(0xFFB8860B), - ), - titleLarge: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 15, - color: Color(0xFF1F1F28), - ), - titleMedium: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - titleSmall: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - bodyLarge: TextStyle(fontSize: 18, color: Color(0xFF1F1F28)), - bodyMedium: TextStyle(fontSize: 17, color: Color(0xFF1F1F28)), - bodySmall: TextStyle(fontSize: 15, color: Color(0xFF1F1F28)), - labelLarge: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - labelMedium: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - labelSmall: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 13, - color: Color(0xFF1F1F28), - ), - ), - // 칩 테마 - chipTheme: const ChipThemeData( - backgroundColor: Color(0xFFE8DDD0), - labelStyle: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: Color(0xFF1F1F28), - ), - side: BorderSide(color: Color(0xFF8B7355)), - ), - // 리스트 타일 테마 - listTileTheme: const ListTileThemeData( - textColor: Color(0xFF1F1F28), - iconColor: Color(0xFFB8860B), - ), - // 프로그레스 인디케이터 - progressIndicatorTheme: const ProgressIndicatorThemeData( - color: Color(0xFFB8860B), - linearTrackColor: Color(0xFFD4C4B0), - ), - ); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + // 앱이 백그라운드로 내려가면 오디오 정지 + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive) { + _audioService.pauseAll(); + } else if (state == AppLifecycleState.resumed) { + _audioService.resumeAll().then((_) { + // 복귀 후 BGM이 없고 시작 화면이면 타이틀 BGM 재생 + if (_audioService.currentBgm == null && !_isCheckingSave) { + _audioService.playBgm('title'); + } + }); + } + } - /// 다크 테마 (Dark Fantasy 스타일) - ThemeData get _darkTheme => ThemeData( + /// 앱 테마 (Dark Fantasy 스타일) + ThemeData get _theme => ThemeData( colorScheme: RetroColors.darkColorScheme, scaffoldBackgroundColor: RetroColors.deepBrown, useMaterial3: true, @@ -440,9 +362,8 @@ class _AskiiNeverDieAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, - theme: _lightTheme, - darkTheme: _darkTheme, - themeMode: _themeMode, + locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값) + theme: _theme, navigatorObservers: [_routeObserver], builder: (context, child) { // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 @@ -470,13 +391,18 @@ class _AskiiNeverDieAppState extends State { onHallOfFame: _navigateToHallOfFame, onLocalArena: _navigateToArena, onSettings: _showSettings, + onPurchaseRemoveAds: _purchaseRemoveAds, + onRestorePurchase: _restorePurchase, hasSaveFile: _hasSave, savedGamePreview: _savedGamePreview, hallOfFameCount: _hallOfFame.count, + isAdRemovalPurchased: _isAdRemovalPurchased, + removeAdsPrice: _removeAdsPrice, routeObserver: _routeObserver, onRefresh: () { _checkForExistingSave(); _loadHallOfFame(); + _updateIAPState(); }, ); } @@ -551,8 +477,6 @@ class _AskiiNeverDieAppState extends State { controller: _controller, audioService: _audioService, forceCarouselLayout: testMode, - currentThemeMode: _themeMode, - onThemeModeChange: _changeThemeMode, ), ), ); @@ -568,8 +492,6 @@ class _AskiiNeverDieAppState extends State { audioService: _audioService, // 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제 forceCarouselLayout: _controller.cheatsEnabled, - currentThemeMode: _themeMode, - onThemeModeChange: _changeThemeMode, ), ), ) @@ -613,12 +535,60 @@ class _AskiiNeverDieAppState extends State { SettingsScreen.show( context, settingsRepository: _settingsRepository, - currentThemeMode: _themeMode, - onThemeModeChange: _changeThemeMode, + onLocaleChange: _changeLocale, onBgmVolumeChange: _audioService.setBgmVolume, onSfxVolumeChange: _audioService.setSfxVolume, ); } + + /// 광고 제거 구매 + Future _purchaseRemoveAds(BuildContext context) async { + final result = await IAPService.instance.purchaseRemoveAds(); + _updateIAPState(); + + if (!context.mounted) return; + + switch (result) { + case IAPResult.success: + case IAPResult.debugSimulated: + _notificationService.showInfo(game_l10n.iapPurchaseSuccess); + case IAPResult.alreadyPurchased: + _notificationService.showInfo(game_l10n.iapAlreadyPurchased); + case IAPResult.cancelled: + // 취소는 무시 + break; + case IAPResult.storeUnavailable: + _notificationService.showWarning(game_l10n.iapStoreUnavailable); + case IAPResult.productNotFound: + case IAPResult.failed: + _notificationService.showWarning(game_l10n.iapPurchaseFailed); + } + } + + /// 구매 복원 + Future _restorePurchase(BuildContext context) async { + final result = await IAPService.instance.restorePurchases(); + _updateIAPState(); + + if (!context.mounted) return; + + switch (result) { + case IAPResult.success: + case IAPResult.debugSimulated: + if (_isAdRemovalPurchased) { + _notificationService.showInfo(game_l10n.iapRestoreSuccess); + } else { + _notificationService.showInfo(game_l10n.iapRestoreFailed); + } + case IAPResult.storeUnavailable: + _notificationService.showWarning(game_l10n.iapStoreUnavailable); + case IAPResult.alreadyPurchased: + case IAPResult.cancelled: + case IAPResult.productNotFound: + case IAPResult.failed: + _notificationService.showWarning(game_l10n.iapRestoreFailed); + } + } } /// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일 diff --git a/lib/src/shared/widgets/retro_panel.dart b/lib/src/shared/widgets/retro_panel.dart index 48c523f..80c61e3 100644 --- a/lib/src/shared/widgets/retro_panel.dart +++ b/lib/src/shared/widgets/retro_panel.dart @@ -14,7 +14,11 @@ class RetroPanel extends StatelessWidget { this.borderWidth = 3.0, this.useGoldBorder = false, this.title, - }); + this.titleWidget, + }) : assert( + title == null || titleWidget == null, + 'title과 titleWidget 중 하나만 사용 가능', + ); /// 패널 내부 컨텐츠 final Widget child; @@ -34,6 +38,9 @@ class RetroPanel extends StatelessWidget { /// 패널 타이틀 (상단에 표시) final String? title; + /// 커스텀 타이틀 위젯 (title 대신 사용) + final Widget? titleWidget; + @override Widget build(BuildContext context) { final painter = useGoldBorder @@ -46,16 +53,24 @@ class RetroPanel extends StatelessWidget { fillColor: backgroundColor, ); + final hasTitle = title != null || titleWidget != null; + return CustomPaint( painter: painter, child: Padding( padding: EdgeInsets.all(borderWidth).add(padding), - child: title != null + child: hasTitle ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _PanelTitle(title: title!, useGoldBorder: useGoldBorder), + if (titleWidget != null) + _PanelTitleContainer( + useGoldBorder: useGoldBorder, + child: titleWidget!, + ) + else + _PanelTitle(title: title!, useGoldBorder: useGoldBorder), const SizedBox(height: 8), Flexible(child: child), ], @@ -73,6 +88,33 @@ class _PanelTitle extends StatelessWidget { final String title; final bool useGoldBorder; + @override + Widget build(BuildContext context) { + return _PanelTitleContainer( + useGoldBorder: useGoldBorder, + child: Text( + title.toUpperCase(), + style: TextStyle( + fontFamily: 'PressStart2P', + fontSize: 14, + color: useGoldBorder ? RetroColors.gold : RetroColors.textLight, + letterSpacing: 1, + ), + ), + ); + } +} + +/// 패널 타이틀 컨테이너 (커스텀 위젯용) +class _PanelTitleContainer extends StatelessWidget { + const _PanelTitleContainer({ + required this.useGoldBorder, + required this.child, + }); + + final bool useGoldBorder; + final Widget child; + @override Widget build(BuildContext context) { return Container( @@ -90,15 +132,7 @@ class _PanelTitle extends StatelessWidget { ), ), ), - child: Text( - title.toUpperCase(), - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 14, - color: useGoldBorder ? RetroColors.gold : RetroColors.textLight, - letterSpacing: 1, - ), - ), + child: child, ); } } @@ -110,11 +144,16 @@ class RetroGoldPanel extends StatelessWidget { required this.child, this.padding = const EdgeInsets.all(12), this.title, - }); + this.titleWidget, + }) : assert( + title == null || titleWidget == null, + 'title과 titleWidget 중 하나만 사용 가능', + ); final Widget child; final EdgeInsets padding; final String? title; + final Widget? titleWidget; @override Widget build(BuildContext context) { @@ -122,6 +161,7 @@ class RetroGoldPanel extends StatelessWidget { useGoldBorder: true, padding: padding, title: title, + titleWidget: titleWidget, child: child, ); }