// lib/services/ad_service.dart import 'dart:async'; import 'package:flutter/foundation.dart'; 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; /// Flips true once the SDK is initialized. Banner slots that were built /// before consent finished listen to this and retry their load — otherwise /// the banner would stay empty until the screen is rebuilt. final ValueNotifier isReady = ValueNotifier(false); /// Called by ConsentService once the SDK is initialized. Preloads ads and /// notifies any waiting banner slots. void onSdkReady() { _initialized = true; _loadInterstitial(); _loadRewarded(); isReady.value = true; } // ---- 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(); isReady.dispose(); } }