# Phase 5 — Monetization (AdMob + IAP) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add AdMob ads (interstitial, rewarded, banner) with strict frequency caps, a compliant consent flow (UMP → ATT → SDK init), and a `remove_ads` non-consumable IAP with Restore — all runnable in dev today via Google's official test IDs, with real IDs slotted in later by config only.

**Architecture:** The frequency-cap decision is a **pure-Dart `AdFrequencyPolicy`** (no plugin imports) so the monetization rules are unit-tested headlessly, exactly like the engine and analytics layers. Thin service wrappers (`AdService`, `ConsentService`, `IapService`) isolate the plugin/platform-channel calls and **swallow every failure** — ads and purchases must never break gameplay. `remove_ads` ownership is a persisted flag (`SaveRepository.adsRemoved`) surfaced through a Riverpod `adsRemovedProvider`; it disables interstitial + banner but **keeps rewarded** (a player benefit).

**Tech Stack:** Flutter, Riverpod 3 (plain Notifiers), `google_mobile_ads` (UMP built in), `in_app_purchase`, `app_tracking_transparency`, shared_preferences.

---

## Design constants (single source of truth)

These values come from the master plan's monetization section. Define them once in `AdFrequencyPolicy` and `ad_config.dart`; never hard-code them elsewhere.

| Rule | Value |
|---|---|
| Interstitial: min stages since last interstitial | `3` |
| Interstitial: min seconds since last interstitial | `90` |
| Interstitial: first-N stages protected (no interstitial) | `5` |
| Interstitial: blocked if a rewarded was watched this round | yes |
| Rewarded: per stage | `1` (engine already enforces `rescueUsed`) |
| Banner: allowed screens | home + season map only — **never** the game screen |
| `remove_ads` effect | interstitial OFF, banner OFF, **rewarded stays ON** |

**Consent order (App Review-critical, do not reorder):** UMP consent form → iOS ATT prompt → `MobileAds.instance.initialize()`. The ATT request must run while the app is foregrounded (after the first frame), never inside `main()` pre-`runApp`.

**Ad IDs in dev:** Use Google's official sample AdMob **App ID** and **test ad-unit IDs** (listed in Task 4). These show real test ads and never risk an AdMob policy strike. The owner's real IDs replace the constants in `lib/services/ad_config.dart` and the two native manifests later — no logic changes.

---

## File Structure

**New files:**
- `lib/services/ad_frequency_policy.dart` — pure-Dart cap logic (no plugin imports). The testable core.
- `lib/services/ad_config.dart` — ad-unit + app IDs, test vs real via `kDebugMode`/`--dart-define=USE_TEST_ADS`.
- `lib/services/ad_service.dart` — loads/shows interstitial, rewarded, banner; consults policy + `adsRemoved`; swallows failures.
- `lib/services/consent_service.dart` — UMP → ATT → `MobileAds.initialize`, in order, each step guarded.
- `lib/services/iap_service.dart` — `remove_ads` purchase/restore over `in_app_purchase`; pushes ownership into a callback.
- `lib/state/ads_notifier.dart` — `AdsRemovedNotifier` (Notifier<bool>) backed by `SaveRepository`.
- `lib/ui/widgets/banner_ad_slot.dart` — renders a banner or `SizedBox.shrink()` (hidden when `adsRemoved` or unloaded).
- `lib/ui/screens/settings_screen.dart` — Remove Ads purchase + Restore Purchases.
- `test/services/ad_frequency_policy_test.dart` — cap-rule table tests.
- `test/data/save_repository_ads_test.dart` — `adsRemoved` persistence.
- `test/state/ads_notifier_test.dart` — notifier reads/writes through the repo.

**Modified files:**
- `pubspec.yaml` — add the three plugins.
- `ios/Runner/Info.plist` — `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems`.
- `android/app/src/main/AndroidManifest.xml` — AdMob `APPLICATION_ID` meta-data.
- `lib/data/save_repository.dart` — additive `adsRemoved` flag.
- `lib/state/providers.dart` — ad/consent/iap/adsRemoved providers (ref-constructed).
- `lib/app.dart` — trigger consent flow after first frame (ConsumerStatefulWidget).

(`lib/main.dart` is intentionally **unchanged** — the services self-construct from `ref`, so there is no bootstrap wiring or override to add there.)
- `lib/ui/screens/game_screen.dart` — rewarded gates rescue; stage-end interstitial.
- `lib/ui/screens/home_screen.dart` — banner slot + settings gear.
- `lib/ui/screens/season_map_screen.dart` — banner slot.
- `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` — settings/IAP copy.

---

## Task 1: Dependencies + native AdMob config

**Files:**
- Modify: `pubspec.yaml`
- Modify: `ios/Runner/Info.plist`
- Modify: `android/app/src/main/AndroidManifest.xml`

- [ ] **Step 1: Add plugins**

Run:
```bash
flutter pub add google_mobile_ads in_app_purchase app_tracking_transparency
```
Expected: three packages added under `dependencies:` in `pubspec.yaml`, `flutter pub get` succeeds.

- [ ] **Step 2: iOS Info.plist — AdMob app id, ATT text, SKAdNetwork**

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

```xml
	<key>GADApplicationIdentifier</key>
	<string>ca-app-pub-3940256099942544~1458002511</string>
	<key>NSUserTrackingUsageDescription</key>
	<string>We use this to show ads that are more relevant to you. You can play fully either way.</string>
	<key>SKAdNetworkItems</key>
	<array>
		<dict>
			<key>SKAdNetworkIdentifier</key>
			<string>cstr6suwn9.skadnetwork</string>
		</dict>
	</array>
```

- [ ] **Step 3: Android manifest — AdMob app id**

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

```xml
        <meta-data
            android:name="com.google.android.gms.ads.APPLICATION_ID"
            android:value="ca-app-pub-3940256099942544~3347511713"/>
```

- [ ] **Step 4: Verify it still builds**

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

- [ ] **Step 5: Commit**

```bash
git add pubspec.yaml pubspec.lock ios/Runner/Info.plist android/app/src/main/AndroidManifest.xml
git commit -m "build: add AdMob/IAP/ATT plugins and native ad config (test ids)"
```

---

## Task 2: AdFrequencyPolicy (pure-Dart cap logic) — TDD

**Files:**
- Create: `lib/services/ad_frequency_policy.dart`
- Test: `test/services/ad_frequency_policy_test.dart`

- [ ] **Step 1: Write the failing tests**

```dart
// test/services/ad_frequency_policy_test.dart
import 'package:block_seasons/services/ad_frequency_policy.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  final t0 = DateTime(2026, 1, 1, 12, 0, 0);

  AdFrequencyPolicy primed() {
    // Past the 5-stage protection and the 3-stage gap, last ad long ago.
    final p = AdFrequencyPolicy();
    for (var i = 0; i < 6; i++) {
      p.onRoundStart();
      p.onStageCompleted();
    }
    return p;
  }

  test('first 5 stages are protected from interstitials', () {
    final p = AdFrequencyPolicy();
    for (var i = 0; i < 5; i++) {
      p.onRoundStart();
      p.onStageCompleted();
      expect(p.canShowInterstitial(t0), isFalse, reason: 'stage ${i + 1}');
    }
    // 6th completed stage clears protection.
    p.onRoundStart();
    p.onStageCompleted();
    expect(p.canShowInterstitial(t0), isTrue);
  });

  test('needs >=3 stages since the last interstitial', () {
    final p = primed();
    expect(p.canShowInterstitial(t0), isTrue);
    p.onInterstitialShown(t0);
    final later = t0.add(const Duration(seconds: 120));
    // Only 2 stages since: still blocked even though time elapsed.
    p.onRoundStart();
    p.onStageCompleted();
    p.onRoundStart();
    p.onStageCompleted();
    expect(p.canShowInterstitial(later), isFalse);
    // 3rd stage opens the gate.
    p.onRoundStart();
    p.onStageCompleted();
    expect(p.canShowInterstitial(later), isTrue);
  });

  test('needs >=90s since the last interstitial', () {
    final p = primed();
    p.onInterstitialShown(t0);
    for (var i = 0; i < 3; i++) {
      p.onRoundStart();
      p.onStageCompleted();
    }
    expect(p.canShowInterstitial(t0.add(const Duration(seconds: 89))), isFalse);
    expect(p.canShowInterstitial(t0.add(const Duration(seconds: 90))), isTrue);
  });

  test('a rewarded watched this round blocks the interstitial', () {
    final p = primed();
    expect(p.canShowInterstitial(t0), isTrue);
    p.onRewardedShown();
    expect(p.canShowInterstitial(t0), isFalse);
    // Next round clears it.
    p.onRoundStart();
    p.onStageCompleted();
    expect(p.canShowInterstitial(t0), isTrue);
  });

  test('showing an interstitial resets the stage counter', () {
    final p = primed();
    p.onInterstitialShown(t0);
    final later = t0.add(const Duration(seconds: 200));
    expect(p.canShowInterstitial(later), isFalse); // 0 stages since
  });
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `flutter test test/services/ad_frequency_policy_test.dart`
Expected: FAIL — `ad_frequency_policy.dart` does not exist.

- [ ] **Step 3: Write the implementation**

```dart
// lib/services/ad_frequency_policy.dart

/// Pure-Dart interstitial gate. No plugin imports so the monetization rules
/// are unit-tested headlessly. The service layer feeds it lifecycle events
/// and asks [canShowInterstitial] before every stage-end interstitial.
class AdFrequencyPolicy {
  AdFrequencyPolicy({
    this.minStagesBetween = 3,
    this.minInterval = const Duration(seconds: 90),
    this.firstStagesProtected = 5,
  });

  final int minStagesBetween;
  final Duration minInterval;
  final int firstStagesProtected;

  int _totalStagesCompleted = 0;
  int _stagesSinceLastInterstitial = 0;
  bool _rewardedShownThisRound = false;
  DateTime? _lastInterstitialAt;

  /// A new stage attempt began (fresh round). Clears the per-round rewarded
  /// flag so last round's rewarded does not block this round's interstitial.
  void onRoundStart() => _rewardedShownThisRound = false;

  /// A stage finished (won or lost). Drives both counters.
  void onStageCompleted() {
    _totalStagesCompleted++;
    _stagesSinceLastInterstitial++;
  }

  /// The player watched a rewarded ad in the current round.
  void onRewardedShown() => _rewardedShownThisRound = true;

  /// An interstitial was actually shown. Resets the spacing counters.
  void onInterstitialShown(DateTime now) {
    _stagesSinceLastInterstitial = 0;
    _lastInterstitialAt = now;
  }

  bool canShowInterstitial(DateTime now) {
    if (_totalStagesCompleted <= firstStagesProtected) return false;
    if (_rewardedShownThisRound) return false;
    if (_stagesSinceLastInterstitial < minStagesBetween) return false;
    final last = _lastInterstitialAt;
    if (last != null && now.difference(last) < minInterval) return false;
    return true;
  }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `flutter test test/services/ad_frequency_policy_test.dart`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit**

```bash
git add lib/services/ad_frequency_policy.dart test/services/ad_frequency_policy_test.dart
git commit -m "feat(ads): pure-Dart interstitial frequency policy with tests"
```

---

## Task 3: SaveRepository.adsRemoved persistence — TDD

**Files:**
- Modify: `lib/data/save_repository.dart`
- Test: `test/data/save_repository_ads_test.dart`

- [ ] **Step 1: Write the failing test**

```dart
// test/data/save_repository_ads_test.dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  setUp(() => SharedPreferences.setMockInitialValues({}));

  test('adsRemoved defaults to false and persists across reopen', () async {
    final repo = await SaveRepository.open();
    expect(repo.adsRemoved, isFalse);
    await repo.setAdsRemoved(true);
    expect(repo.adsRemoved, isTrue);

    final reopened = await SaveRepository.open();
    expect(reopened.adsRemoved, isTrue);
  });

  test('legacy save without the ads flag reads as false', () async {
    SharedPreferences.setMockInitialValues({
      'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}',
    });
    final repo = await SaveRepository.open();
    expect(repo.adsRemoved, isFalse);
    expect(repo.tutorialDone, isTrue);
  });
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `flutter test test/data/save_repository_ads_test.dart`
Expected: FAIL — `adsRemoved`/`setAdsRemoved` undefined.

- [ ] **Step 3: Implement the additive flag**

In `lib/data/save_repository.dart`:

Add the field after `int _endlessBest = 0;`:
```dart
  bool _adsRemoved = false;
```

In the constructor, after the `_endlessBest = ...` line, add:
```dart
      _adsRemoved =
          (json['flags'] as Map<String, dynamic>?)?['adsRemoved'] as bool? ??
              false;
```

Add the getter near `bool get tutorialDone`:
```dart
  bool get adsRemoved => _adsRemoved;
```

Add the setter near `markTutorialDone`:
```dart
  Future<void> setAdsRemoved(bool value) {
    _adsRemoved = value;
    return _flush();
  }
```

In `_flush()`, change the `'flags'` entry to include the new key:
```dart
          'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved},
```

- [ ] **Step 4: Run test to verify it passes**

Run: `flutter test test/data/save_repository_ads_test.dart`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add lib/data/save_repository.dart test/data/save_repository_ads_test.dart
git commit -m "feat(iap): persist adsRemoved flag (additive, saveVersion 1)"
```

---

## Task 4: Ad config (test vs real IDs)

**Files:**
- Create: `lib/services/ad_config.dart`

- [ ] **Step 1: Write the config**

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

import 'package:flutter/foundation.dart';

/// Ad-unit and app IDs. Dev/test builds use Google's official sample IDs,
/// which serve real test ads and never trigger an AdMob policy strike. The
/// owner replaces the `_real*` constants (and the two native manifests'
/// APPLICATION_ID) with the AdMob console values for release.
///
/// `--dart-define=USE_TEST_ADS=true` forces test IDs even in a release build,
/// for store-review smoke tests on real devices.
class AdConfig {
  static const _forceTest =
      bool.fromEnvironment('USE_TEST_ADS', defaultValue: false);

  static bool get useTestIds => kDebugMode || _forceTest;

  // --- Google official test ad units ---
  static const _testInterstitialAndroid =
      'ca-app-pub-3940256099942544/1033173712';
  static const _testInterstitialIos =
      'ca-app-pub-3940256099942544/4411468910';
  static const _testRewardedAndroid =
      'ca-app-pub-3940256099942544/5224354917';
  static const _testRewardedIos = 'ca-app-pub-3940256099942544/1712485313';
  static const _testBannerAndroid = 'ca-app-pub-3940256099942544/6300978111';
  static const _testBannerIos = 'ca-app-pub-3940256099942544/2934735716';

  // --- Owner replaces these with real AdMob console unit IDs ---
  static const _realInterstitialAndroid = 'TODO_REAL_INTERSTITIAL_ANDROID';
  static const _realInterstitialIos = 'TODO_REAL_INTERSTITIAL_IOS';
  static const _realRewardedAndroid = 'TODO_REAL_REWARDED_ANDROID';
  static const _realRewardedIos = 'TODO_REAL_REWARDED_IOS';
  static const _realBannerAndroid = 'TODO_REAL_BANNER_ANDROID';
  static const _realBannerIos = 'TODO_REAL_BANNER_IOS';

  static String _pick(String testA, String testI, String realA, String realI) {
    final android = useTestIds ? testA : realA;
    final ios = useTestIds ? testI : realI;
    return Platform.isAndroid ? android : ios;
  }

  static String get interstitial => _pick(_testInterstitialAndroid,
      _testInterstitialIos, _realInterstitialAndroid, _realInterstitialIos);

  static String get rewarded => _pick(_testRewardedAndroid, _testRewardedIos,
      _realRewardedAndroid, _realRewardedIos);

  static String get banner => _pick(
      _testBannerAndroid, _testBannerIos, _realBannerAndroid, _realBannerIos);

  /// Non-consumable product id for the "remove ads" purchase. Must match the
  /// product created in App Store Connect and Google Play Console.
  static const removeAdsProductId = 'remove_ads';
}
```

- [ ] **Step 2: Analyze**

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

- [ ] **Step 3: Commit**

```bash
git add lib/services/ad_config.dart
git commit -m "feat(ads): ad id config with test/real switch via dart-define"
```

---

## Task 5: AdService (interstitial + rewarded + banner)

**Files:**
- Create: `lib/services/ad_service.dart`

No unit test (pure plugin glue; the testable decision lives in `AdFrequencyPolicy`). Every plugin call is wrapped so a failure is swallowed.

- [ ] **Step 1: Write the service**

```dart
// lib/services/ad_service.dart
import 'dart:async';

import 'package:google_mobile_ads/google_mobile_ads.dart';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- [ ] **Step 2: Analyze**

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

- [ ] **Step 3: Commit**

```bash
git add lib/services/ad_service.dart
git commit -m "feat(ads): AdService for interstitial/rewarded/banner with policy gating"
```

---

## Task 6: ConsentService (UMP → ATT → SDK init)

**Files:**
- Create: `lib/services/consent_service.dart`

- [ ] **Step 1: Write the service**

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

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

import 'ad_service.dart';

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

  final AdService _adService;
  bool _ran = false;

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

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

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

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

  Future<void> _initializeAds() async {
    try {
      await MobileAds.instance.initialize();
      _adService.onSdkReady();
    } catch (e) {
      debugPrint('MobileAds init failed: $e');
    }
  }
}
```

- [ ] **Step 2: Add the missing import**

The file uses `Completer`; add at the top:
```dart
import 'dart:async';
```

- [ ] **Step 3: Analyze**

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

- [ ] **Step 4: Commit**

```bash
git add lib/services/consent_service.dart
git commit -m "feat(ads): ConsentService enforcing UMP -> ATT -> SDK init order"
```

---

## Task 7: IapService + AdsRemovedNotifier

**Files:**
- Create: `lib/services/iap_service.dart`
- Create: `lib/state/ads_notifier.dart`
- Test: `test/state/ads_notifier_test.dart`

- [ ] **Step 1: Write the AdsRemovedNotifier test**

```dart
// test/state/ads_notifier_test.dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/state/ads_notifier.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  test('reads persisted ownership and updates on grant', () async {
    SharedPreferences.setMockInitialValues({});
    final repo = await SaveRepository.open();
    final container = ProviderContainer(
      overrides: [saveRepositoryProvider.overrideWithValue(repo)],
    );
    addTearDown(container.dispose);

    expect(container.read(adsRemovedProvider), isFalse);
    await container.read(adsRemovedProvider.notifier).grant();
    expect(container.read(adsRemovedProvider), isTrue);
    expect(repo.adsRemoved, isTrue);
  });
}
```

- [ ] **Step 2: Run it to verify it fails**

Run: `flutter test test/state/ads_notifier_test.dart`
Expected: FAIL — `ads_notifier.dart` / `adsRemovedProvider` undefined.

- [ ] **Step 3: Write AdsRemovedNotifier**

```dart
// lib/state/ads_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers.dart';

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

  Future<void> grant() async {
    if (state) return;
    await ref.read(saveRepositoryProvider).setAdsRemoved(true);
    state = true;
  }
}
```

- [ ] **Step 4: Register the provider**

In `lib/state/providers.dart`, add the import and provider (full wiring lands in Task 8, but add this one now so the test passes):
```dart
import 'ads_notifier.dart';
// ...
final adsRemovedProvider =
    NotifierProvider<AdsRemovedNotifier, bool>(AdsRemovedNotifier.new);
```

- [ ] **Step 5: Run the test to verify it passes**

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

- [ ] **Step 6: Write IapService**

```dart
// lib/services/iap_service.dart
import 'dart:async';

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

import 'ad_config.dart';

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

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

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

  ProductDetails? get product => _product;

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

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

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

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

  void dispose() => _sub?.cancel();
}
```

- [ ] **Step 7: Analyze**

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

- [ ] **Step 8: Commit**

```bash
git add lib/services/iap_service.dart lib/state/ads_notifier.dart lib/state/providers.dart test/state/ads_notifier_test.dart
git commit -m "feat(iap): remove_ads purchase/restore service + adsRemoved notifier"
```

---

## Task 8: Provider wiring (single source of truth)

**Files:**
- Modify: `lib/state/providers.dart`

The three services self-construct from `ref` so there is ONE source of truth
for ownership: `adsRemovedProvider` (repo-backed). `AdService` reads it live;
`IapService` flips it via the notifier on a verified purchase/restore. No
mutable holder, no `main.dart` override for these three.

- [ ] **Step 1: Add the service providers**

Add imports:
```dart
import '../services/ad_service.dart';
import '../services/consent_service.dart';
import '../services/iap_service.dart';
```

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

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

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

- [ ] **Step 2: Analyze**

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

- [ ] **Step 3: Commit**

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

---

## Task 9: app.dart bootstrap (consent after first frame)

**Files:**
- Modify: `lib/app.dart`

`main.dart` needs no changes for the ad/consent/iap services — they
self-construct from `ref` (Task 8). The only bootstrap step is triggering the
consent flow once after the first frame (ATT requires the foreground).

- [ ] **Step 1: Trigger consent after first frame in app.dart**

Convert `BlockSeasonsApp` to a `ConsumerStatefulWidget` and run the consent flow once after the first frame (ATT requires foreground):

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

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

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

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

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

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

- [ ] **Step 2: Verify the suite still builds and passes**

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

- [ ] **Step 3: Commit**

```bash
git add lib/app.dart
git commit -m "feat(ads): run consent flow (UMP->ATT->init) after the first frame"
```

---

## Task 10: Rewarded ad gates the rescue

**Files:**
- Modify: `lib/ui/screens/game_screen.dart`

The rescue buttons (`watchAdContinue`, `plusFiveMoves`) currently call the engine directly. Route them through a rewarded ad first; the engine action runs when `showRewarded()` resolves true. Round lifecycle (`onRoundStart`/`onStageCompleted`) is also driven here.

- [ ] **Step 1: Drive round lifecycle on stage start and completion**

In `_GameScreenState`, the session starts elsewhere; hook the policy via `onSessionChange`. In `_onSessionChange`, when `prev == null && next != null` (first publish of a stage) call `onRoundStart`; when a stage finishes (`next.phase == won || lost`) call `onStageCompleted`. Add at the top of the `if (prev?.phase != next.phase)` block, after the existing tutorial skip:

```dart
      if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
        ref.read(adServiceProvider).onStageCompleted();
      }
```

And at the very start of `_onSessionChange`, after the `if (next == null) return;` guard:
```dart
    if (prev == null) ref.read(adServiceProvider).onRoundStart();
```

> Note: `restart()` re-publishes from a null-less state, so also call `onRoundStart` when a new attempt begins. Add to `GameSessionNotifier.restart()` is cleaner — but to keep this task UI-only, additionally detect a fresh board: when `prev != null && next.phase == GamePhase.playing && next.score == 0 && next.movesLeft == next.moveLimit` treat as a new round. Simpler and reliable: call `onRoundStart()` inside the `restart()` path in Task 11. For now, the `prev == null` hook covers the first entry.

- [ ] **Step 2: Replace the continue rescue action**

In `_resultOverlay`, the `(GamePhase.stuck, _)` branch `watchAdContinue` button: change `onPressed` to show a rewarded ad first.

```dart
            if (!view.rescueUsed)
              FilledButton(
                onPressed: () async {
                  final earned =
                      await ref.read(adServiceProvider).showRewarded();
                  if (!earned) return;
                  ref.read(analyticsProvider).rescueUsed(type: 'continue');
                  notifier.useContinue();
                },
                child: Text(l10n.watchAdContinue),
              ),
```

- [ ] **Step 3: Replace the extra-moves rescue action**

In the `(GamePhase.stuck, StuckReason.outOfMoves)` branch `plusFiveMoves` button:

```dart
            if (!view.rescueUsed)
              FilledButton(
                onPressed: () async {
                  final earned =
                      await ref.read(adServiceProvider).showRewarded();
                  if (!earned) return;
                  ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
                  notifier.addExtraMoves();
                },
                child: Text(l10n.plusFiveMoves),
              ),
```

- [ ] **Step 4: Analyze**

Run: `flutter analyze lib/ui/screens/game_screen.dart`
Expected: No issues.

- [ ] **Step 5: Commit**

```bash
git add lib/ui/screens/game_screen.dart
git commit -m "feat(ads): rewarded ad gates the continue/extra-moves rescue"
```

---

## Task 11: Stage-end interstitial + restart round hook

**Files:**
- Modify: `lib/ui/screens/game_screen.dart`
- Modify: `lib/state/game_session_notifier.dart`

- [ ] **Step 1: Fire round-start from restart()**

In `lib/state/game_session_notifier.dart` `restart()`, after `startStage(...)`, the policy must reset the round. Do it through the analytics path already there — add an ad hook. Add to `restart()` after the `startStage` call:
```dart
    ref.read(adServiceProvider).onRoundStart();
```

(Confirm `adServiceProvider` is imported via `providers.dart`, already imported here.)

- [ ] **Step 2: Show the interstitial after a non-endless stage ends**

In `game_screen.dart` `_onSessionChange`, the interstitial should fire when the player leaves the result card — but the simplest review-safe trigger is when a non-endless stage reaches `won`/`lost` and the player taps Next/Retry. Show it on result dismissal. Concretely, wrap the "next stage" and "retry" actions: after they run, call `maybeShowInterstitial()`.

For the won branch `nextStage` action in `_resultOverlay`:
```dart
              FilledButton(
                onPressed: () {
                  ref.read(seasonFlowProvider.notifier).nextStage();
                  if (!view.endless) {
                    ref.read(adServiceProvider).maybeShowInterstitial();
                  }
                },
                child: Text(l10n.nextStage),
              ),
```

For the lost (non-endless) retry `playAgain` (`(_, _)` default branch):
```dart
            FilledButton(
              onPressed: () {
                ref.read(adServiceProvider).maybeShowInterstitial();
                notifier.restart();
              },
              child: Text(l10n.playAgain),
            ),
```

> Endless mode never shows an interstitial here (`view.endless` guard / endless branch left unchanged). Banner is never on this screen.

- [ ] **Step 3: Analyze**

Run: `flutter analyze lib/ui/screens/game_screen.dart lib/state/game_session_notifier.dart`
Expected: No issues.

- [ ] **Step 4: Run the full suite**

Run: `flutter test`
Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
git add lib/ui/screens/game_screen.dart lib/state/game_session_notifier.dart
git commit -m "feat(ads): stage-end interstitial gated by policy; restart resets round"
```

---

## Task 12: Banner slot on home + map

**Files:**
- Create: `lib/ui/widgets/banner_ad_slot.dart`
- Modify: `lib/ui/screens/home_screen.dart`
- Modify: `lib/ui/screens/season_map_screen.dart`

- [ ] **Step 1: Write the banner slot widget**

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

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

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

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

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

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

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

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

> **Implementer note:** `AdService.createBanner()` builds the banner with a `const BannerAdListener()` and calls `load()`. Reassigning `ad.listener` here is not supported after construction. Instead, change `createBanner()` to accept an optional `BannerAdListener`:
> ```dart
> BannerAd? createBanner({BannerAdListener? listener}) { ... listener: listener ?? const BannerAdListener() ... }
> ```
> and pass the listener from the widget at creation time. Update Task 5's `createBanner` accordingly.

- [ ] **Step 2: Fix createBanner to accept a listener**

In `lib/services/ad_service.dart`, change `createBanner` signature to `BannerAd? createBanner({BannerAdListener? listener})` and use `listener: listener ?? const BannerAdListener()`. In the widget, build via:
```dart
    final ad = ref.read(adServiceProvider).createBanner(
      listener: BannerAdListener(
        onAdLoaded: (_) { if (mounted) setState(() => _loaded = true); },
        onAdFailedToLoad: (ad, _) => ad.dispose(),
      ),
    );
    if (ad == null) return;
    _ad = ad;
```
Remove the post-construction `ad.listener = ...` block.

- [ ] **Step 3: Place the banner on the home screen**

In `home_screen.dart`, wrap the body so the banner pins to the bottom. Change the outer `Stack`'s `SafeArea` child from `Center(child: Column(...))` to a `Column` with the existing centered content in an `Expanded` and the banner below:
```dart
          SafeArea(
            child: Column(
              children: [
                Expanded(child: Center(child: /* existing Column(...) */ )),
                const BannerAdSlot(),
              ],
            ),
          ),
```
Add `import '../widgets/banner_ad_slot.dart';`.

- [ ] **Step 4: Place the banner on the season map**

In `season_map_screen.dart`, add `const BannerAdSlot()` at the bottom of the screen's main `Column` (the one around line 197) or as the last child below the map `Expanded`. Add the import. Ensure it sits inside `SafeArea` and below the scrollable map.

- [ ] **Step 5: Analyze**

Run: `flutter analyze lib/ui/widgets/banner_ad_slot.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart lib/services/ad_service.dart`
Expected: No issues.

- [ ] **Step 6: Commit**

```bash
git add lib/ui/widgets/banner_ad_slot.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart lib/services/ad_service.dart
git commit -m "feat(ads): home/map banner slot (hidden when removed or unloaded)"
```

---

## Task 13: Settings screen (Remove Ads + Restore) + l10n

**Files:**
- Create: `lib/ui/screens/settings_screen.dart`
- Modify: `lib/ui/screens/home_screen.dart`
- Modify: `lib/l10n/app_en.arb`
- Modify: `lib/l10n/app_ko.arb`

- [ ] **Step 1: Add l10n keys (en)**

In `lib/l10n/app_en.arb`, add:
```json
  "settings": "Settings",
  "removeAds": "Remove ads",
  "removeAdsDescription": "Removes banners and full-screen ads. Reward ads stay available.",
  "restorePurchases": "Restore purchases",
  "adsRemovedThanks": "Ads removed — thank you!",
  "purchaseUnavailable": "Purchases are unavailable right now."
```

- [ ] **Step 2: Add l10n keys (ko)**

In `lib/l10n/app_ko.arb`, add:
```json
  "settings": "설정",
  "removeAds": "광고 제거",
  "removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
  "restorePurchases": "구매 복원",
  "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
  "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
```

- [ ] **Step 3: Regenerate localizations**

Run: `flutter gen-l10n`
Expected: `lib/l10n/gen/app_localizations.dart` updated with the new getters.

- [ ] **Step 4: Write the settings screen**

```dart
// lib/ui/screens/settings_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';

class SettingsScreen extends ConsumerWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = AppLocalizations.of(context)!;
    final adsRemoved = ref.watch(adsRemovedProvider);
    final iap = ref.read(iapServiceProvider);

    return Scaffold(
      appBar: AppBar(title: Text(l10n.settings)),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          ListTile(
            title: Text(l10n.removeAds),
            subtitle: Text(l10n.removeAdsDescription),
            trailing: adsRemoved
                ? const Icon(Icons.check_circle, color: Colors.green)
                : Text(iap.product?.price ?? ''),
            onTap: adsRemoved
                ? null
                : () async {
                    if (!iap.available || iap.product == null) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text(l10n.purchaseUnavailable)),
                      );
                      return;
                    }
                    await iap.buyRemoveAds();
                  },
          ),
          const Divider(),
          ListTile(
            leading: const Icon(Icons.restore),
            title: Text(l10n.restorePurchases),
            onTap: () => iap.restorePurchases(),
          ),
        ],
      ),
    );
  }
}
```

> **Entitlement sync (already wired):** No polling needed. `buyRemoveAds()`/`restorePurchases()` start the platform flow; when it resolves, `IapService`'s purchase stream fires `onEntitlementGranted` → `adsRemovedProvider.notifier.grant()` (Task 8), which persists the flag and flips state. Because Settings uses `ref.watch(adsRemovedProvider)`, the row updates to the green check automatically, the banner disappears (it also watches the provider), and `AdService` reads the new value on its next decision. The "thank you" SnackBar can be shown by adding a `ref.listen(adsRemovedProvider, ...)` at the top of `build`:
> ```dart
>     ref.listen<bool>(adsRemovedProvider, (prev, next) {
>       if (next && !(prev ?? false)) {
>         ScaffoldMessenger.of(context).showSnackBar(
>           SnackBar(content: Text(l10n.adsRemovedThanks)),
>         );
>       }
>     });
> ```
> Add this listen call inside `build` before returning the `Scaffold`.

- [ ] **Step 5: Add a settings gear to the home screen**

In `home_screen.dart`, add a top-right settings button. Wrap the `SafeArea` content in a `Stack` (or add to the existing one) with:
```dart
                Positioned(
                  top: 8,
                  right: 8,
                  child: IconButton(
                    icon: const Icon(Icons.settings, color: Colors.white70),
                    onPressed: () => Navigator.of(context).push(
                      MaterialPageRoute(builder: (_) => const SettingsScreen()),
                    ),
                  ),
                ),
```
Add `import 'settings_screen.dart';`.

- [ ] **Step 6: Analyze + full test**

Run: `flutter analyze && flutter test`
Expected: No analyzer issues; all tests pass.

- [ ] **Step 7: Commit**

```bash
git add lib/ui/screens/settings_screen.dart lib/ui/screens/home_screen.dart lib/l10n/app_en.arb lib/l10n/app_ko.arb lib/l10n/gen/app_localizations.dart
git commit -m "feat(iap): settings screen with remove-ads purchase and restore"
```

---

## Task 14: Integration verification on simulator

**Files:** none (verification only)

- [ ] **Step 1: Build for the simulator**

Run: `flutter build ios --debug --simulator`
Expected: `✓ Built build/ios/iphonesimulator/Runner.app`.

- [ ] **Step 2: Install, launch, and capture the console**

```bash
xcrun simctl install booted build/ios/iphonesimulator/Runner.app
xcrun simctl launch --console-pty booted com.airkjw.blockseasons > /tmp/bs_ads_run.log 2>&1 &
```
Wait ~12s, then inspect the system log:
```bash
xcrun simctl spawn booted log show --last 30s --predicate 'process == "Runner"' | grep -iE "consent|ATT|MobileAds|interstitial|rewarded|banner|GADApplication"
```
Expected: evidence that `MobileAds` initialized after consent; no crash referencing a missing `GADApplicationIdentifier`.

- [ ] **Step 3: Manual smoke checklist (document results in the commit message)**

- Cold start: consent/ATT prompt appears once (or is skipped on simulator), app reaches home without crashing.
- Home/map: a test banner ("Test Ad") shows at the bottom.
- Play 6+ stages, fail one, tap Retry: a test interstitial appears (respects the 5-stage protection + 3-stage gap).
- Get stuck, tap Continue: a test rewarded ad shows; on completion the board continues; that round's stage-end interstitial is suppressed.
- Game screen: never shows a banner.
- Settings → Remove ads (sandbox): banner + interstitial disappear; rewarded still works. Relaunch: ownership persists.

- [ ] **Step 4: Capture a screenshot of the banner on home**

```bash
xcrun simctl io booted screenshot /tmp/bs_ads.png
cp /tmp/bs_ads.png docs/screenshots/sim_ads_banner.png
```

- [ ] **Step 5: Commit the evidence**

```bash
git add docs/screenshots/sim_ads_banner.png
git commit -m "docs: Phase 5 ad integration verified on simulator (test ads)"
```

---

## Self-Review

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

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

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

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