diff --git a/docs/superpowers/plans/2026-06-13-monetization.md b/docs/superpowers/plans/2026-06-13-monetization.md new file mode 100644 index 0000000..6ee4bb9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-monetization.md @@ -0,0 +1,1502 @@ +# 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) 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 ``, add (uses Google's sample AdMob **App ID** for dev; owner swaps later): + +```xml + GADApplicationIdentifier + ca-app-pub-3940256099942544~1458002511 + NSUserTrackingUsageDescription + We use this to show ads that are more relevant to you. You can play fully either way. + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + +``` + +- [ ] **Step 3: Android manifest — AdMob app id** + +In `android/app/src/main/AndroidManifest.xml`, inside ``, add (Google sample App ID): + +```xml + +``` + +- [ ] **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?)?['adsRemoved'] as bool? ?? + false; +``` + +Add the getter near `bool get tutorialDone`: +```dart + bool get adsRemoved => _adsRemoved; +``` + +Add the setter near `markTutorialDone`: +```dart + Future 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 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: (_, __) => 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 ensureConsentAndInitialize() async { + if (_ran) return; + _ran = true; + await _requestUmp(); + await _requestAtt(); + await _initializeAds(); + } + + Future _requestUmp() async { + try { + final params = ConsentRequestParameters(); + final completer = Completer(); + 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 _loadAndShowFormIfRequired() async { + final completer = Completer(); + 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 _requestAtt() async { + if (!Platform.isIOS) return; + try { + final status = + await AppTrackingTransparency.trackingAuthorizationStatus; + if (status == TrackingStatus.notDetermined) { + await AppTrackingTransparency.requestTrackingAuthorization(); + } + } catch (_) {/* ATT optional */} + } + + Future _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 { + @override + bool build() => ref.read(saveRepositoryProvider).adsRemoved; + + Future 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.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 Function() onEntitlementGranted; + final InAppPurchase _iap = InAppPurchase.instance; + StreamSubscription>? _sub; + ProductDetails? _product; + + bool get available => _available; + bool _available = false; + + ProductDetails? get product => _product; + + Future 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 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 restorePurchases() async { + try { + await _iap.restorePurchases(); + } catch (e) { + debugPrint('IAP restore failed: $e'); + } + } + + Future _onPurchases(List 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((ref) { + final service = AdService(adsRemoved: () => ref.read(adsRemovedProvider)); + ref.onDispose(service.dispose); + return service; +}); + +final consentServiceProvider = Provider( + (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((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 createState() => _BlockSeasonsAppState(); +} + +class _BlockSeasonsAppState extends ConsumerState { + @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 createState() => _BannerAdSlotState(); +} + +class _BannerAdSlotState extends ConsumerState { + 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(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` in Task 5 Step 2. + +**Type consistency:** `AdService.createBanner({BannerAdListener? listener})` (Task 5 amended by Task 12 Step 2), `showRewarded() → Future`, `AdFrequencyPolicy` method names (`onRoundStart`/`onStageCompleted`/`onRewardedShown`/`onInterstitialShown`/`canShowInterstitial`) used identically in Tasks 5, 10, 11. `adsRemovedProvider`, `adServiceProvider`, `consentServiceProvider`, `iapServiceProvider` consistent across Tasks 7–13.