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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -26,5 +26,10 @@
|
||||
"bestScore": "최고 {score}",
|
||||
"newBest": "신기록!",
|
||||
"adventure": "어드벤처",
|
||||
"classic": "클래식"
|
||||
"classic": "클래식",
|
||||
"removeAds": "광고 제거",
|
||||
"removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
|
||||
"restorePurchases": "구매 복원",
|
||||
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user