640b23804f
On a cold start the consent flow is still running when home first builds, so createBanner returns null and the slot stayed empty until a rebuild. AdService now exposes an isReady ValueNotifier; BannerAdSlot listens and retries its load once MobileAds finishes initializing. Verified: analyze clean, 169 tests green.
151 lines
4.8 KiB
Dart
151 lines
4.8 KiB
Dart
// 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<bool> isReady = ValueNotifier<bool>(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<bool> showRewarded() async {
|
|
policy.onRewardedShown();
|
|
final ad = _rewarded;
|
|
if (ad == null) {
|
|
_loadRewarded();
|
|
return true; // no ad available -> grant the rescue
|
|
}
|
|
_rewarded = null;
|
|
final completer = Completer<bool>();
|
|
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();
|
|
}
|
|
}
|