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

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

51 KiB
Raw Blame History

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.dartremove_ads purchase/restore over in_app_purchase; pushes ownership into a callback.
  • lib/state/ads_notifier.dartAdsRemovedNotifier (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.dartadsRemoved persistence.
  • test/state/ads_notifier_test.dart — notifier reads/writes through the repo.

Modified files:

  • pubspec.yaml — add the three plugins.
  • ios/Runner/Info.plistGADApplicationIdentifier, 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:

flutter pub add google_mobile_ads in_app_purchase app_tracking_transparency

Expected: three packages added under dependencies: in pubspec.yaml, flutter pub get succeeds.

  • Step 2: iOS Info.plist — AdMob app id, ATT text, SKAdNetwork

In ios/Runner/Info.plist, inside the top-level <dict>, add (uses Google's sample AdMob App ID for dev; owner swaps later):

	<key>GADApplicationIdentifier</key>
	<string>ca-app-pub-3940256099942544~1458002511</string>
	<key>NSUserTrackingUsageDescription</key>
	<string>We use this to show ads that are more relevant to you. You can play fully either way.</string>
	<key>SKAdNetworkItems</key>
	<array>
		<dict>
			<key>SKAdNetworkIdentifier</key>
			<string>cstr6suwn9.skadnetwork</string>
		</dict>
	</array>
  • Step 3: Android manifest — AdMob app id

In android/app/src/main/AndroidManifest.xml, inside <application ...>, add (Google sample App ID):

        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="ca-app-pub-3940256099942544~3347511713"/>
  • Step 4: Verify it still builds

Run: flutter build ios --debug --simulator --no-codesign Expected: ✓ Built build/ios/iphonesimulator/Runner.app (pod install pulls Google-Mobile-Ads-SDK).

  • Step 5: Commit
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

// 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
// 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
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

// 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;:

  bool _adsRemoved = false;

In the constructor, after the _endlessBest = ... line, add:

      _adsRemoved =
          (json['flags'] as Map<String, dynamic>?)?['adsRemoved'] as bool? ??
              false;

Add the getter near bool get tutorialDone:

  bool get adsRemoved => _adsRemoved;

Add the setter near markTutorialDone:

  Future<void> setAdsRemoved(bool value) {
    _adsRemoved = value;
    return _flush();
  }

In _flush(), change the 'flags' entry to include the new key:

          '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
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

// 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
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
// lib/services/ad_service.dart
import 'dart:async';

import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'ad_config.dart';
import 'ad_frequency_policy.dart';

/// Owns the interstitial/rewarded/banner lifecycle. Holds the
/// [AdFrequencyPolicy] and never shows an ad the policy or [adsRemoved]
/// forbids. All failures are swallowed — ads must never break gameplay.
///
/// [adsRemoved] is read through a getter callback so a mid-session purchase
/// takes effect without re-wiring the service.
class AdService {
  AdService({
    required bool Function() adsRemoved,
    AdFrequencyPolicy? policy,
    DateTime Function() now = DateTime.now,
  })  : _adsRemoved = adsRemoved,
        policy = policy ?? AdFrequencyPolicy(),
        _now = now;

  final bool Function() _adsRemoved;
  final AdFrequencyPolicy policy;
  final DateTime Function() _now;

  InterstitialAd? _interstitial;
  RewardedAd? _rewarded;
  bool _initialized = false;

  /// Called by ConsentService once the SDK is initialized. Preloads ads.
  void onSdkReady() {
    _initialized = true;
    _loadInterstitial();
    _loadRewarded();
  }

  // ---- lifecycle hooks the game calls ----
  void onRoundStart() => policy.onRoundStart();
  void onStageCompleted() => policy.onStageCompleted();

  void _loadInterstitial() {
    if (!_initialized || _adsRemoved()) return;
    InterstitialAd.load(
      adUnitId: AdConfig.interstitial,
      request: const AdRequest(),
      adLoadCallback: InterstitialAdLoadCallback(
        onAdLoaded: (ad) => _interstitial = ad,
        onAdFailedToLoad: (_) => _interstitial = null,
      ),
    );
  }

  void _loadRewarded() {
    if (!_initialized) return; // rewarded stays available even with adsRemoved
    RewardedAd.load(
      adUnitId: AdConfig.rewarded,
      request: const AdRequest(),
      rewardedAdLoadCallback: RewardedAdLoadCallback(
        onAdLoaded: (ad) => _rewarded = ad,
        onAdFailedToLoad: (_) => _rewarded = null,
      ),
    );
  }

  /// Shows a stage-end interstitial when the policy and adsRemoved allow it.
  /// No-op (and reloads) otherwise. Safe to call after every finished stage.
  void maybeShowInterstitial() {
    if (_adsRemoved()) return;
    final ad = _interstitial;
    if (ad == null || !policy.canShowInterstitial(_now())) {
      if (ad == null) _loadInterstitial();
      return;
    }
    _interstitial = null;
    ad.fullScreenContentCallback = FullScreenContentCallback(
      onAdDismissedFullScreenContent: (ad) {
        ad.dispose();
        _loadInterstitial();
      },
      onAdFailedToShowFullScreenContent: (ad, _) {
        ad.dispose();
        _loadInterstitial();
      },
    );
    policy.onInterstitialShown(_now());
    ad.show();
  }

  /// Shows a rewarded ad and resolves `true` when the reward is earned. If no
  /// ad is loaded, resolves `true` anyway — the rescue is a player benefit and
  /// must not be blocked by ad availability. A genuine dismissal-without-earn
  /// resolves `false`. Records the watch in the policy so it suppresses this
  /// round's interstitial.
  Future<bool> showRewarded() async {
    policy.onRewardedShown();
    final ad = _rewarded;
    if (ad == null) {
      _loadRewarded();
      return true; // no ad available -> grant the rescue
    }
    _rewarded = null;
    final completer = Completer<bool>();
    void finish(bool v) {
      if (!completer.isCompleted) completer.complete(v);
    }

    ad.fullScreenContentCallback = FullScreenContentCallback(
      onAdDismissedFullScreenContent: (ad) {
        ad.dispose();
        _loadRewarded();
        finish(false); // dismissed; earn already resolved true if it happened
      },
      onAdFailedToShowFullScreenContent: (ad, _) {
        ad.dispose();
        _loadRewarded();
        finish(true); // failed to present -> grant
      },
    );
    ad.show(onUserEarnedReward: (_, __) => finish(true));
    return completer.future;
  }

  /// Creates a fresh banner for the home/map slot, or null when ads are
  /// removed. The caller passes a listener and owns disposal.
  BannerAd? createBanner({BannerAdListener? listener}) {
    if (_adsRemoved() || !_initialized) return null;
    return BannerAd(
      adUnitId: AdConfig.banner,
      size: AdSize.banner,
      request: const AdRequest(),
      listener: listener ?? const BannerAdListener(),
    )..load();
  }

  void dispose() {
    _interstitial?.dispose();
    _rewarded?.dispose();
  }
}

This file imports dart:async for Completer; add it at the top alongside the google_mobile_ads import.

  • Step 2: Analyze

Run: flutter analyze lib/services/ad_service.dart Expected: No issues.

  • Step 3: Commit
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

// lib/services/consent_service.dart
import 'dart:io' show Platform;

import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'ad_service.dart';

/// Runs the App-Review-mandated consent sequence exactly once per launch and
/// in the required order: UMP consent form -> iOS ATT prompt -> MobileAds
/// initialize. Each step is guarded; a failure in one never skips SDK init,
/// because un-initialized ads would silently disable the whole monetization
/// layer. Must be invoked AFTER the first frame (ATT needs the foreground).
class ConsentService {
  ConsentService(this._adService);

  final AdService _adService;
  bool _ran = false;

  Future<void> ensureConsentAndInitialize() async {
    if (_ran) return;
    _ran = true;
    await _requestUmp();
    await _requestAtt();
    await _initializeAds();
  }

  Future<void> _requestUmp() async {
    try {
      final params = ConsentRequestParameters();
      final completer = Completer<void>();
      ConsentInformation.instance.requestConsentInfoUpdate(
        params,
        () async {
          try {
            if (await ConsentInformation.instance.isConsentFormAvailable()) {
              await _loadAndShowFormIfRequired();
            }
          } finally {
            completer.complete();
          }
        },
        (_) => completer.complete(),
      );
      await completer.future;
    } catch (_) {/* proceed without UMP */}
  }

  Future<void> _loadAndShowFormIfRequired() async {
    final completer = Completer<void>();
    ConsentForm.loadConsentForm(
      (form) async {
        final status = await ConsentInformation.instance.getConsentStatus();
        if (status == ConsentStatus.required) {
          form.show((_) => completer.complete());
        } else {
          completer.complete();
        }
      },
      (_) => completer.complete(),
    );
    await completer.future;
  }

  Future<void> _requestAtt() async {
    if (!Platform.isIOS) return;
    try {
      final status =
          await AppTrackingTransparency.trackingAuthorizationStatus;
      if (status == TrackingStatus.notDetermined) {
        await AppTrackingTransparency.requestTrackingAuthorization();
      }
    } catch (_) {/* ATT optional */}
  }

  Future<void> _initializeAds() async {
    try {
      await MobileAds.instance.initialize();
      _adService.onSdkReady();
    } catch (e) {
      debugPrint('MobileAds init failed: $e');
    }
  }
}
  • Step 2: Add the missing import

The file uses Completer; add at the top:

import 'dart:async';
  • Step 3: Analyze

Run: flutter analyze lib/services/consent_service.dart Expected: No issues.

  • Step 4: Commit
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

// 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
// lib/state/ads_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers.dart';

/// Whether the player owns the remove-ads entitlement. Seeded from the save
/// repository; [grant] flips it on (after a successful purchase/restore) and
/// persists.
class AdsRemovedNotifier extends Notifier<bool> {
  @override
  bool build() => ref.read(saveRepositoryProvider).adsRemoved;

  Future<void> grant() async {
    if (state) return;
    await ref.read(saveRepositoryProvider).setAdsRemoved(true);
    state = true;
  }
}
  • Step 4: Register the provider

In lib/state/providers.dart, add the import and provider (full wiring lands in Task 8, but add this one now so the test passes):

import 'ads_notifier.dart';
// ...
final adsRemovedProvider =
    NotifierProvider<AdsRemovedNotifier, bool>(AdsRemovedNotifier.new);
  • Step 5: Run the test to verify it passes

Run: flutter test test/state/ads_notifier_test.dart Expected: PASS.

  • Step 6: Write IapService
// lib/services/iap_service.dart
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

import 'ad_config.dart';

/// Wraps the non-consumable `remove_ads` purchase. On any verified purchase
/// or restore of the product it invokes [onEntitlementGranted]; the caller
/// flips the AdsRemovedNotifier. All failures are swallowed.
class IapService {
  IapService({required this.onEntitlementGranted});

  final Future<void> Function() onEntitlementGranted;
  final InAppPurchase _iap = InAppPurchase.instance;
  StreamSubscription<List<PurchaseDetails>>? _sub;
  ProductDetails? _product;

  bool get available => _available;
  bool _available = false;

  ProductDetails? get product => _product;

  Future<void> initialize() async {
    try {
      _available = await _iap.isAvailable();
      if (!_available) return;
      _sub = _iap.purchaseStream.listen(_onPurchases,
          onError: (_) {}, cancelOnError: false);
      final response =
          await _iap.queryProductDetails({AdConfig.removeAdsProductId});
      if (response.productDetails.isNotEmpty) {
        _product = response.productDetails.first;
      }
    } catch (e) {
      debugPrint('IAP init failed: $e');
    }
  }

  Future<void> buyRemoveAds() async {
    final product = _product;
    if (product == null) return;
    try {
      await _iap.buyNonConsumable(
          purchaseParam: PurchaseParam(productDetails: product));
    } catch (e) {
      debugPrint('IAP buy failed: $e');
    }
  }

  Future<void> restorePurchases() async {
    try {
      await _iap.restorePurchases();
    } catch (e) {
      debugPrint('IAP restore failed: $e');
    }
  }

  Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
    for (final p in purchases) {
      if (p.productID != AdConfig.removeAdsProductId) continue;
      if (p.status == PurchaseStatus.purchased ||
          p.status == PurchaseStatus.restored) {
        await onEntitlementGranted();
      }
      if (p.pendingCompletePurchase) {
        await _iap.completePurchase(p);
      }
    }
  }

  void dispose() => _sub?.cancel();
}
  • Step 7: Analyze

Run: flutter analyze lib/services/iap_service.dart lib/state/ads_notifier.dart Expected: No issues.

  • Step 8: Commit
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:

import '../services/ad_service.dart';
import '../services/consent_service.dart';
import '../services/iap_service.dart';

Add providers (after adsRemovedProvider from Task 7):

/// Reads ownership live from [adsRemovedProvider]; a mid-session purchase
/// takes effect on the next ad decision without re-wiring.
final adServiceProvider = Provider<AdService>((ref) {
  final service = AdService(adsRemoved: () => ref.read(adsRemovedProvider));
  ref.onDispose(service.dispose);
  return service;
});

final consentServiceProvider = Provider<ConsentService>(
  (ref) => ConsentService(ref.read(adServiceProvider)),
);

/// A verified remove_ads purchase/restore grants the entitlement through the
/// notifier (persists + flips state), which AdService and the banner observe.
final iapServiceProvider = Provider<IapService>((ref) {
  final service = IapService(
    onEntitlementGranted: () =>
        ref.read(adsRemovedProvider.notifier).grant(),
  );
  ref.onDispose(service.dispose);
  service.initialize();
  return service;
});
  • Step 2: Analyze

Run: flutter analyze lib/state/providers.dart Expected: No issues.

  • Step 3: Commit
git add lib/state/providers.dart
git commit -m "feat(ads): ref-constructed ad/consent/iap providers, single ownership source"

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):

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'l10n/gen/app_localizations.dart';
import 'state/providers.dart';
import 'ui/screens/splash_screen.dart';

class BlockSeasonsApp extends ConsumerStatefulWidget {
  const BlockSeasonsApp({super.key});

  @override
  ConsumerState<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
}

class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(consentServiceProvider).ensureConsentAndInitialize();
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
      debugShowCheckedModeBanner: false,
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: const [Locale('en'), Locale('ko')],
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF5B7FFF),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const SplashScreen(),
    );
  }
}
  • Step 2: Verify the suite still builds and passes

Run: flutter analyze && flutter test Expected: No analyzer issues; all tests pass. BlockSeasonsApp is rarely pumped directly; if test/widget_test.dart pumps it, it now triggers consentServiceProvider on first frame — that provider self-constructs and the consent call is fire-and-forget, so it is harmless in tests (plugin calls no-op under the test binding). If any test fails on a missing plugin, pump the child screen directly instead of the whole app.

  • Step 3: Commit
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:

      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:

    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.

            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:

            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
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:

    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:

              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):

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

// lib/ui/widgets/banner_ad_slot.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

import '../../state/providers.dart';

/// A self-loading 320x50 banner for the home/map screens. Renders nothing
/// when ads are removed or the banner has not loaded — never reserves blank
/// space and never appears on the game screen.
class BannerAdSlot extends ConsumerStatefulWidget {
  const BannerAdSlot({super.key});

  @override
  ConsumerState<BannerAdSlot> createState() => _BannerAdSlotState();
}

class _BannerAdSlotState extends ConsumerState<BannerAdSlot> {
  BannerAd? _ad;
  bool _loaded = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_ad != null) return;
    if (ref.read(adsRemovedProvider)) return;
    final ad = ref.read(adServiceProvider).createBanner();
    if (ad == null) return;
    _ad = ad;
    // createBanner already called load(); attach a listener for the loaded flag.
    ad.listener = BannerAdListener(
      onAdLoaded: (_) {
        if (mounted) setState(() => _loaded = true);
      },
      onAdFailedToLoad: (ad, _) => ad.dispose(),
    );
  }

  @override
  void dispose() {
    _ad?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final ad = _ad;
    if (ad == null || !_loaded || ref.watch(adsRemovedProvider)) {
      return const SizedBox.shrink();
    }
    return SizedBox(
      width: ad.size.width.toDouble(),
      height: ad.size.height.toDouble(),
      child: AdWidget(ad: ad),
    );
  }
}

Implementer note: AdService.createBanner() builds the banner with a const BannerAdListener() and calls load(). Reassigning ad.listener here is not supported after construction. Instead, change createBanner() to accept an optional BannerAdListener:

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:

    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:

          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
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:

  "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:

  "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
// 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 onEntitlementGrantedadsRemovedProvider.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:

    ref.listen<bool>(adsRemovedProvider, (prev, next) {
      if (next && !(prev ?? false)) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(l10n.adsRemovedThanks)),
        );
      }
    });

Add this listen call inside build before returning the Scaffold.

  • Step 5: Add a settings gear to the home screen

In home_screen.dart, add a top-right settings button. Wrap the SafeArea content in a Stack (or add to the existing one) with:

                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
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
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:

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

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
git add docs/screenshots/sim_ads_banner.png
git commit -m "docs: Phase 5 ad integration verified on simulator (test ads)"

Self-Review

Spec coverage:

  • Interstitial caps (3 stages / 90s / no-rewarded-this-round / first-5-protected) → Task 2 (policy) + Task 11 (trigger). ✓
  • Rewarded once per stage, mutually exclusive with continue → Task 10 + engine's existing rescueUsed. ✓
  • Banner home/map only, never game → Task 12 (slot only on home/map). ✓
  • Consent order UMP → ATT → init → Task 6 + Task 9 (post-first-frame). ✓
  • remove_ads non-consumable, disables interstitial+banner, keeps rewarded, Restore button → Tasks 3, 7, 12, 13. ✓
  • Test IDs via kDebugMode/dart-define → Task 4. ✓
  • Owner real IDs slot in by config → Task 4 constants + Task 1 native manifests. ✓

Known follow-ups (out of scope, note to owner): real AdMob app + 3 ad units, App Store/Play remove_ads product, app-ads.txt (Phase 7). Until then everything runs on Google test IDs.

Placeholder scan: the TODO_REAL_* constants in ad_config.dart are intentional owner-fill values, documented as such — not plan placeholders. The _BoolOnce scaffolding in Task 5 is explicitly replaced by a clean Completer<bool> in Task 5 Step 2.

Type consistency: AdService.createBanner({BannerAdListener? listener}) (Task 5 amended by Task 12 Step 2), showRewarded() → Future<bool>, AdFrequencyPolicy method names (onRoundStart/onStageCompleted/onRewardedShown/onInterstitialShown/canShowInterstitial) used identically in Tasks 5, 10, 11. adsRemovedProvider, adServiceProvider, consentServiceProvider, iapServiceProvider consistent across Tasks 713.