diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b1a3831..f329b63 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -25,6 +25,9 @@
+
12.14.0)
+ - firebase_analytics (12.4.2):
+ - firebase_core
+ - FirebaseAnalytics (= 12.14.0)
+ - Flutter
+ - firebase_core (4.10.0):
+ - Firebase/CoreOnly (= 12.14.0)
+ - Flutter
+ - FirebaseAnalytics (12.14.0):
+ - FirebaseAnalytics/Default (= 12.14.0)
+ - FirebaseCore (~> 12.14.0)
+ - FirebaseInstallations (~> 12.14.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - FirebaseAnalytics/Default (12.14.0):
+ - FirebaseCore (~> 12.14.0)
+ - FirebaseInstallations (~> 12.14.0)
+ - GoogleAppMeasurement/Default (= 12.14.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - FirebaseCore (12.14.0):
+ - FirebaseCoreInternal (~> 12.14.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/Logger (~> 8.1)
+ - FirebaseCoreInternal (12.14.0):
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - FirebaseInstallations (12.14.0):
+ - FirebaseCore (~> 12.14.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
+ - PromisesObjC (~> 2.4)
- Flutter (1.0.0)
+ - Google-Mobile-Ads-SDK (12.14.0):
+ - GoogleUserMessagingPlatform (>= 1.1)
+ - google_mobile_ads (7.0.0):
+ - Flutter
+ - Google-Mobile-Ads-SDK (~> 12.14.0)
+ - webview_flutter_wkwebview
+ - GoogleAdsOnDeviceConversion (3.6.0):
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/Logger (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - nanopb (~> 3.30910.0)
+ - GoogleAppMeasurement/Core (12.14.0):
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - GoogleAppMeasurement/Default (12.14.0):
+ - GoogleAdsOnDeviceConversion (~> 3.6.0)
+ - GoogleAppMeasurement/Core (= 12.14.0)
+ - GoogleAppMeasurement/IdentitySupport (= 12.14.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - GoogleAppMeasurement/IdentitySupport (12.14.0):
+ - GoogleAppMeasurement/Core (= 12.14.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - GoogleUserMessagingPlatform (3.1.0)
+ - GoogleUtilities/AppDelegateSwizzler (8.1.0):
+ - GoogleUtilities/Environment
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Network
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Environment (8.1.0):
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Logger (8.1.0):
+ - GoogleUtilities/Environment
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/MethodSwizzler (8.1.0):
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Network (8.1.0):
+ - GoogleUtilities/Logger
+ - "GoogleUtilities/NSData+zlib"
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Reachability
+ - "GoogleUtilities/NSData+zlib (8.1.0)":
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Privacy (8.1.0)
+ - GoogleUtilities/Reachability (8.1.0):
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/UserDefaults (8.1.0):
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Privacy
+ - in_app_purchase_storekit (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - nanopb (3.30910.0):
+ - nanopb/decode (= 3.30910.0)
+ - nanopb/encode (= 3.30910.0)
+ - nanopb/decode (3.30910.0)
+ - nanopb/encode (3.30910.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
+ - PromisesObjC (2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
+ - webview_flutter_wkwebview (0.0.1):
+ - Flutter
+ - FlutterMacOS
DEPENDENCIES:
+ - app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
+ - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
+ - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- Flutter (from `Flutter`)
+ - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
+ - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
+
+SPEC REPOS:
+ trunk:
+ - Firebase
+ - FirebaseAnalytics
+ - FirebaseCore
+ - FirebaseCoreInternal
+ - FirebaseInstallations
+ - Google-Mobile-Ads-SDK
+ - GoogleAdsOnDeviceConversion
+ - GoogleAppMeasurement
+ - GoogleUserMessagingPlatform
+ - GoogleUtilities
+ - nanopb
+ - PromisesObjC
EXTERNAL SOURCES:
+ app_tracking_transparency:
+ :path: ".symlinks/plugins/app_tracking_transparency/ios"
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
+ firebase_analytics:
+ :path: ".symlinks/plugins/firebase_analytics/ios"
+ firebase_core:
+ :path: ".symlinks/plugins/firebase_core/ios"
Flutter:
:path: Flutter
+ google_mobile_ads:
+ :path: ".symlinks/plugins/google_mobile_ads/ios"
+ in_app_purchase_storekit:
+ :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ webview_flutter_wkwebview:
+ :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
+ app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
+ Firebase: 7cc10425300768ec86292688af5cb228f0604bde
+ firebase_analytics: 9ea6c22e60e1d37ee76772de57b4addddb03e276
+ firebase_core: 383e19b49a08df5d7a6cf5017616de6a357ed7af
+ FirebaseAnalytics: 99364329a3ea4d1ab4a3744b5464371b7351c481
+ FirebaseCore: 4939b340b9c598dc1f965d68f8fe57e630b65407
+ FirebaseCoreInternal: 090369a5fffd7423cf88006ab4d2ccc2173a8db9
+ FirebaseInstallations: 7cdc919e29dc54306edeffdbdc1eed1a40d7d1e7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ Google-Mobile-Ads-SDK: 4534fd2dfcd3f705c5485a6633c5188d03d4eed2
+ google_mobile_ads: df3008bafbe1f2ad6862f87334e560d2f047f902
+ GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744
+ GoogleAppMeasurement: 1987ffa55055dfb22c52e363c31aa50c1e11d349
+ GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
+ GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
+ in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
+ nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+ PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
-PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1
COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index a49888a..6207be1 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -202,6 +202,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */,
+ 2838ED76467446CF49AD274C /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -274,6 +275,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
+ 2838ED76467446CF49AD274C /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 3bc345e..7192d9f 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -45,5 +45,16 @@
UIApplicationSupportsIndirectInputEvents
+ GADApplicationIdentifier
+ ca-app-pub-3940256099942544~1458002511
+ NSUserTrackingUsageDescription
+ We use this to show ads that are more relevant to you. You can play fully either way.
+ SKAdNetworkItems
+
+
+ SKAdNetworkIdentifier
+ cstr6suwn9.skadnetwork
+
+
diff --git a/lib/app.dart b/lib/app.dart
index cfd596a..c7dbcf7 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -1,12 +1,31 @@
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 StatelessWidget {
+class BlockSeasonsApp extends ConsumerStatefulWidget {
const BlockSeasonsApp({super.key});
+ @override
+ ConsumerState createState() => _BlockSeasonsAppState();
+}
+
+class _BlockSeasonsAppState extends ConsumerState {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(consentServiceProvider).ensureConsentAndInitialize();
+ // Eagerly start the IAP service so its purchase stream is live for the
+ // whole session — restores and interrupted/deferred transactions are
+ // delivered (and completed) even if the player never opens Settings.
+ ref.read(iapServiceProvider);
+ });
+ }
+
@override
Widget build(BuildContext context) {
return MaterialApp(
diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart
index d9ab677..0629725 100644
--- a/lib/data/save_repository.dart
+++ b/lib/data/save_repository.dart
@@ -39,6 +39,9 @@ class SaveRepository {
false;
_endlessBest =
(json['endless'] as Map?)?['best'] as int? ?? 0;
+ _adsRemoved =
+ (json['flags'] as Map?)?['adsRemoved'] as bool? ??
+ false;
}
}
@@ -52,16 +55,23 @@ class SaveRepository {
StreakState _streak = StreakState.initial;
bool _tutorialDone = false;
int _endlessBest = 0;
+ bool _adsRemoved = false;
StreakState get streak => _streak;
bool get tutorialDone => _tutorialDone;
int get endlessBest => _endlessBest;
+ bool get adsRemoved => _adsRemoved;
Future markTutorialDone() {
_tutorialDone = true;
return _flush();
}
+ Future setAdsRemoved(bool value) {
+ _adsRemoved = value;
+ return _flush();
+ }
+
Future recordEndlessScore(int score) {
if (score > _endlessBest) _endlessBest = score;
return _flush();
@@ -130,7 +140,7 @@ class SaveRepository {
'best': _streak.best,
'lastYmd': _streak.lastYmd,
},
- 'flags': {'tutorialDone': _tutorialDone},
+ 'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved},
'endless': {'best': _endlessBest},
}),
);
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index d6efbb6..8c2659d 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -54,5 +54,10 @@
},
"newBest": "NEW BEST!",
"adventure": "Adventure",
- "classic": "Classic"
+ "classic": "Classic",
+ "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."
}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 0afe322..a4f2705 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -26,5 +26,10 @@
"bestScore": "최고 {score}",
"newBest": "신기록!",
"adventure": "어드벤처",
- "classic": "클래식"
+ "classic": "클래식",
+ "removeAds": "광고 제거",
+ "removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
+ "restorePurchases": "구매 복원",
+ "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
+ "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
}
diff --git a/lib/services/ad_config.dart b/lib/services/ad_config.dart
new file mode 100644
index 0000000..681ba10
--- /dev/null
+++ b/lib/services/ad_config.dart
@@ -0,0 +1,56 @@
+// 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';
+}
diff --git a/lib/services/ad_frequency_policy.dart b/lib/services/ad_frequency_policy.dart
new file mode 100644
index 0000000..2bbcdb6
--- /dev/null
+++ b/lib/services/ad_frequency_policy.dart
@@ -0,0 +1,47 @@
+/// 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;
+ }
+}
diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart
new file mode 100644
index 0000000..392b3aa
--- /dev/null
+++ b/lib/services/ad_service.dart
@@ -0,0 +1,150 @@
+// lib/services/ad_service.dart
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+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;
+
+ /// Flips true once the SDK is initialized. Banner slots that were built
+ /// before consent finished listen to this and retry their load — otherwise
+ /// the banner would stay empty until the screen is rebuilt.
+ final ValueNotifier isReady = ValueNotifier(false);
+
+ /// Called by ConsentService once the SDK is initialized. Preloads ads and
+ /// notifies any waiting banner slots.
+ void onSdkReady() {
+ _initialized = true;
+ _loadInterstitial();
+ _loadRewarded();
+ isReady.value = true;
+ }
+
+ // ---- lifecycle hooks the game calls ----
+ void onRoundStart() => policy.onRoundStart();
+ void onStageCompleted() => policy.onStageCompleted();
+
+ void _loadInterstitial() {
+ if (!_initialized || _adsRemoved()) return;
+ InterstitialAd.load(
+ adUnitId: AdConfig.interstitial,
+ request: const AdRequest(),
+ adLoadCallback: InterstitialAdLoadCallback(
+ onAdLoaded: (ad) => _interstitial = ad,
+ onAdFailedToLoad: (_) => _interstitial = null,
+ ),
+ );
+ }
+
+ void _loadRewarded() {
+ if (!_initialized) return; // rewarded stays available even with adsRemoved
+ RewardedAd.load(
+ adUnitId: AdConfig.rewarded,
+ request: const AdRequest(),
+ rewardedAdLoadCallback: RewardedAdLoadCallback(
+ onAdLoaded: (ad) => _rewarded = ad,
+ onAdFailedToLoad: (_) => _rewarded = null,
+ ),
+ );
+ }
+
+ /// Shows a stage-end interstitial when the policy and adsRemoved allow it.
+ /// No-op (and reloads) otherwise. Safe to call after every finished stage.
+ void maybeShowInterstitial() {
+ if (_adsRemoved()) return;
+ final ad = _interstitial;
+ if (ad == null || !policy.canShowInterstitial(_now())) {
+ if (ad == null) _loadInterstitial();
+ return;
+ }
+ _interstitial = null;
+ ad.fullScreenContentCallback = FullScreenContentCallback(
+ onAdDismissedFullScreenContent: (ad) {
+ ad.dispose();
+ _loadInterstitial();
+ },
+ onAdFailedToShowFullScreenContent: (ad, _) {
+ ad.dispose();
+ _loadInterstitial();
+ },
+ );
+ policy.onInterstitialShown(_now());
+ ad.show();
+ }
+
+ /// Shows a rewarded ad and resolves `true` when the reward is earned. If no
+ /// ad is loaded, resolves `true` anyway — the rescue is a player benefit and
+ /// must not be blocked by ad availability. A genuine dismissal-without-earn
+ /// resolves `false`. Records the watch in the policy so it suppresses this
+ /// round's interstitial.
+ Future showRewarded() async {
+ policy.onRewardedShown();
+ final ad = _rewarded;
+ if (ad == null) {
+ _loadRewarded();
+ return true; // no ad available -> grant the rescue
+ }
+ _rewarded = null;
+ final completer = Completer();
+ void finish(bool v) {
+ if (!completer.isCompleted) completer.complete(v);
+ }
+
+ ad.fullScreenContentCallback = FullScreenContentCallback(
+ onAdDismissedFullScreenContent: (ad) {
+ ad.dispose();
+ _loadRewarded();
+ finish(false); // dismissed; earn already resolved true if it happened
+ },
+ onAdFailedToShowFullScreenContent: (ad, _) {
+ ad.dispose();
+ _loadRewarded();
+ finish(true); // failed to present -> grant
+ },
+ );
+ ad.show(onUserEarnedReward: (_, reward) => 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();
+ isReady.dispose();
+ }
+}
diff --git a/lib/services/consent_service.dart b/lib/services/consent_service.dart
new file mode 100644
index 0000000..e3e3e24
--- /dev/null
+++ b/lib/services/consent_service.dart
@@ -0,0 +1,86 @@
+// lib/services/consent_service.dart
+import 'dart:async';
+import 'dart:io' show Platform;
+
+import 'package:app_tracking_transparency/app_tracking_transparency.dart';
+import 'package:flutter/foundation.dart';
+import 'package:google_mobile_ads/google_mobile_ads.dart';
+
+import 'ad_service.dart';
+
+/// Runs the App-Review-mandated consent sequence exactly once per launch and
+/// in the required order: UMP consent form -> iOS ATT prompt -> MobileAds
+/// initialize. Each step is guarded; a failure in one never skips SDK init,
+/// because un-initialized ads would silently disable the whole monetization
+/// layer. Must be invoked AFTER the first frame (ATT needs the foreground).
+class ConsentService {
+ ConsentService(this._adService);
+
+ final AdService _adService;
+ bool _ran = false;
+
+ Future ensureConsentAndInitialize() async {
+ if (_ran) return;
+ _ran = true;
+ await _requestUmp();
+ await _requestAtt();
+ await _initializeAds();
+ }
+
+ Future _requestUmp() async {
+ try {
+ final params = ConsentRequestParameters();
+ final completer = Completer();
+ ConsentInformation.instance.requestConsentInfoUpdate(
+ params,
+ () async {
+ try {
+ if (await ConsentInformation.instance.isConsentFormAvailable()) {
+ await _loadAndShowFormIfRequired();
+ }
+ } finally {
+ completer.complete();
+ }
+ },
+ (_) => completer.complete(),
+ );
+ await completer.future;
+ } catch (_) {/* proceed without UMP */}
+ }
+
+ Future _loadAndShowFormIfRequired() async {
+ final completer = Completer();
+ ConsentForm.loadConsentForm(
+ (form) async {
+ final status = await ConsentInformation.instance.getConsentStatus();
+ if (status == ConsentStatus.required) {
+ form.show((_) => completer.complete());
+ } else {
+ completer.complete();
+ }
+ },
+ (_) => completer.complete(),
+ );
+ await completer.future;
+ }
+
+ Future _requestAtt() async {
+ if (!Platform.isIOS) return;
+ try {
+ final status =
+ await AppTrackingTransparency.trackingAuthorizationStatus;
+ if (status == TrackingStatus.notDetermined) {
+ await AppTrackingTransparency.requestTrackingAuthorization();
+ }
+ } catch (_) {/* ATT optional */}
+ }
+
+ Future _initializeAds() async {
+ try {
+ await MobileAds.instance.initialize();
+ _adService.onSdkReady();
+ } catch (e) {
+ debugPrint('MobileAds init failed: $e');
+ }
+ }
+}
diff --git a/lib/services/iap_service.dart b/lib/services/iap_service.dart
new file mode 100644
index 0000000..6b6bac2
--- /dev/null
+++ b/lib/services/iap_service.dart
@@ -0,0 +1,73 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:in_app_purchase/in_app_purchase.dart';
+
+import 'ad_config.dart';
+
+/// Wraps the non-consumable `remove_ads` purchase. On any verified purchase
+/// or restore of the product it invokes [onEntitlementGranted]; the caller
+/// flips the AdsRemovedNotifier. All failures are swallowed.
+class IapService {
+ IapService({required this.onEntitlementGranted});
+
+ final Future Function() onEntitlementGranted;
+ final InAppPurchase _iap = InAppPurchase.instance;
+ StreamSubscription>? _sub;
+ ProductDetails? _product;
+
+ bool get available => _available;
+ bool _available = false;
+
+ ProductDetails? get product => _product;
+
+ Future initialize() async {
+ try {
+ _available = await _iap.isAvailable();
+ if (!_available) return;
+ _sub = _iap.purchaseStream.listen(_onPurchases,
+ onError: (_) {}, cancelOnError: false);
+ final response =
+ await _iap.queryProductDetails({AdConfig.removeAdsProductId});
+ if (response.productDetails.isNotEmpty) {
+ _product = response.productDetails.first;
+ }
+ } catch (e) {
+ debugPrint('IAP init failed: $e');
+ }
+ }
+
+ Future buyRemoveAds() async {
+ final product = _product;
+ if (product == null) return;
+ try {
+ await _iap.buyNonConsumable(
+ purchaseParam: PurchaseParam(productDetails: product));
+ } catch (e) {
+ debugPrint('IAP buy failed: $e');
+ }
+ }
+
+ Future restorePurchases() async {
+ try {
+ await _iap.restorePurchases();
+ } catch (e) {
+ debugPrint('IAP restore failed: $e');
+ }
+ }
+
+ Future _onPurchases(List purchases) async {
+ for (final p in purchases) {
+ if (p.productID != AdConfig.removeAdsProductId) continue;
+ if (p.status == PurchaseStatus.purchased ||
+ p.status == PurchaseStatus.restored) {
+ await onEntitlementGranted();
+ }
+ if (p.pendingCompletePurchase) {
+ await _iap.completePurchase(p);
+ }
+ }
+ }
+
+ void dispose() => _sub?.cancel();
+}
diff --git a/lib/state/ads_notifier.dart b/lib/state/ads_notifier.dart
new file mode 100644
index 0000000..b6912be
--- /dev/null
+++ b/lib/state/ads_notifier.dart
@@ -0,0 +1,17 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'providers.dart';
+
+/// Whether the player owns the remove-ads entitlement. Seeded from the save
+/// repository; [grant] flips it on (after a successful purchase/restore) and
+/// persists.
+class AdsRemovedNotifier extends Notifier {
+ @override
+ bool build() => ref.read(saveRepositoryProvider).adsRemoved;
+
+ Future grant() async {
+ if (state) return;
+ await ref.read(saveRepositoryProvider).setAdsRemoved(true);
+ state = true;
+ }
+}
diff --git a/lib/state/game_session_notifier.dart b/lib/state/game_session_notifier.dart
index 1823578..6634dd0 100644
--- a/lib/state/game_session_notifier.dart
+++ b/lib/state/game_session_notifier.dart
@@ -68,6 +68,7 @@ class GameSessionNotifier extends Notifier {
_engine = GameEngine(stage, attempt: attempt, generator: generator);
_fxTick = 0;
_publish(lastPlacement: null);
+ ref.read(adServiceProvider).onRoundStart();
}
/// Restarts the current stage as a new attempt (fresh piece sequence).
diff --git a/lib/state/providers.dart b/lib/state/providers.dart
index 34d1f52..02e35d2 100644
--- a/lib/state/providers.dart
+++ b/lib/state/providers.dart
@@ -4,8 +4,12 @@ import '../data/content_repository.dart';
import '../data/save_repository.dart';
import '../data/streak.dart';
import '../game/models/season.dart';
+import '../services/ad_service.dart';
import '../services/analytics_service.dart';
import '../services/audio_service.dart';
+import '../services/consent_service.dart';
+import '../services/iap_service.dart';
+import 'ads_notifier.dart';
import 'endless_best_notifier.dart';
import 'game_session_notifier.dart';
import 'progress_notifier.dart';
@@ -71,6 +75,33 @@ final endlessBestProvider = NotifierProvider(
EndlessBestNotifier.new,
);
+final adsRemovedProvider =
+ NotifierProvider(AdsRemovedNotifier.new);
+
+/// Reads ownership live from [adsRemovedProvider]; a mid-session purchase
+/// takes effect on the next ad decision without re-wiring.
+final adServiceProvider = Provider((ref) {
+ final service = AdService(adsRemoved: () => ref.read(adsRemovedProvider));
+ ref.onDispose(service.dispose);
+ return service;
+});
+
+final consentServiceProvider = Provider(
+ (ref) => ConsentService(ref.read(adServiceProvider)),
+);
+
+/// A verified remove_ads purchase/restore grants the entitlement through the
+/// notifier (persists + flips state), which AdService and the banner observe.
+final iapServiceProvider = Provider((ref) {
+ final service = IapService(
+ onEntitlementGranted: () =>
+ ref.read(adsRemovedProvider.notifier).grant(),
+ );
+ ref.onDispose(service.dispose);
+ service.initialize();
+ return service;
+});
+
final analyticsProvider = Provider(
(ref) => AnalyticsService(DebugAnalyticsBackend()),
);
diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart
index 8c58745..90cf52b 100644
--- a/lib/ui/screens/game_screen.dart
+++ b/lib/ui/screens/game_screen.dart
@@ -153,6 +153,9 @@ class _GameScreenState extends ConsumerState
if (next.phase != GamePhase.playing) {
ref.read(tutorialProvider.notifier).skip();
}
+ if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
+ ref.read(adServiceProvider).onStageCompleted();
+ }
if (next.phase == GamePhase.won) {
audio.play(Sfx.win);
// recordResult keeps the best run, so re-entry is harmless.
@@ -378,8 +381,12 @@ class _GameScreenState extends ConsumerState
[
if (flow != null && flow.hasNext)
FilledButton(
- onPressed:
- ref.read(seasonFlowProvider.notifier).nextStage,
+ onPressed: () {
+ ref.read(seasonFlowProvider.notifier).nextStage();
+ if (!view.endless) {
+ ref.read(adServiceProvider).maybeShowInterstitial();
+ }
+ },
child: Text(l10n.nextStage),
),
TextButton(
@@ -393,7 +400,10 @@ class _GameScreenState extends ConsumerState
[
if (!view.rescueUsed)
FilledButton(
- onPressed: () {
+ onPressed: () async {
+ final earned =
+ await ref.read(adServiceProvider).showRewarded();
+ if (!earned) return;
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
notifier.addExtraMoves();
},
@@ -416,7 +426,10 @@ class _GameScreenState extends ConsumerState
[
if (!view.rescueUsed)
FilledButton(
- onPressed: () {
+ onPressed: () async {
+ final earned =
+ await ref.read(adServiceProvider).showRewarded();
+ if (!earned) return;
ref.read(analyticsProvider).rescueUsed(type: 'continue');
notifier.useContinue();
},
@@ -450,7 +463,10 @@ class _GameScreenState extends ConsumerState
l10n.stageFailed,
[
FilledButton(
- onPressed: notifier.restart,
+ onPressed: () {
+ ref.read(adServiceProvider).maybeShowInterstitial();
+ notifier.restart();
+ },
child: Text(l10n.playAgain),
),
],
diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart
index aa85a2d..2891e60 100644
--- a/lib/ui/screens/home_screen.dart
+++ b/lib/ui/screens/home_screen.dart
@@ -5,9 +5,11 @@ import '../../game/models/season.dart';
import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
+import '../widgets/banner_ad_slot.dart';
import '../widgets/season_background.dart';
import 'game_screen.dart';
import 'season_map_screen.dart';
+import 'settings_screen.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@@ -32,83 +34,104 @@ class HomeScreen extends ConsumerWidget {
children: [
const SeasonBackground(theme: SeasonTheme.fallback),
SafeArea(
- child: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- _logoMark(),
- const SizedBox(height: 18),
- Text(
- l10n.appTitle,
- style: Theme.of(context)
- .textTheme
- .displaySmall
- ?.copyWith(fontWeight: FontWeight.w900),
- ),
- if (streak.current > 0) ...[
- const SizedBox(height: 10),
- Chip(
- avatar: const Icon(
- Icons.local_fire_department,
- color: Colors.deepOrange,
- size: 20,
- ),
- label: Text(
- '${streak.current}',
- style: Theme.of(context).textTheme.titleMedium,
- ),
- ),
- ],
- const SizedBox(height: 44),
- FilledButton(
- style: FilledButton.styleFrom(
- padding: const EdgeInsets.symmetric(
- horizontal: 56, vertical: 18),
- textStyle: Theme.of(context).textTheme.titleLarge,
- ),
- onPressed: () {
- if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => const SeasonMapScreen()),
- );
- },
- child: Text(l10n.adventure),
- ),
- const SizedBox(height: 14),
- OutlinedButton(
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(
- horizontal: 40, vertical: 14),
- textStyle: Theme.of(context).textTheme.titleMedium,
- ),
- onPressed: () {
- if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
- ref.read(seasonFlowProvider.notifier).clear();
- ref.read(analyticsProvider).endlessStart();
- ref.read(gameSessionProvider.notifier).startStage(
- StageConfig.endless(
- seed: DateTime.now().millisecondsSinceEpoch,
+ child: Stack(
+ children: [
+ Column(
+ children: [
+ Expanded(
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ _logoMark(),
+ const SizedBox(height: 18),
+ Text(
+ l10n.appTitle,
+ style: Theme.of(context)
+ .textTheme
+ .displaySmall
+ ?.copyWith(fontWeight: FontWeight.w900),
),
- );
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => const GameScreen()),
- );
- },
- child: Text(l10n.classic),
- ),
- if (best > 0) ...[
- const SizedBox(height: 10),
- Text(
- l10n.bestScore(best),
- style: TextStyle(
- color: Colors.white.withValues(alpha: 0.55),
+ if (streak.current > 0) ...[
+ const SizedBox(height: 10),
+ Chip(
+ avatar: const Icon(
+ Icons.local_fire_department,
+ color: Colors.deepOrange,
+ size: 20,
+ ),
+ label: Text(
+ '${streak.current}',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ ),
+ ],
+ const SizedBox(height: 44),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 56, vertical: 18),
+ textStyle: Theme.of(context).textTheme.titleLarge,
+ ),
+ onPressed: () {
+ if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const SeasonMapScreen()),
+ );
+ },
+ child: Text(l10n.adventure),
+ ),
+ const SizedBox(height: 14),
+ OutlinedButton(
+ style: OutlinedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 40, vertical: 14),
+ textStyle: Theme.of(context).textTheme.titleMedium,
+ ),
+ onPressed: () {
+ if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
+ ref.read(seasonFlowProvider.notifier).clear();
+ ref.read(analyticsProvider).endlessStart();
+ ref.read(gameSessionProvider.notifier).startStage(
+ StageConfig.endless(
+ seed: DateTime.now().millisecondsSinceEpoch,
+ ),
+ );
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const GameScreen()),
+ );
+ },
+ child: Text(l10n.classic),
+ ),
+ if (best > 0) ...[
+ const SizedBox(height: 10),
+ Text(
+ l10n.bestScore(best),
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.55),
+ ),
+ ),
+ ],
+ ],
+ ),
),
),
+ const BannerAdSlot(),
],
- ],
- ),
+ ),
+ Positioned(
+ top: 8,
+ right: 8,
+ child: IconButton(
+ icon: const Icon(Icons.settings, color: Colors.white70),
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ ),
+ ),
+ ),
+ ],
),
),
],
diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart
index 8e02b49..8088cba 100644
--- a/lib/ui/screens/season_map_screen.dart
+++ b/lib/ui/screens/season_map_screen.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/models/season.dart';
import '../../state/providers.dart';
import '../theme/palette.dart';
+import '../widgets/banner_ad_slot.dart';
import '../widgets/map_layout.dart';
import '../widgets/season_background.dart';
import '../widgets/tile_painter.dart';
@@ -74,100 +75,109 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> {
return Scaffold(
backgroundColor: Colors.transparent,
- body: Stack(
- fit: StackFit.expand,
- children: [
- SeasonBackground(theme: pack.theme),
- LayoutBuilder(
- builder: (context, constraints) {
- final layout = MapLayout(width: constraints.maxWidth);
- final count = pack.stages.length;
- if (!_autoScrolled) {
- WidgetsBinding.instance.addPostFrameCallback((_) =>
- _autoScrollTo(
- layout, unlocked, count, constraints.maxHeight));
- }
- return SingleChildScrollView(
- controller: _scroll,
- reverse: true,
- child: SizedBox(
- width: constraints.maxWidth,
- height: layout.heightFor(count),
- child: Stack(
- children: [
- CustomPaint(
- size: Size(
- constraints.maxWidth, layout.heightFor(count)),
- painter:
- _PathPainter(layout: layout, count: count),
- ),
- for (var i = 0; i < count; i++)
- _node(
- context,
- layout,
- i,
- count,
- unlocked,
- repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0,
- colors,
- seasonComplete,
- ),
- ],
- ),
- ),
- );
- },
- ),
- // Glass header.
- Positioned(
- top: 0,
- left: 0,
- right: 0,
- child: Container(
- padding: EdgeInsets.only(
- top: MediaQuery.of(context).padding.top + 6,
- bottom: 12,
- left: 8,
- right: 16,
- ),
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [
- Colors.black.withValues(alpha: 0.45),
- Colors.transparent,
- ],
- ),
- ),
- child: Row(
+ body: SafeArea(
+ child: Column(
+ children: [
+ Expanded(
+ child: Stack(
+ fit: StackFit.expand,
children: [
- IconButton(
- icon: const Icon(Icons.arrow_back, color: Colors.white),
- onPressed: () => Navigator.of(context).pop(),
+ SeasonBackground(theme: pack.theme),
+ LayoutBuilder(
+ builder: (context, constraints) {
+ final layout = MapLayout(width: constraints.maxWidth);
+ final count = pack.stages.length;
+ if (!_autoScrolled) {
+ WidgetsBinding.instance.addPostFrameCallback((_) =>
+ _autoScrollTo(
+ layout, unlocked, count, constraints.maxHeight));
+ }
+ return SingleChildScrollView(
+ controller: _scroll,
+ reverse: true,
+ child: SizedBox(
+ width: constraints.maxWidth,
+ height: layout.heightFor(count),
+ child: Stack(
+ children: [
+ CustomPaint(
+ size: Size(
+ constraints.maxWidth, layout.heightFor(count)),
+ painter:
+ _PathPainter(layout: layout, count: count),
+ ),
+ for (var i = 0; i < count; i++)
+ _node(
+ context,
+ layout,
+ i,
+ count,
+ unlocked,
+ repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0,
+ colors,
+ seasonComplete,
+ ),
+ ],
+ ),
+ ),
+ );
+ },
),
- Expanded(
- child: Text(
- pack.titleFor(locale),
- style: const TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.w800,
- color: Colors.white,
+ // Glass header.
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ padding: const EdgeInsets.only(
+ top: 6,
+ bottom: 12,
+ left: 8,
+ right: 16,
+ ),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withValues(alpha: 0.45),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ child: Row(
+ children: [
+ IconButton(
+ icon: const Icon(Icons.arrow_back, color: Colors.white),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ Expanded(
+ child: Text(
+ pack.titleFor(locale),
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ Text(
+ '★ $totalStars/${pack.stages.length * 3}',
+ style: const TextStyle(
+ color: Colors.amber,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
),
- ),
- ),
- Text(
- '★ $totalStars/${pack.stages.length * 3}',
- style: const TextStyle(
- color: Colors.amber,
- fontWeight: FontWeight.w700,
),
),
],
),
),
- ),
- ],
+ const BannerAdSlot(),
+ ],
+ ),
),
);
}
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
new file mode 100644
index 0000000..e4269af
--- /dev/null
+++ b/lib/ui/screens/settings_screen.dart
@@ -0,0 +1,58 @@
+// 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);
+
+ ref.listen(adsRemovedProvider, (prev, next) {
+ if (next && !(prev ?? false)) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(l10n.adsRemovedThanks)),
+ );
+ }
+ });
+
+ 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(),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/ui/widgets/banner_ad_slot.dart b/lib/ui/widgets/banner_ad_slot.dart
new file mode 100644
index 0000000..bd484ae
--- /dev/null
+++ b/lib/ui/widgets/banner_ad_slot.dart
@@ -0,0 +1,72 @@
+// 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 '../../services/ad_service.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.
+///
+/// If the slot is built before the AdMob SDK finished initializing (common on
+/// a cold start while the consent flow is still running), the first create
+/// returns null; it then retries once the [AdService.isReady] notifier flips.
+class BannerAdSlot extends ConsumerStatefulWidget {
+ const BannerAdSlot({super.key});
+
+ @override
+ ConsumerState createState() => _BannerAdSlotState();
+}
+
+class _BannerAdSlotState extends ConsumerState {
+ BannerAd? _ad;
+ bool _loaded = false;
+ AdService? _adService;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (_adService == null) {
+ _adService = ref.read(adServiceProvider);
+ _adService!.isReady.addListener(_tryCreate);
+ }
+ _tryCreate();
+ }
+
+ void _tryCreate() {
+ if (_ad != null || !mounted) return;
+ if (ref.read(adsRemovedProvider)) return;
+ final ad = _adService!.createBanner(
+ listener: BannerAdListener(
+ onAdLoaded: (_) {
+ if (mounted) setState(() => _loaded = true);
+ },
+ onAdFailedToLoad: (ad, _) => ad.dispose(),
+ ),
+ );
+ if (ad == null) return; // SDK not ready yet; isReady will retry.
+ _ad = ad;
+ }
+
+ @override
+ void dispose() {
+ _adService?.isReady.removeListener(_tryCreate);
+ _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),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index ee4d828..afa215b 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.7.1"
+ app_tracking_transparency:
+ dependency: "direct main"
+ description:
+ name: app_tracking_transparency
+ sha256: "3a52669c0645ffd7efe9a460047616de3e3f2128338f9ec7cdb681628d36238e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.7"
args:
dependency: transitive
description:
@@ -301,6 +309,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
+ google_mobile_ads:
+ dependency: "direct main"
+ description:
+ name: google_mobile_ads
+ sha256: f35e040875bb54e8a3455bcffed3b4ac9e9263fbf7751b9fd1ae7f30793faee8
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.0"
http:
dependency: "direct main"
description:
@@ -325,6 +341,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ in_app_purchase:
+ dependency: "direct main"
+ description:
+ name: in_app_purchase
+ sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.3"
+ in_app_purchase_android:
+ dependency: transitive
+ description:
+ name: in_app_purchase_android
+ sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.0+10"
+ in_app_purchase_platform_interface:
+ dependency: transitive
+ description:
+ name: in_app_purchase_platform_interface
+ sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ in_app_purchase_storekit:
+ dependency: transitive
+ description:
+ name: in_app_purchase_storekit
+ sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.8+1"
intl:
dependency: transitive
description:
@@ -349,6 +397,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.2"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.12.0"
leak_tracker:
dependency: transitive
description:
@@ -794,6 +850,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
+ webview_flutter:
+ dependency: transitive
+ description:
+ name: webview_flutter
+ sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.13.1"
+ webview_flutter_android:
+ dependency: transitive
+ description:
+ name: webview_flutter_android
+ sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.12.0"
+ webview_flutter_platform_interface:
+ dependency: transitive
+ description:
+ name: webview_flutter_platform_interface
+ sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.15.1"
+ webview_flutter_wkwebview:
+ dependency: transitive
+ description:
+ name: webview_flutter_wkwebview
+ sha256: e50cd97ad28e888f6a4f862a05eab917a635417f9b134df64c3abb78250c6446
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.25.0"
xdg_directories:
dependency: transitive
description:
@@ -812,4 +900,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.9.2 <4.0.0"
- flutter: ">=3.35.0"
+ flutter: ">=3.35.1"
diff --git a/pubspec.yaml b/pubspec.yaml
index cd828c2..c3ad09a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -44,6 +44,9 @@ dependencies:
path_provider: ^2.1.5
firebase_core: ^4.10.0
firebase_analytics: ^12.4.2
+ google_mobile_ads: ^7.0.0
+ in_app_purchase: ^3.2.3
+ app_tracking_transparency: ^2.0.7
dev_dependencies:
flutter_test:
diff --git a/test/data/save_repository_ads_test.dart b/test/data/save_repository_ads_test.dart
new file mode 100644
index 0000000..b60a352
--- /dev/null
+++ b/test/data/save_repository_ads_test.dart
@@ -0,0 +1,26 @@
+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);
+ });
+}
diff --git a/test/services/ad_frequency_policy_test.dart b/test/services/ad_frequency_policy_test.dart
new file mode 100644
index 0000000..98a3e6a
--- /dev/null
+++ b/test/services/ad_frequency_policy_test.dart
@@ -0,0 +1,75 @@
+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
+ });
+}
diff --git a/test/state/ads_notifier_test.dart b/test/state/ads_notifier_test.dart
new file mode 100644
index 0000000..0c02e62
--- /dev/null
+++ b/test/state/ads_notifier_test.dart
@@ -0,0 +1,21 @@
+import 'package:block_seasons/data/save_repository.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);
+ });
+}