# 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.