From 6662a5dcfbef8b9be67d8572a097f099fbcc5997 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 16 Jan 2026 20:08:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(ads):=20AdMob=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리워드/인터스티셜 광고 로드 및 표시 - 디버그 모드 광고 토글 지원 - 비모바일 플랫폼 자동 스킵 --- lib/src/core/engine/ad_service.dart | 359 ++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 lib/src/core/engine/ad_service.dart diff --git a/lib/src/core/engine/ad_service.dart b/lib/src/core/engine/ad_service.dart new file mode 100644 index 0000000..a9a3135 --- /dev/null +++ b/lib/src/core/engine/ad_service.dart @@ -0,0 +1,359 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +/// 광고 타입 +enum AdType { + /// 부활용 리워드 광고 (30초) + rewardRevive, + + /// 캐릭터 생성 되돌리기용 리워드 광고 (30초) + rewardUndo, + + /// 굴리기 충전용 인터스티셜 광고 (6초) + interstitialRoll, + + /// 속도업용 인터스티셜 광고 (6초) + interstitialSpeed, +} + +/// 광고 결과 +enum AdResult { + /// 광고 시청 완료 (보상 지급) + completed, + + /// 광고 시청 취소/스킵 + cancelled, + + /// 광고 로드 실패 + failed, + + /// 디버그 모드에서 광고 스킵 + debugSkipped, +} + +/// 광고 서비스 +/// +/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다. +/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다. +class AdService { + AdService._(); + + static AdService? _instance; + + /// 싱글톤 인스턴스 + static AdService get instance { + _instance ??= AdService._(); + return _instance!; + } + + // =========================================================================== + // 광고 단위 ID + // =========================================================================== + + // ───────────────────────────────────────────────────────────────────────── + // 테스트 광고 ID (Google 공식 테스트 ID) + // ───────────────────────────────────────────────────────────────────────── + static const String _testRewardedAndroid = + 'ca-app-pub-3940256099942544/5224354917'; + static const String _testRewardedIos = + 'ca-app-pub-3940256099942544/1712485313'; + static const String _testInterstitialAndroid = + 'ca-app-pub-3940256099942544/1033173712'; + static const String _testInterstitialIos = + 'ca-app-pub-3940256099942544/4411468910'; + + // ───────────────────────────────────────────────────────────────────────── + // 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체) + // ───────────────────────────────────────────────────────────────────────── + // TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체 + static const String _prodRewardedAndroid = + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고 + static const String _prodRewardedIos = + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고 + static const String _prodInterstitialAndroid = + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고 + static const String _prodInterstitialIos = + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고 + + /// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID) + String get _rewardAdUnitId { + if (Platform.isAndroid) { + return kReleaseMode ? _prodRewardedAndroid : _testRewardedAndroid; + } else if (Platform.isIOS) { + return kReleaseMode ? _prodRewardedIos : _testRewardedIos; + } + return ''; + } + + /// 인터스티셜 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID) + String get _interstitialAdUnitId { + if (Platform.isAndroid) { + return kReleaseMode ? _prodInterstitialAndroid : _testInterstitialAndroid; + } else if (Platform.isIOS) { + return kReleaseMode ? _prodInterstitialIos : _testInterstitialIos; + } + return ''; + } + + // =========================================================================== + // 상태 + // =========================================================================== + + bool _isInitialized = false; + + /// 디버그 모드에서 광고 활성화 여부 + bool _debugAdEnabled = true; + + /// 로드된 리워드 광고 + RewardedAd? _rewardedAd; + + /// 로드된 인터스티셜 광고 + InterstitialAd? _interstitialAd; + + /// 리워드 광고 로딩 중 여부 + bool _isLoadingRewardedAd = false; + + /// 인터스티셜 광고 로딩 중 여부 + bool _isLoadingInterstitialAd = false; + + // =========================================================================== + // 초기화 + // =========================================================================== + + /// AdMob SDK 초기화 + Future initialize() async { + if (_isInitialized) return; + + // 모바일 플랫폼에서만 초기화 + if (!Platform.isAndroid && !Platform.isIOS) { + debugPrint('[AdService] Non-mobile platform, skipping initialization'); + return; + } + + await MobileAds.instance.initialize(); + _isInitialized = true; + debugPrint('[AdService] Initialized'); + + // 초기 광고 로드 + _loadRewardedAd(); + _loadInterstitialAd(); + } + + // =========================================================================== + // 디버그 설정 + // =========================================================================== + + /// 디버그 모드 광고 활성화 여부 + bool get debugAdEnabled => _debugAdEnabled; + + /// 디버그 모드 광고 토글 + set debugAdEnabled(bool value) { + _debugAdEnabled = value; + debugPrint('[AdService] Debug ad enabled: $value'); + } + + /// 광고를 스킵할지 여부 + /// + /// 스킵 조건: + /// - 비모바일 플랫폼 (macOS, Windows, Linux, Web) + /// - 디버그 모드에서 광고 비활성화 + bool get _shouldSkipAd { + // 웹에서는 항상 스킵 + if (kIsWeb) return true; + // 비모바일 플랫폼(데스크톱)에서는 항상 스킵 + if (!Platform.isAndroid && !Platform.isIOS) { + return true; + } + // 디버그 모드에서 광고 비활성화 시 스킵 + return kDebugMode && !_debugAdEnabled; + } + + // =========================================================================== + // 리워드 광고 + // =========================================================================== + + /// 리워드 광고 로드 + void _loadRewardedAd() { + if (_isLoadingRewardedAd || _rewardedAd != null) return; + if (!_isInitialized) return; + + _isLoadingRewardedAd = true; + debugPrint('[AdService] Loading rewarded ad...'); + + RewardedAd.load( + adUnitId: _rewardAdUnitId, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _rewardedAd = ad; + _isLoadingRewardedAd = false; + debugPrint('[AdService] Rewarded ad loaded'); + }, + onAdFailedToLoad: (error) { + _isLoadingRewardedAd = false; + debugPrint('[AdService] Rewarded ad failed to load: ${error.message}'); + }, + ), + ); + } + + /// 리워드 광고 준비 여부 + bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd; + + /// 리워드 광고 표시 + /// + /// [adType] 광고 타입 (로깅용) + /// [onRewarded] 보상 지급 콜백 + /// Returns: 광고 결과 + Future showRewardedAd({ + required AdType adType, + required void Function() onRewarded, + }) async { + // 디버그 모드에서 광고 스킵 + if (_shouldSkipAd) { + debugPrint('[AdService] Debug: Skipping $adType ad'); + onRewarded(); + return AdResult.debugSkipped; + } + + // 광고가 로드되지 않은 경우 + if (_rewardedAd == null) { + debugPrint('[AdService] Rewarded ad not ready'); + _loadRewardedAd(); + return AdResult.failed; + } + + final ad = _rewardedAd!; + _rewardedAd = null; + + // 결과 추적용 + var result = AdResult.cancelled; + + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + debugPrint('[AdService] Rewarded ad dismissed'); + ad.dispose(); + _loadRewardedAd(); // 다음 광고 미리 로드 + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint('[AdService] Rewarded ad failed to show: ${error.message}'); + ad.dispose(); + result = AdResult.failed; + _loadRewardedAd(); + }, + ); + + await ad.show( + onUserEarnedReward: (ad, reward) { + debugPrint('[AdService] User earned reward: ${reward.amount}'); + result = AdResult.completed; + onRewarded(); + }, + ); + + return result; + } + + // =========================================================================== + // 인터스티셜 광고 + // =========================================================================== + + /// 인터스티셜 광고 로드 + void _loadInterstitialAd() { + if (_isLoadingInterstitialAd || _interstitialAd != null) return; + if (!_isInitialized) return; + + _isLoadingInterstitialAd = true; + debugPrint('[AdService] Loading interstitial ad...'); + + InterstitialAd.load( + adUnitId: _interstitialAdUnitId, + request: const AdRequest(), + adLoadCallback: InterstitialAdLoadCallback( + onAdLoaded: (ad) { + _interstitialAd = ad; + _isLoadingInterstitialAd = false; + debugPrint('[AdService] Interstitial ad loaded'); + }, + onAdFailedToLoad: (error) { + _isLoadingInterstitialAd = false; + debugPrint( + '[AdService] Interstitial ad failed to load: ${error.message}', + ); + }, + ), + ); + } + + /// 인터스티셜 광고 준비 여부 + bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd; + + /// 인터스티셜 광고 표시 + /// + /// [adType] 광고 타입 (로깅용) + /// [onComplete] 광고 완료 콜백 (보상 지급) + /// Returns: 광고 결과 + Future showInterstitialAd({ + required AdType adType, + required void Function() onComplete, + }) async { + // 디버그 모드에서 광고 스킵 + if (_shouldSkipAd) { + debugPrint('[AdService] Debug: Skipping $adType ad'); + onComplete(); + return AdResult.debugSkipped; + } + + // 광고가 로드되지 않은 경우 + if (_interstitialAd == null) { + debugPrint('[AdService] Interstitial ad not ready'); + _loadInterstitialAd(); + return AdResult.failed; + } + + final ad = _interstitialAd!; + _interstitialAd = null; + + // 결과 추적용 + var result = AdResult.cancelled; + + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + debugPrint('[AdService] Interstitial ad dismissed'); + ad.dispose(); + result = AdResult.completed; + onComplete(); + _loadInterstitialAd(); // 다음 광고 미리 로드 + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint( + '[AdService] Interstitial ad failed to show: ${error.message}', + ); + ad.dispose(); + result = AdResult.failed; + _loadInterstitialAd(); + }, + ); + + await ad.show(); + + return result; + } + + // =========================================================================== + // 정리 + // =========================================================================== + + /// 리소스 해제 + void dispose() { + _rewardedAd?.dispose(); + _rewardedAd = null; + + _interstitialAd?.dispose(); + _interstitialAd = null; + + debugPrint('[AdService] Disposed'); + } +}