Merge Phase 5: monetization (AdMob + IAP)

AdMob ads (interstitial/rewarded/banner) with a pure-Dart frequency policy,
a compliant UMP->ATT->init consent flow, and a remove_ads non-consumable IAP
with Restore. Single repo-backed ownership source (adsRemovedProvider); all
ad/IAP/consent failures swallowed. Runs on Google test ids today; owner swaps
real ids by config. 169 tests green; opus final review passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:43:46 +09:00
27 changed files with 1258 additions and 170 deletions
+3
View File
@@ -25,6 +25,9 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3940256099942544~3347511713"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+165 -1
View File
@@ -1,37 +1,201 @@
PODS:
- app_tracking_transparency (0.0.1):
- Flutter
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- Firebase/CoreOnly (12.14.0):
- FirebaseCore (~> 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
+18
View File
@@ -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;
+11
View File
@@ -45,5 +45,16 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<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>
</dict>
</plist>
+20 -1
View File
@@ -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<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
}
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp> {
@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(
+11 -1
View File
@@ -39,6 +39,9 @@ class SaveRepository {
false;
_endlessBest =
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
_adsRemoved =
(json['flags'] as Map<String, dynamic>?)?['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<void> markTutorialDone() {
_tutorialDone = true;
return _flush();
}
Future<void> setAdsRemoved(bool value) {
_adsRemoved = value;
return _flush();
}
Future<void> 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},
}),
);
+6 -1
View File
@@ -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."
}
+6 -1
View File
@@ -26,5 +26,10 @@
"bestScore": "최고 {score}",
"newBest": "신기록!",
"adventure": "어드벤처",
"classic": "클래식"
"classic": "클래식",
"removeAds": "광고 제거",
"removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
"restorePurchases": "구매 복원",
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
}
+56
View File
@@ -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';
}
+47
View File
@@ -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;
}
}
+150
View File
@@ -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<bool> isReady = ValueNotifier<bool>(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<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: (_, 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();
}
}
+86
View File
@@ -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<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');
}
}
}
+73
View File
@@ -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<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();
}
+17
View File
@@ -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<bool> {
@override
bool build() => ref.read(saveRepositoryProvider).adsRemoved;
Future<void> grant() async {
if (state) return;
await ref.read(saveRepositoryProvider).setAdsRemoved(true);
state = true;
}
}
+1
View File
@@ -68,6 +68,7 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
_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).
+31
View File
@@ -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, int>(
EndlessBestNotifier.new,
);
final adsRemovedProvider =
NotifierProvider<AdsRemovedNotifier, bool>(AdsRemovedNotifier.new);
/// 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;
});
final analyticsProvider = Provider<AnalyticsService>(
(ref) => AnalyticsService(DebugAnalyticsBackend()),
);
+21 -5
View File
@@ -153,6 +153,9 @@ class _GameScreenState extends ConsumerState<GameScreen>
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<GameScreen>
[
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<GameScreen>
[
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<GameScreen>
[
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<GameScreen>
l10n.stageFailed,
[
FilledButton(
onPressed: notifier.restart,
onPressed: () {
ref.read(adServiceProvider).maybeShowInterstitial();
notifier.restart();
},
child: Text(l10n.playAgain),
),
],
+96 -73
View File
@@ -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()),
),
),
),
],
),
),
],
+96 -86
View File
@@ -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(),
],
),
),
);
}
+58
View File
@@ -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<bool>(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(),
),
],
),
);
}
}
+72
View File
@@ -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<BannerAdSlot> createState() => _BannerAdSlotState();
}
class _BannerAdSlotState extends ConsumerState<BannerAdSlot> {
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),
);
}
}
+89 -1
View File
@@ -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"
+3
View File
@@ -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:
+26
View File
@@ -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);
});
}
@@ -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
});
}
+21
View File
@@ -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);
});
}