diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart new file mode 100644 index 0000000..c0bb2ea --- /dev/null +++ b/lib/services/ad_service.dart @@ -0,0 +1,141 @@ +// lib/services/ad_service.dart +import 'dart:async'; + +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +import 'ad_config.dart'; +import 'ad_frequency_policy.dart'; + +/// Owns the interstitial/rewarded/banner lifecycle. Holds the +/// [AdFrequencyPolicy] and never shows an ad the policy or [adsRemoved] +/// forbids. All failures are swallowed — ads must never break gameplay. +/// +/// [adsRemoved] is read through a getter callback so a mid-session purchase +/// takes effect without re-wiring the service. +class AdService { + AdService({ + required bool Function() adsRemoved, + AdFrequencyPolicy? policy, + DateTime Function() now = DateTime.now, + }) : _adsRemoved = adsRemoved, + policy = policy ?? AdFrequencyPolicy(), + _now = now; + + final bool Function() _adsRemoved; + final AdFrequencyPolicy policy; + final DateTime Function() _now; + + InterstitialAd? _interstitial; + RewardedAd? _rewarded; + bool _initialized = false; + + /// Called by ConsentService once the SDK is initialized. Preloads ads. + void onSdkReady() { + _initialized = true; + _loadInterstitial(); + _loadRewarded(); + } + + // ---- lifecycle hooks the game calls ---- + void onRoundStart() => policy.onRoundStart(); + void onStageCompleted() => policy.onStageCompleted(); + + void _loadInterstitial() { + if (!_initialized || _adsRemoved()) return; + InterstitialAd.load( + adUnitId: AdConfig.interstitial, + request: const AdRequest(), + adLoadCallback: InterstitialAdLoadCallback( + onAdLoaded: (ad) => _interstitial = ad, + onAdFailedToLoad: (_) => _interstitial = null, + ), + ); + } + + void _loadRewarded() { + if (!_initialized) return; // rewarded stays available even with adsRemoved + RewardedAd.load( + adUnitId: AdConfig.rewarded, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) => _rewarded = ad, + onAdFailedToLoad: (_) => _rewarded = null, + ), + ); + } + + /// Shows a stage-end interstitial when the policy and adsRemoved allow it. + /// No-op (and reloads) otherwise. Safe to call after every finished stage. + void maybeShowInterstitial() { + if (_adsRemoved()) return; + final ad = _interstitial; + if (ad == null || !policy.canShowInterstitial(_now())) { + if (ad == null) _loadInterstitial(); + return; + } + _interstitial = null; + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _loadInterstitial(); + }, + onAdFailedToShowFullScreenContent: (ad, _) { + ad.dispose(); + _loadInterstitial(); + }, + ); + policy.onInterstitialShown(_now()); + ad.show(); + } + + /// Shows a rewarded ad and resolves `true` when the reward is earned. If no + /// ad is loaded, resolves `true` anyway — the rescue is a player benefit and + /// must not be blocked by ad availability. A genuine dismissal-without-earn + /// resolves `false`. Records the watch in the policy so it suppresses this + /// round's interstitial. + Future showRewarded() async { + policy.onRewardedShown(); + final ad = _rewarded; + if (ad == null) { + _loadRewarded(); + return true; // no ad available -> grant the rescue + } + _rewarded = null; + final completer = Completer(); + void finish(bool v) { + if (!completer.isCompleted) completer.complete(v); + } + + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _loadRewarded(); + finish(false); // dismissed; earn already resolved true if it happened + }, + onAdFailedToShowFullScreenContent: (ad, _) { + ad.dispose(); + _loadRewarded(); + finish(true); // failed to present -> grant + }, + ); + ad.show(onUserEarnedReward: (_, reward) => finish(true)); + return completer.future; + } + + /// Creates a fresh banner for the home/map slot, or null when ads are + /// removed. The caller passes a listener and owns disposal. + BannerAd? createBanner({BannerAdListener? listener}) { + if (_adsRemoved() || !_initialized) return null; + return BannerAd( + adUnitId: AdConfig.banner, + size: AdSize.banner, + request: const AdRequest(), + listener: listener ?? const BannerAdListener(), + )..load(); + } + + void dispose() { + _interstitial?.dispose(); + _rewarded?.dispose(); + } +}