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>
51 KiB
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 viakDebugMode/--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_adspurchase/restore overin_app_purchase; pushes ownership into a callback.lib/state/ads_notifier.dart—AdsRemovedNotifier(Notifier) backed bySaveRepository.lib/ui/widgets/banner_ad_slot.dart— renders a banner orSizedBox.shrink()(hidden whenadsRemovedor 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—adsRemovedpersistence.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— AdMobAPPLICATION_IDmeta-data.lib/data/save_repository.dart— additiveadsRemovedflag.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"
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):
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 callonRoundStartwhen a new attempt begins. Add toGameSessionNotifier.restart()is cleaner — but to keep this task UI-only, additionally detect a fresh board: whenprev != null && next.phase == GamePhase.playing && next.score == 0 && next.movesLeft == next.moveLimittreat as a new round. Simpler and reliable: callonRoundStart()inside therestart()path in Task 11. For now, theprev == nullhook 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.endlessguard / 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 aconst BannerAdListener()and callsload(). Reassigningad.listenerhere is not supported after construction. Instead, changecreateBanner()to accept an optionalBannerAdListener:BannerAd? createBanner({BannerAdListener? listener}) { ... listener: listener ?? const BannerAdListener() ... }and pass the listener from the widget at creation time. Update Task 5's
createBanneraccordingly.
- 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 firesonEntitlementGranted→adsRemovedProvider.notifier.grant()(Task 8), which persists the flag and flips state. Because Settings usesref.watch(adsRemovedProvider), the row updates to the green check automatically, the banner disappears (it also watches the provider), andAdServicereads the new value on its next decision. The "thank you" SnackBar can be shown by adding aref.listen(adsRemovedProvider, ...)at the top ofbuild:ref.listen<bool>(adsRemovedProvider, (prev, next) { if (next && !(prev ?? false)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.adsRemovedThanks)), ); } });Add this listen call inside
buildbefore returning theScaffold.
- 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_adsnon-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.