Files
BlockSeasons/docs/superpowers/plans/2026-06-13-monetization.md
T
airkjw 0781e817d0 docs: Phase 5 monetization (AdMob + IAP) implementation plan
14 tasks: pure-Dart frequency policy (TDD), adsRemoved persistence, ad config
(test ids), AdService/ConsentService/IapService, ref-constructed providers
(single ownership source), rewarded-gated rescue, stage-end interstitial,
home/map banner, settings (remove ads + restore), simulator verification.
Runs on Google test ids today; owner real ids slot in by config later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:50:59 +09:00

1503 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5 — Monetization (AdMob + IAP) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add AdMob ads (interstitial, rewarded, banner) with strict frequency caps, a compliant consent flow (UMP → ATT → SDK init), and a `remove_ads` non-consumable IAP with Restore — all runnable in dev today via Google's official test IDs, with real IDs slotted in later by config only.
**Architecture:** The frequency-cap decision is a **pure-Dart `AdFrequencyPolicy`** (no plugin imports) so the monetization rules are unit-tested headlessly, exactly like the engine and analytics layers. Thin service wrappers (`AdService`, `ConsentService`, `IapService`) isolate the plugin/platform-channel calls and **swallow every failure** — ads and purchases must never break gameplay. `remove_ads` ownership is a persisted flag (`SaveRepository.adsRemoved`) surfaced through a Riverpod `adsRemovedProvider`; it disables interstitial + banner but **keeps rewarded** (a player benefit).
**Tech Stack:** Flutter, Riverpod 3 (plain Notifiers), `google_mobile_ads` (UMP built in), `in_app_purchase`, `app_tracking_transparency`, shared_preferences.
---
## Design constants (single source of truth)
These values come from the master plan's monetization section. Define them once in `AdFrequencyPolicy` and `ad_config.dart`; never hard-code them elsewhere.
| Rule | Value |
|---|---|
| Interstitial: min stages since last interstitial | `3` |
| Interstitial: min seconds since last interstitial | `90` |
| Interstitial: first-N stages protected (no interstitial) | `5` |
| Interstitial: blocked if a rewarded was watched this round | yes |
| Rewarded: per stage | `1` (engine already enforces `rescueUsed`) |
| Banner: allowed screens | home + season map only — **never** the game screen |
| `remove_ads` effect | interstitial OFF, banner OFF, **rewarded stays ON** |
**Consent order (App Review-critical, do not reorder):** UMP consent form → iOS ATT prompt → `MobileAds.instance.initialize()`. The ATT request must run while the app is foregrounded (after the first frame), never inside `main()` pre-`runApp`.
**Ad IDs in dev:** Use Google's official sample AdMob **App ID** and **test ad-unit IDs** (listed in Task 4). These show real test ads and never risk an AdMob policy strike. The owner's real IDs replace the constants in `lib/services/ad_config.dart` and the two native manifests later — no logic changes.
---
## File Structure
**New files:**
- `lib/services/ad_frequency_policy.dart` — pure-Dart cap logic (no plugin imports). The testable core.
- `lib/services/ad_config.dart` — ad-unit + app IDs, test vs real via `kDebugMode`/`--dart-define=USE_TEST_ADS`.
- `lib/services/ad_service.dart` — loads/shows interstitial, rewarded, banner; consults policy + `adsRemoved`; swallows failures.
- `lib/services/consent_service.dart` — UMP → ATT → `MobileAds.initialize`, in order, each step guarded.
- `lib/services/iap_service.dart``remove_ads` purchase/restore over `in_app_purchase`; pushes ownership into a callback.
- `lib/state/ads_notifier.dart``AdsRemovedNotifier` (Notifier<bool>) backed by `SaveRepository`.
- `lib/ui/widgets/banner_ad_slot.dart` — renders a banner or `SizedBox.shrink()` (hidden when `adsRemoved` or unloaded).
- `lib/ui/screens/settings_screen.dart` — Remove Ads purchase + Restore Purchases.
- `test/services/ad_frequency_policy_test.dart` — cap-rule table tests.
- `test/data/save_repository_ads_test.dart``adsRemoved` persistence.
- `test/state/ads_notifier_test.dart` — notifier reads/writes through the repo.
**Modified files:**
- `pubspec.yaml` — add the three plugins.
- `ios/Runner/Info.plist``GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems`.
- `android/app/src/main/AndroidManifest.xml` — AdMob `APPLICATION_ID` meta-data.
- `lib/data/save_repository.dart` — additive `adsRemoved` flag.
- `lib/state/providers.dart` — ad/consent/iap/adsRemoved providers (ref-constructed).
- `lib/app.dart` — trigger consent flow after first frame (ConsumerStatefulWidget).
(`lib/main.dart` is intentionally **unchanged** — the services self-construct from `ref`, so there is no bootstrap wiring or override to add there.)
- `lib/ui/screens/game_screen.dart` — rewarded gates rescue; stage-end interstitial.
- `lib/ui/screens/home_screen.dart` — banner slot + settings gear.
- `lib/ui/screens/season_map_screen.dart` — banner slot.
- `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` — settings/IAP copy.
---
## Task 1: Dependencies + native AdMob config
**Files:**
- Modify: `pubspec.yaml`
- Modify: `ios/Runner/Info.plist`
- Modify: `android/app/src/main/AndroidManifest.xml`
- [ ] **Step 1: Add plugins**
Run:
```bash
flutter pub add google_mobile_ads in_app_purchase app_tracking_transparency
```
Expected: three packages added under `dependencies:` in `pubspec.yaml`, `flutter pub get` succeeds.
- [ ] **Step 2: iOS Info.plist — AdMob app id, ATT text, SKAdNetwork**
In `ios/Runner/Info.plist`, inside the top-level `<dict>`, add (uses Google's sample AdMob **App ID** for dev; owner swaps later):
```xml
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-3940256099942544~1458002511</string>
<key>NSUserTrackingUsageDescription</key>
<string>We use this to show ads that are more relevant to you. You can play fully either way.</string>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
</array>
```
- [ ] **Step 3: Android manifest — AdMob app id**
In `android/app/src/main/AndroidManifest.xml`, inside `<application ...>`, add (Google sample App ID):
```xml
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3940256099942544~3347511713"/>
```
- [ ] **Step 4: Verify it still builds**
Run: `flutter build ios --debug --simulator --no-codesign`
Expected: `✓ Built build/ios/iphonesimulator/Runner.app` (pod install pulls Google-Mobile-Ads-SDK).
- [ ] **Step 5: Commit**
```bash
git add pubspec.yaml pubspec.lock ios/Runner/Info.plist android/app/src/main/AndroidManifest.xml
git commit -m "build: add AdMob/IAP/ATT plugins and native ad config (test ids)"
```
---
## Task 2: AdFrequencyPolicy (pure-Dart cap logic) — TDD
**Files:**
- Create: `lib/services/ad_frequency_policy.dart`
- Test: `test/services/ad_frequency_policy_test.dart`
- [ ] **Step 1: Write the failing tests**
```dart
// test/services/ad_frequency_policy_test.dart
import 'package:block_seasons/services/ad_frequency_policy.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final t0 = DateTime(2026, 1, 1, 12, 0, 0);
AdFrequencyPolicy primed() {
// Past the 5-stage protection and the 3-stage gap, last ad long ago.
final p = AdFrequencyPolicy();
for (var i = 0; i < 6; i++) {
p.onRoundStart();
p.onStageCompleted();
}
return p;
}
test('first 5 stages are protected from interstitials', () {
final p = AdFrequencyPolicy();
for (var i = 0; i < 5; i++) {
p.onRoundStart();
p.onStageCompleted();
expect(p.canShowInterstitial(t0), isFalse, reason: 'stage ${i + 1}');
}
// 6th completed stage clears protection.
p.onRoundStart();
p.onStageCompleted();
expect(p.canShowInterstitial(t0), isTrue);
});
test('needs >=3 stages since the last interstitial', () {
final p = primed();
expect(p.canShowInterstitial(t0), isTrue);
p.onInterstitialShown(t0);
final later = t0.add(const Duration(seconds: 120));
// Only 2 stages since: still blocked even though time elapsed.
p.onRoundStart();
p.onStageCompleted();
p.onRoundStart();
p.onStageCompleted();
expect(p.canShowInterstitial(later), isFalse);
// 3rd stage opens the gate.
p.onRoundStart();
p.onStageCompleted();
expect(p.canShowInterstitial(later), isTrue);
});
test('needs >=90s since the last interstitial', () {
final p = primed();
p.onInterstitialShown(t0);
for (var i = 0; i < 3; i++) {
p.onRoundStart();
p.onStageCompleted();
}
expect(p.canShowInterstitial(t0.add(const Duration(seconds: 89))), isFalse);
expect(p.canShowInterstitial(t0.add(const Duration(seconds: 90))), isTrue);
});
test('a rewarded watched this round blocks the interstitial', () {
final p = primed();
expect(p.canShowInterstitial(t0), isTrue);
p.onRewardedShown();
expect(p.canShowInterstitial(t0), isFalse);
// Next round clears it.
p.onRoundStart();
p.onStageCompleted();
expect(p.canShowInterstitial(t0), isTrue);
});
test('showing an interstitial resets the stage counter', () {
final p = primed();
p.onInterstitialShown(t0);
final later = t0.add(const Duration(seconds: 200));
expect(p.canShowInterstitial(later), isFalse); // 0 stages since
});
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `flutter test test/services/ad_frequency_policy_test.dart`
Expected: FAIL — `ad_frequency_policy.dart` does not exist.
- [ ] **Step 3: Write the implementation**
```dart
// lib/services/ad_frequency_policy.dart
/// Pure-Dart interstitial gate. No plugin imports so the monetization rules
/// are unit-tested headlessly. The service layer feeds it lifecycle events
/// and asks [canShowInterstitial] before every stage-end interstitial.
class AdFrequencyPolicy {
AdFrequencyPolicy({
this.minStagesBetween = 3,
this.minInterval = const Duration(seconds: 90),
this.firstStagesProtected = 5,
});
final int minStagesBetween;
final Duration minInterval;
final int firstStagesProtected;
int _totalStagesCompleted = 0;
int _stagesSinceLastInterstitial = 0;
bool _rewardedShownThisRound = false;
DateTime? _lastInterstitialAt;
/// A new stage attempt began (fresh round). Clears the per-round rewarded
/// flag so last round's rewarded does not block this round's interstitial.
void onRoundStart() => _rewardedShownThisRound = false;
/// A stage finished (won or lost). Drives both counters.
void onStageCompleted() {
_totalStagesCompleted++;
_stagesSinceLastInterstitial++;
}
/// The player watched a rewarded ad in the current round.
void onRewardedShown() => _rewardedShownThisRound = true;
/// An interstitial was actually shown. Resets the spacing counters.
void onInterstitialShown(DateTime now) {
_stagesSinceLastInterstitial = 0;
_lastInterstitialAt = now;
}
bool canShowInterstitial(DateTime now) {
if (_totalStagesCompleted <= firstStagesProtected) return false;
if (_rewardedShownThisRound) return false;
if (_stagesSinceLastInterstitial < minStagesBetween) return false;
final last = _lastInterstitialAt;
if (last != null && now.difference(last) < minInterval) return false;
return true;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `flutter test test/services/ad_frequency_policy_test.dart`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add lib/services/ad_frequency_policy.dart test/services/ad_frequency_policy_test.dart
git commit -m "feat(ads): pure-Dart interstitial frequency policy with tests"
```
---
## Task 3: SaveRepository.adsRemoved persistence — TDD
**Files:**
- Modify: `lib/data/save_repository.dart`
- Test: `test/data/save_repository_ads_test.dart`
- [ ] **Step 1: Write the failing test**
```dart
// test/data/save_repository_ads_test.dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('adsRemoved defaults to false and persists across reopen', () async {
final repo = await SaveRepository.open();
expect(repo.adsRemoved, isFalse);
await repo.setAdsRemoved(true);
expect(repo.adsRemoved, isTrue);
final reopened = await SaveRepository.open();
expect(reopened.adsRemoved, isTrue);
});
test('legacy save without the ads flag reads as false', () async {
SharedPreferences.setMockInitialValues({
'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}',
});
final repo = await SaveRepository.open();
expect(repo.adsRemoved, isFalse);
expect(repo.tutorialDone, isTrue);
});
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `flutter test test/data/save_repository_ads_test.dart`
Expected: FAIL — `adsRemoved`/`setAdsRemoved` undefined.
- [ ] **Step 3: Implement the additive flag**
In `lib/data/save_repository.dart`:
Add the field after `int _endlessBest = 0;`:
```dart
bool _adsRemoved = false;
```
In the constructor, after the `_endlessBest = ...` line, add:
```dart
_adsRemoved =
(json['flags'] as Map<String, dynamic>?)?['adsRemoved'] as bool? ??
false;
```
Add the getter near `bool get tutorialDone`:
```dart
bool get adsRemoved => _adsRemoved;
```
Add the setter near `markTutorialDone`:
```dart
Future<void> setAdsRemoved(bool value) {
_adsRemoved = value;
return _flush();
}
```
In `_flush()`, change the `'flags'` entry to include the new key:
```dart
'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved},
```
- [ ] **Step 4: Run test to verify it passes**
Run: `flutter test test/data/save_repository_ads_test.dart`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add lib/data/save_repository.dart test/data/save_repository_ads_test.dart
git commit -m "feat(iap): persist adsRemoved flag (additive, saveVersion 1)"
```
---
## Task 4: Ad config (test vs real IDs)
**Files:**
- Create: `lib/services/ad_config.dart`
- [ ] **Step 1: Write the config**
```dart
// lib/services/ad_config.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Ad-unit and app IDs. Dev/test builds use Google's official sample IDs,
/// which serve real test ads and never trigger an AdMob policy strike. The
/// owner replaces the `_real*` constants (and the two native manifests'
/// APPLICATION_ID) with the AdMob console values for release.
///
/// `--dart-define=USE_TEST_ADS=true` forces test IDs even in a release build,
/// for store-review smoke tests on real devices.
class AdConfig {
static const _forceTest =
bool.fromEnvironment('USE_TEST_ADS', defaultValue: false);
static bool get useTestIds => kDebugMode || _forceTest;
// --- Google official test ad units ---
static const _testInterstitialAndroid =
'ca-app-pub-3940256099942544/1033173712';
static const _testInterstitialIos =
'ca-app-pub-3940256099942544/4411468910';
static const _testRewardedAndroid =
'ca-app-pub-3940256099942544/5224354917';
static const _testRewardedIos = 'ca-app-pub-3940256099942544/1712485313';
static const _testBannerAndroid = 'ca-app-pub-3940256099942544/6300978111';
static const _testBannerIos = 'ca-app-pub-3940256099942544/2934735716';
// --- Owner replaces these with real AdMob console unit IDs ---
static const _realInterstitialAndroid = 'TODO_REAL_INTERSTITIAL_ANDROID';
static const _realInterstitialIos = 'TODO_REAL_INTERSTITIAL_IOS';
static const _realRewardedAndroid = 'TODO_REAL_REWARDED_ANDROID';
static const _realRewardedIos = 'TODO_REAL_REWARDED_IOS';
static const _realBannerAndroid = 'TODO_REAL_BANNER_ANDROID';
static const _realBannerIos = 'TODO_REAL_BANNER_IOS';
static String _pick(String testA, String testI, String realA, String realI) {
final android = useTestIds ? testA : realA;
final ios = useTestIds ? testI : realI;
return Platform.isAndroid ? android : ios;
}
static String get interstitial => _pick(_testInterstitialAndroid,
_testInterstitialIos, _realInterstitialAndroid, _realInterstitialIos);
static String get rewarded => _pick(_testRewardedAndroid, _testRewardedIos,
_realRewardedAndroid, _realRewardedIos);
static String get banner => _pick(
_testBannerAndroid, _testBannerIos, _realBannerAndroid, _realBannerIos);
/// Non-consumable product id for the "remove ads" purchase. Must match the
/// product created in App Store Connect and Google Play Console.
static const removeAdsProductId = 'remove_ads';
}
```
- [ ] **Step 2: Analyze**
Run: `flutter analyze lib/services/ad_config.dart`
Expected: No issues.
- [ ] **Step 3: Commit**
```bash
git add lib/services/ad_config.dart
git commit -m "feat(ads): ad id config with test/real switch via dart-define"
```
---
## Task 5: AdService (interstitial + rewarded + banner)
**Files:**
- Create: `lib/services/ad_service.dart`
No unit test (pure plugin glue; the testable decision lives in `AdFrequencyPolicy`). Every plugin call is wrapped so a failure is swallowed.
- [ ] **Step 1: Write the service**
```dart
// 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: (_, __) => 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();
}
}
```
This file imports `dart:async` for `Completer`; add it at the top alongside the `google_mobile_ads` import.
- [ ] **Step 2: Analyze**
Run: `flutter analyze lib/services/ad_service.dart`
Expected: No issues.
- [ ] **Step 3: Commit**
```bash
git add lib/services/ad_service.dart
git commit -m "feat(ads): AdService for interstitial/rewarded/banner with policy gating"
```
---
## Task 6: ConsentService (UMP → ATT → SDK init)
**Files:**
- Create: `lib/services/consent_service.dart`
- [ ] **Step 1: Write the service**
```dart
// lib/services/consent_service.dart
import 'dart:io' show Platform;
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'ad_service.dart';
/// Runs the App-Review-mandated consent sequence exactly once per launch and
/// in the required order: UMP consent form -> iOS ATT prompt -> MobileAds
/// initialize. Each step is guarded; a failure in one never skips SDK init,
/// because un-initialized ads would silently disable the whole monetization
/// layer. Must be invoked AFTER the first frame (ATT needs the foreground).
class ConsentService {
ConsentService(this._adService);
final AdService _adService;
bool _ran = false;
Future<void> ensureConsentAndInitialize() async {
if (_ran) return;
_ran = true;
await _requestUmp();
await _requestAtt();
await _initializeAds();
}
Future<void> _requestUmp() async {
try {
final params = ConsentRequestParameters();
final completer = Completer<void>();
ConsentInformation.instance.requestConsentInfoUpdate(
params,
() async {
try {
if (await ConsentInformation.instance.isConsentFormAvailable()) {
await _loadAndShowFormIfRequired();
}
} finally {
completer.complete();
}
},
(_) => completer.complete(),
);
await completer.future;
} catch (_) {/* proceed without UMP */}
}
Future<void> _loadAndShowFormIfRequired() async {
final completer = Completer<void>();
ConsentForm.loadConsentForm(
(form) async {
final status = await ConsentInformation.instance.getConsentStatus();
if (status == ConsentStatus.required) {
form.show((_) => completer.complete());
} else {
completer.complete();
}
},
(_) => completer.complete(),
);
await completer.future;
}
Future<void> _requestAtt() async {
if (!Platform.isIOS) return;
try {
final status =
await AppTrackingTransparency.trackingAuthorizationStatus;
if (status == TrackingStatus.notDetermined) {
await AppTrackingTransparency.requestTrackingAuthorization();
}
} catch (_) {/* ATT optional */}
}
Future<void> _initializeAds() async {
try {
await MobileAds.instance.initialize();
_adService.onSdkReady();
} catch (e) {
debugPrint('MobileAds init failed: $e');
}
}
}
```
- [ ] **Step 2: Add the missing import**
The file uses `Completer`; add at the top:
```dart
import 'dart:async';
```
- [ ] **Step 3: Analyze**
Run: `flutter analyze lib/services/consent_service.dart`
Expected: No issues.
- [ ] **Step 4: Commit**
```bash
git add lib/services/consent_service.dart
git commit -m "feat(ads): ConsentService enforcing UMP -> ATT -> SDK init order"
```
---
## Task 7: IapService + AdsRemovedNotifier
**Files:**
- Create: `lib/services/iap_service.dart`
- Create: `lib/state/ads_notifier.dart`
- Test: `test/state/ads_notifier_test.dart`
- [ ] **Step 1: Write the AdsRemovedNotifier test**
```dart
// test/state/ads_notifier_test.dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/state/ads_notifier.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('reads persisted ownership and updates on grant', () async {
SharedPreferences.setMockInitialValues({});
final repo = await SaveRepository.open();
final container = ProviderContainer(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
expect(container.read(adsRemovedProvider), isFalse);
await container.read(adsRemovedProvider.notifier).grant();
expect(container.read(adsRemovedProvider), isTrue);
expect(repo.adsRemoved, isTrue);
});
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `flutter test test/state/ads_notifier_test.dart`
Expected: FAIL — `ads_notifier.dart` / `adsRemovedProvider` undefined.
- [ ] **Step 3: Write AdsRemovedNotifier**
```dart
// lib/state/ads_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// Whether the player owns the remove-ads entitlement. Seeded from the save
/// repository; [grant] flips it on (after a successful purchase/restore) and
/// persists.
class AdsRemovedNotifier extends Notifier<bool> {
@override
bool build() => ref.read(saveRepositoryProvider).adsRemoved;
Future<void> grant() async {
if (state) return;
await ref.read(saveRepositoryProvider).setAdsRemoved(true);
state = true;
}
}
```
- [ ] **Step 4: Register the provider**
In `lib/state/providers.dart`, add the import and provider (full wiring lands in Task 8, but add this one now so the test passes):
```dart
import 'ads_notifier.dart';
// ...
final adsRemovedProvider =
NotifierProvider<AdsRemovedNotifier, bool>(AdsRemovedNotifier.new);
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `flutter test test/state/ads_notifier_test.dart`
Expected: PASS.
- [ ] **Step 6: Write IapService**
```dart
// lib/services/iap_service.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'ad_config.dart';
/// Wraps the non-consumable `remove_ads` purchase. On any verified purchase
/// or restore of the product it invokes [onEntitlementGranted]; the caller
/// flips the AdsRemovedNotifier. All failures are swallowed.
class IapService {
IapService({required this.onEntitlementGranted});
final Future<void> Function() onEntitlementGranted;
final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
ProductDetails? _product;
bool get available => _available;
bool _available = false;
ProductDetails? get product => _product;
Future<void> initialize() async {
try {
_available = await _iap.isAvailable();
if (!_available) return;
_sub = _iap.purchaseStream.listen(_onPurchases,
onError: (_) {}, cancelOnError: false);
final response =
await _iap.queryProductDetails({AdConfig.removeAdsProductId});
if (response.productDetails.isNotEmpty) {
_product = response.productDetails.first;
}
} catch (e) {
debugPrint('IAP init failed: $e');
}
}
Future<void> buyRemoveAds() async {
final product = _product;
if (product == null) return;
try {
await _iap.buyNonConsumable(
purchaseParam: PurchaseParam(productDetails: product));
} catch (e) {
debugPrint('IAP buy failed: $e');
}
}
Future<void> restorePurchases() async {
try {
await _iap.restorePurchases();
} catch (e) {
debugPrint('IAP restore failed: $e');
}
}
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
for (final p in purchases) {
if (p.productID != AdConfig.removeAdsProductId) continue;
if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) {
await onEntitlementGranted();
}
if (p.pendingCompletePurchase) {
await _iap.completePurchase(p);
}
}
}
void dispose() => _sub?.cancel();
}
```
- [ ] **Step 7: Analyze**
Run: `flutter analyze lib/services/iap_service.dart lib/state/ads_notifier.dart`
Expected: No issues.
- [ ] **Step 8: Commit**
```bash
git add lib/services/iap_service.dart lib/state/ads_notifier.dart lib/state/providers.dart test/state/ads_notifier_test.dart
git commit -m "feat(iap): remove_ads purchase/restore service + adsRemoved notifier"
```
---
## Task 8: Provider wiring (single source of truth)
**Files:**
- Modify: `lib/state/providers.dart`
The three services self-construct from `ref` so there is ONE source of truth
for ownership: `adsRemovedProvider` (repo-backed). `AdService` reads it live;
`IapService` flips it via the notifier on a verified purchase/restore. No
mutable holder, no `main.dart` override for these three.
- [ ] **Step 1: Add the service providers**
Add imports:
```dart
import '../services/ad_service.dart';
import '../services/consent_service.dart';
import '../services/iap_service.dart';
```
Add providers (after `adsRemovedProvider` from Task 7):
```dart
/// Reads ownership live from [adsRemovedProvider]; a mid-session purchase
/// takes effect on the next ad decision without re-wiring.
final adServiceProvider = Provider<AdService>((ref) {
final service = AdService(adsRemoved: () => ref.read(adsRemovedProvider));
ref.onDispose(service.dispose);
return service;
});
final consentServiceProvider = Provider<ConsentService>(
(ref) => ConsentService(ref.read(adServiceProvider)),
);
/// A verified remove_ads purchase/restore grants the entitlement through the
/// notifier (persists + flips state), which AdService and the banner observe.
final iapServiceProvider = Provider<IapService>((ref) {
final service = IapService(
onEntitlementGranted: () =>
ref.read(adsRemovedProvider.notifier).grant(),
);
ref.onDispose(service.dispose);
service.initialize();
return service;
});
```
- [ ] **Step 2: Analyze**
Run: `flutter analyze lib/state/providers.dart`
Expected: No issues.
- [ ] **Step 3: Commit**
```bash
git add lib/state/providers.dart
git commit -m "feat(ads): ref-constructed ad/consent/iap providers, single ownership source"
```
---
## Task 9: app.dart bootstrap (consent after first frame)
**Files:**
- Modify: `lib/app.dart`
`main.dart` needs no changes for the ad/consent/iap services — they
self-construct from `ref` (Task 8). The only bootstrap step is triggering the
consent flow once after the first frame (ATT requires the foreground).
- [ ] **Step 1: Trigger consent after first frame in app.dart**
Convert `BlockSeasonsApp` to a `ConsumerStatefulWidget` and run the consent flow once after the first frame (ATT requires foreground):
```dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'l10n/gen/app_localizations.dart';
import 'state/providers.dart';
import 'ui/screens/splash_screen.dart';
class BlockSeasonsApp extends ConsumerStatefulWidget {
const BlockSeasonsApp({super.key});
@override
ConsumerState<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
}
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(consentServiceProvider).ensureConsentAndInitialize();
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('en'), Locale('ko')],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF5B7FFF),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const SplashScreen(),
);
}
}
```
- [ ] **Step 2: Verify the suite still builds and passes**
Run: `flutter analyze && flutter test`
Expected: No analyzer issues; all tests pass. `BlockSeasonsApp` is rarely pumped directly; if `test/widget_test.dart` pumps it, it now triggers `consentServiceProvider` on first frame — that provider self-constructs and the consent call is fire-and-forget, so it is harmless in tests (plugin calls no-op under the test binding). If any test fails on a missing plugin, pump the child screen directly instead of the whole app.
- [ ] **Step 3: Commit**
```bash
git add lib/app.dart
git commit -m "feat(ads): run consent flow (UMP->ATT->init) after the first frame"
```
---
## Task 10: Rewarded ad gates the rescue
**Files:**
- Modify: `lib/ui/screens/game_screen.dart`
The rescue buttons (`watchAdContinue`, `plusFiveMoves`) currently call the engine directly. Route them through a rewarded ad first; the engine action runs when `showRewarded()` resolves true. Round lifecycle (`onRoundStart`/`onStageCompleted`) is also driven here.
- [ ] **Step 1: Drive round lifecycle on stage start and completion**
In `_GameScreenState`, the session starts elsewhere; hook the policy via `onSessionChange`. In `_onSessionChange`, when `prev == null && next != null` (first publish of a stage) call `onRoundStart`; when a stage finishes (`next.phase == won || lost`) call `onStageCompleted`. Add at the top of the `if (prev?.phase != next.phase)` block, after the existing tutorial skip:
```dart
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
ref.read(adServiceProvider).onStageCompleted();
}
```
And at the very start of `_onSessionChange`, after the `if (next == null) return;` guard:
```dart
if (prev == null) ref.read(adServiceProvider).onRoundStart();
```
> Note: `restart()` re-publishes from a null-less state, so also call `onRoundStart` when a new attempt begins. Add to `GameSessionNotifier.restart()` is cleaner — but to keep this task UI-only, additionally detect a fresh board: when `prev != null && next.phase == GamePhase.playing && next.score == 0 && next.movesLeft == next.moveLimit` treat as a new round. Simpler and reliable: call `onRoundStart()` inside the `restart()` path in Task 11. For now, the `prev == null` hook covers the first entry.
- [ ] **Step 2: Replace the continue rescue action**
In `_resultOverlay`, the `(GamePhase.stuck, _)` branch `watchAdContinue` button: change `onPressed` to show a rewarded ad first.
```dart
if (!view.rescueUsed)
FilledButton(
onPressed: () async {
final earned =
await ref.read(adServiceProvider).showRewarded();
if (!earned) return;
ref.read(analyticsProvider).rescueUsed(type: 'continue');
notifier.useContinue();
},
child: Text(l10n.watchAdContinue),
),
```
- [ ] **Step 3: Replace the extra-moves rescue action**
In the `(GamePhase.stuck, StuckReason.outOfMoves)` branch `plusFiveMoves` button:
```dart
if (!view.rescueUsed)
FilledButton(
onPressed: () async {
final earned =
await ref.read(adServiceProvider).showRewarded();
if (!earned) return;
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
notifier.addExtraMoves();
},
child: Text(l10n.plusFiveMoves),
),
```
- [ ] **Step 4: Analyze**
Run: `flutter analyze lib/ui/screens/game_screen.dart`
Expected: No issues.
- [ ] **Step 5: Commit**
```bash
git add lib/ui/screens/game_screen.dart
git commit -m "feat(ads): rewarded ad gates the continue/extra-moves rescue"
```
---
## Task 11: Stage-end interstitial + restart round hook
**Files:**
- Modify: `lib/ui/screens/game_screen.dart`
- Modify: `lib/state/game_session_notifier.dart`
- [ ] **Step 1: Fire round-start from restart()**
In `lib/state/game_session_notifier.dart` `restart()`, after `startStage(...)`, the policy must reset the round. Do it through the analytics path already there — add an ad hook. Add to `restart()` after the `startStage` call:
```dart
ref.read(adServiceProvider).onRoundStart();
```
(Confirm `adServiceProvider` is imported via `providers.dart`, already imported here.)
- [ ] **Step 2: Show the interstitial after a non-endless stage ends**
In `game_screen.dart` `_onSessionChange`, the interstitial should fire when the player leaves the result card — but the simplest review-safe trigger is when a non-endless stage reaches `won`/`lost` and the player taps Next/Retry. Show it on result dismissal. Concretely, wrap the "next stage" and "retry" actions: after they run, call `maybeShowInterstitial()`.
For the won branch `nextStage` action in `_resultOverlay`:
```dart
FilledButton(
onPressed: () {
ref.read(seasonFlowProvider.notifier).nextStage();
if (!view.endless) {
ref.read(adServiceProvider).maybeShowInterstitial();
}
},
child: Text(l10n.nextStage),
),
```
For the lost (non-endless) retry `playAgain` (`(_, _)` default branch):
```dart
FilledButton(
onPressed: () {
ref.read(adServiceProvider).maybeShowInterstitial();
notifier.restart();
},
child: Text(l10n.playAgain),
),
```
> Endless mode never shows an interstitial here (`view.endless` guard / endless branch left unchanged). Banner is never on this screen.
- [ ] **Step 3: Analyze**
Run: `flutter analyze lib/ui/screens/game_screen.dart lib/state/game_session_notifier.dart`
Expected: No issues.
- [ ] **Step 4: Run the full suite**
Run: `flutter test`
Expected: all tests pass.
- [ ] **Step 5: Commit**
```bash
git add lib/ui/screens/game_screen.dart lib/state/game_session_notifier.dart
git commit -m "feat(ads): stage-end interstitial gated by policy; restart resets round"
```
---
## Task 12: Banner slot on home + map
**Files:**
- Create: `lib/ui/widgets/banner_ad_slot.dart`
- Modify: `lib/ui/screens/home_screen.dart`
- Modify: `lib/ui/screens/season_map_screen.dart`
- [ ] **Step 1: Write the banner slot widget**
```dart
// lib/ui/widgets/banner_ad_slot.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../../state/providers.dart';
/// A self-loading 320x50 banner for the home/map screens. Renders nothing
/// when ads are removed or the banner has not loaded — never reserves blank
/// space and never appears on the game screen.
class BannerAdSlot extends ConsumerStatefulWidget {
const BannerAdSlot({super.key});
@override
ConsumerState<BannerAdSlot> createState() => _BannerAdSlotState();
}
class _BannerAdSlotState extends ConsumerState<BannerAdSlot> {
BannerAd? _ad;
bool _loaded = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_ad != null) return;
if (ref.read(adsRemovedProvider)) return;
final ad = ref.read(adServiceProvider).createBanner();
if (ad == null) return;
_ad = ad;
// createBanner already called load(); attach a listener for the loaded flag.
ad.listener = BannerAdListener(
onAdLoaded: (_) {
if (mounted) setState(() => _loaded = true);
},
onAdFailedToLoad: (ad, _) => ad.dispose(),
);
}
@override
void dispose() {
_ad?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ad = _ad;
if (ad == null || !_loaded || ref.watch(adsRemovedProvider)) {
return const SizedBox.shrink();
}
return SizedBox(
width: ad.size.width.toDouble(),
height: ad.size.height.toDouble(),
child: AdWidget(ad: ad),
);
}
}
```
> **Implementer note:** `AdService.createBanner()` builds the banner with a `const BannerAdListener()` and calls `load()`. Reassigning `ad.listener` here is not supported after construction. Instead, change `createBanner()` to accept an optional `BannerAdListener`:
> ```dart
> BannerAd? createBanner({BannerAdListener? listener}) { ... listener: listener ?? const BannerAdListener() ... }
> ```
> and pass the listener from the widget at creation time. Update Task 5's `createBanner` accordingly.
- [ ] **Step 2: Fix createBanner to accept a listener**
In `lib/services/ad_service.dart`, change `createBanner` signature to `BannerAd? createBanner({BannerAdListener? listener})` and use `listener: listener ?? const BannerAdListener()`. In the widget, build via:
```dart
final ad = ref.read(adServiceProvider).createBanner(
listener: BannerAdListener(
onAdLoaded: (_) { if (mounted) setState(() => _loaded = true); },
onAdFailedToLoad: (ad, _) => ad.dispose(),
),
);
if (ad == null) return;
_ad = ad;
```
Remove the post-construction `ad.listener = ...` block.
- [ ] **Step 3: Place the banner on the home screen**
In `home_screen.dart`, wrap the body so the banner pins to the bottom. Change the outer `Stack`'s `SafeArea` child from `Center(child: Column(...))` to a `Column` with the existing centered content in an `Expanded` and the banner below:
```dart
SafeArea(
child: Column(
children: [
Expanded(child: Center(child: /* existing Column(...) */ )),
const BannerAdSlot(),
],
),
),
```
Add `import '../widgets/banner_ad_slot.dart';`.
- [ ] **Step 4: Place the banner on the season map**
In `season_map_screen.dart`, add `const BannerAdSlot()` at the bottom of the screen's main `Column` (the one around line 197) or as the last child below the map `Expanded`. Add the import. Ensure it sits inside `SafeArea` and below the scrollable map.
- [ ] **Step 5: Analyze**
Run: `flutter analyze lib/ui/widgets/banner_ad_slot.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart lib/services/ad_service.dart`
Expected: No issues.
- [ ] **Step 6: Commit**
```bash
git add lib/ui/widgets/banner_ad_slot.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart lib/services/ad_service.dart
git commit -m "feat(ads): home/map banner slot (hidden when removed or unloaded)"
```
---
## Task 13: Settings screen (Remove Ads + Restore) + l10n
**Files:**
- Create: `lib/ui/screens/settings_screen.dart`
- Modify: `lib/ui/screens/home_screen.dart`
- Modify: `lib/l10n/app_en.arb`
- Modify: `lib/l10n/app_ko.arb`
- [ ] **Step 1: Add l10n keys (en)**
In `lib/l10n/app_en.arb`, add:
```json
"settings": "Settings",
"removeAds": "Remove ads",
"removeAdsDescription": "Removes banners and full-screen ads. Reward ads stay available.",
"restorePurchases": "Restore purchases",
"adsRemovedThanks": "Ads removed — thank you!",
"purchaseUnavailable": "Purchases are unavailable right now."
```
- [ ] **Step 2: Add l10n keys (ko)**
In `lib/l10n/app_ko.arb`, add:
```json
"settings": "설정",
"removeAds": "광고 제거",
"removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
"restorePurchases": "구매 복원",
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
```
- [ ] **Step 3: Regenerate localizations**
Run: `flutter gen-l10n`
Expected: `lib/l10n/gen/app_localizations.dart` updated with the new getters.
- [ ] **Step 4: Write the settings screen**
```dart
// lib/ui/screens/settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final adsRemoved = ref.watch(adsRemovedProvider);
final iap = ref.read(iapServiceProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.settings)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
ListTile(
title: Text(l10n.removeAds),
subtitle: Text(l10n.removeAdsDescription),
trailing: adsRemoved
? const Icon(Icons.check_circle, color: Colors.green)
: Text(iap.product?.price ?? ''),
onTap: adsRemoved
? null
: () async {
if (!iap.available || iap.product == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.purchaseUnavailable)),
);
return;
}
await iap.buyRemoveAds();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.restore),
title: Text(l10n.restorePurchases),
onTap: () => iap.restorePurchases(),
),
],
),
);
}
}
```
> **Entitlement sync (already wired):** No polling needed. `buyRemoveAds()`/`restorePurchases()` start the platform flow; when it resolves, `IapService`'s purchase stream fires `onEntitlementGranted` → `adsRemovedProvider.notifier.grant()` (Task 8), which persists the flag and flips state. Because Settings uses `ref.watch(adsRemovedProvider)`, the row updates to the green check automatically, the banner disappears (it also watches the provider), and `AdService` reads the new value on its next decision. The "thank you" SnackBar can be shown by adding a `ref.listen(adsRemovedProvider, ...)` at the top of `build`:
> ```dart
> ref.listen<bool>(adsRemovedProvider, (prev, next) {
> if (next && !(prev ?? false)) {
> ScaffoldMessenger.of(context).showSnackBar(
> SnackBar(content: Text(l10n.adsRemovedThanks)),
> );
> }
> });
> ```
> Add this listen call inside `build` before returning the `Scaffold`.
- [ ] **Step 5: Add a settings gear to the home screen**
In `home_screen.dart`, add a top-right settings button. Wrap the `SafeArea` content in a `Stack` (or add to the existing one) with:
```dart
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.settings, color: Colors.white70),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
),
```
Add `import 'settings_screen.dart';`.
- [ ] **Step 6: Analyze + full test**
Run: `flutter analyze && flutter test`
Expected: No analyzer issues; all tests pass.
- [ ] **Step 7: Commit**
```bash
git add lib/ui/screens/settings_screen.dart lib/ui/screens/home_screen.dart lib/l10n/app_en.arb lib/l10n/app_ko.arb lib/l10n/gen/app_localizations.dart
git commit -m "feat(iap): settings screen with remove-ads purchase and restore"
```
---
## Task 14: Integration verification on simulator
**Files:** none (verification only)
- [ ] **Step 1: Build for the simulator**
Run: `flutter build ios --debug --simulator`
Expected: `✓ Built build/ios/iphonesimulator/Runner.app`.
- [ ] **Step 2: Install, launch, and capture the console**
```bash
xcrun simctl install booted build/ios/iphonesimulator/Runner.app
xcrun simctl launch --console-pty booted com.airkjw.blockseasons > /tmp/bs_ads_run.log 2>&1 &
```
Wait ~12s, then inspect the system log:
```bash
xcrun simctl spawn booted log show --last 30s --predicate 'process == "Runner"' | grep -iE "consent|ATT|MobileAds|interstitial|rewarded|banner|GADApplication"
```
Expected: evidence that `MobileAds` initialized after consent; no crash referencing a missing `GADApplicationIdentifier`.
- [ ] **Step 3: Manual smoke checklist (document results in the commit message)**
- Cold start: consent/ATT prompt appears once (or is skipped on simulator), app reaches home without crashing.
- Home/map: a test banner ("Test Ad") shows at the bottom.
- Play 6+ stages, fail one, tap Retry: a test interstitial appears (respects the 5-stage protection + 3-stage gap).
- Get stuck, tap Continue: a test rewarded ad shows; on completion the board continues; that round's stage-end interstitial is suppressed.
- Game screen: never shows a banner.
- Settings → Remove ads (sandbox): banner + interstitial disappear; rewarded still works. Relaunch: ownership persists.
- [ ] **Step 4: Capture a screenshot of the banner on home**
```bash
xcrun simctl io booted screenshot /tmp/bs_ads.png
cp /tmp/bs_ads.png docs/screenshots/sim_ads_banner.png
```
- [ ] **Step 5: Commit the evidence**
```bash
git add docs/screenshots/sim_ads_banner.png
git commit -m "docs: Phase 5 ad integration verified on simulator (test ads)"
```
---
## Self-Review
**Spec coverage:**
- Interstitial caps (3 stages / 90s / no-rewarded-this-round / first-5-protected) → Task 2 (policy) + Task 11 (trigger). ✓
- Rewarded once per stage, mutually exclusive with continue → Task 10 + engine's existing `rescueUsed`. ✓
- Banner home/map only, never game → Task 12 (slot only on home/map). ✓
- Consent order UMP → ATT → init → Task 6 + Task 9 (post-first-frame). ✓
- `remove_ads` non-consumable, disables interstitial+banner, keeps rewarded, Restore button → Tasks 3, 7, 12, 13. ✓
- Test IDs via kDebugMode/dart-define → Task 4. ✓
- Owner real IDs slot in by config → Task 4 constants + Task 1 native manifests. ✓
**Known follow-ups (out of scope, note to owner):** real AdMob app + 3 ad units, App Store/Play `remove_ads` product, `app-ads.txt` (Phase 7). Until then everything runs on Google test IDs.
**Placeholder scan:** the `TODO_REAL_*` constants in `ad_config.dart` are intentional owner-fill values, documented as such — not plan placeholders. The `_BoolOnce` scaffolding in Task 5 is explicitly replaced by a clean `Completer<bool>` in Task 5 Step 2.
**Type consistency:** `AdService.createBanner({BannerAdListener? listener})` (Task 5 amended by Task 12 Step 2), `showRewarded() → Future<bool>`, `AdFrequencyPolicy` method names (`onRoundStart`/`onStageCompleted`/`onRewardedShown`/`onInterstitialShown`/`canShowInterstitial`) used identically in Tasks 5, 10, 11. `adsRemovedProvider`, `adServiceProvider`, `consentServiceProvider`, `iapServiceProvider` consistent across Tasks 713.