feat(ads): AdMob 광고 서비스 추가
- 리워드/인터스티셜 광고 로드 및 표시 - 디버그 모드 광고 토글 지원 - 비모바일 플랫폼 자동 스킵
This commit is contained in:
359
lib/src/core/engine/ad_service.dart
Normal file
359
lib/src/core/engine/ad_service.dart
Normal file
@@ -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<void> 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<AdResult> 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<AdResult> 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user