Files
BlockSeasons/lib/services/ad_service.dart
T
airkjw 640b23804f fix(ads): banner retries load when SDK becomes ready
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.
2026-06-13 14:23:45 +09:00

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();
}
}