feat(ads): AdService for interstitial/rewarded/banner with policy gating

This commit is contained in:
2026-06-13 13:49:18 +09:00
parent eb258c7324
commit 4744aa167a
+141
View File
@@ -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<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();
}
}