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"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
+165
-1
@@ -1,37 +1,201 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- app_tracking_transparency (0.0.1):
|
||||||
|
- Flutter
|
||||||
- audioplayers_darwin (0.0.1):
|
- audioplayers_darwin (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- 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)
|
- 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):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- PromisesObjC (2.4.0)
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
|
||||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
- 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`)
|
- 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`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_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:
|
EXTERNAL SOURCES:
|
||||||
|
app_tracking_transparency:
|
||||||
|
:path: ".symlinks/plugins/app_tracking_transparency/ios"
|
||||||
audioplayers_darwin:
|
audioplayers_darwin:
|
||||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
||||||
|
firebase_analytics:
|
||||||
|
:path: ".symlinks/plugins/firebase_analytics/ios"
|
||||||
|
firebase_core:
|
||||||
|
:path: ".symlinks/plugins/firebase_core/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: 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_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
|
||||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
|
Firebase: 7cc10425300768ec86292688af5cb228f0604bde
|
||||||
|
firebase_analytics: 9ea6c22e60e1d37ee76772de57b4addddb03e276
|
||||||
|
firebase_core: 383e19b49a08df5d7a6cf5017616de6a357ed7af
|
||||||
|
FirebaseAnalytics: 99364329a3ea4d1ab4a3744b5464371b7351c481
|
||||||
|
FirebaseCore: 4939b340b9c598dc1f965d68f8fe57e630b65407
|
||||||
|
FirebaseCoreInternal: 090369a5fffd7423cf88006ab4d2ccc2173a8db9
|
||||||
|
FirebaseInstallations: 7cdc919e29dc54306edeffdbdc1eed1a40d7d1e7
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
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
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -202,6 +202,7 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */,
|
B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */,
|
||||||
|
2838ED76467446CF49AD274C /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -274,6 +275,23 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase 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 */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
|
|||||||
@@ -45,5 +45,16 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+20
-1
@@ -1,12 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'l10n/gen/app_localizations.dart';
|
import 'l10n/gen/app_localizations.dart';
|
||||||
|
import 'state/providers.dart';
|
||||||
import 'ui/screens/splash_screen.dart';
|
import 'ui/screens/splash_screen.dart';
|
||||||
|
|
||||||
class BlockSeasonsApp extends StatelessWidget {
|
class BlockSeasonsApp extends ConsumerStatefulWidget {
|
||||||
const BlockSeasonsApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class SaveRepository {
|
|||||||
false;
|
false;
|
||||||
_endlessBest =
|
_endlessBest =
|
||||||
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
|
(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;
|
StreakState _streak = StreakState.initial;
|
||||||
bool _tutorialDone = false;
|
bool _tutorialDone = false;
|
||||||
int _endlessBest = 0;
|
int _endlessBest = 0;
|
||||||
|
bool _adsRemoved = false;
|
||||||
|
|
||||||
StreakState get streak => _streak;
|
StreakState get streak => _streak;
|
||||||
bool get tutorialDone => _tutorialDone;
|
bool get tutorialDone => _tutorialDone;
|
||||||
int get endlessBest => _endlessBest;
|
int get endlessBest => _endlessBest;
|
||||||
|
bool get adsRemoved => _adsRemoved;
|
||||||
|
|
||||||
Future<void> markTutorialDone() {
|
Future<void> markTutorialDone() {
|
||||||
_tutorialDone = true;
|
_tutorialDone = true;
|
||||||
return _flush();
|
return _flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setAdsRemoved(bool value) {
|
||||||
|
_adsRemoved = value;
|
||||||
|
return _flush();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> recordEndlessScore(int score) {
|
Future<void> recordEndlessScore(int score) {
|
||||||
if (score > _endlessBest) _endlessBest = score;
|
if (score > _endlessBest) _endlessBest = score;
|
||||||
return _flush();
|
return _flush();
|
||||||
@@ -130,7 +140,7 @@ class SaveRepository {
|
|||||||
'best': _streak.best,
|
'best': _streak.best,
|
||||||
'lastYmd': _streak.lastYmd,
|
'lastYmd': _streak.lastYmd,
|
||||||
},
|
},
|
||||||
'flags': {'tutorialDone': _tutorialDone},
|
'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved},
|
||||||
'endless': {'best': _endlessBest},
|
'endless': {'best': _endlessBest},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-1
@@ -54,5 +54,10 @@
|
|||||||
},
|
},
|
||||||
"newBest": "NEW BEST!",
|
"newBest": "NEW BEST!",
|
||||||
"adventure": "Adventure",
|
"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}",
|
"bestScore": "최고 {score}",
|
||||||
"newBest": "신기록!",
|
"newBest": "신기록!",
|
||||||
"adventure": "어드벤처",
|
"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);
|
_engine = GameEngine(stage, attempt: attempt, generator: generator);
|
||||||
_fxTick = 0;
|
_fxTick = 0;
|
||||||
_publish(lastPlacement: null);
|
_publish(lastPlacement: null);
|
||||||
|
ref.read(adServiceProvider).onRoundStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restarts the current stage as a new attempt (fresh piece sequence).
|
/// 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/save_repository.dart';
|
||||||
import '../data/streak.dart';
|
import '../data/streak.dart';
|
||||||
import '../game/models/season.dart';
|
import '../game/models/season.dart';
|
||||||
|
import '../services/ad_service.dart';
|
||||||
import '../services/analytics_service.dart';
|
import '../services/analytics_service.dart';
|
||||||
import '../services/audio_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 'endless_best_notifier.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
import 'progress_notifier.dart';
|
import 'progress_notifier.dart';
|
||||||
@@ -71,6 +75,33 @@ final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
|
|||||||
EndlessBestNotifier.new,
|
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>(
|
final analyticsProvider = Provider<AnalyticsService>(
|
||||||
(ref) => AnalyticsService(DebugAnalyticsBackend()),
|
(ref) => AnalyticsService(DebugAnalyticsBackend()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
if (next.phase != GamePhase.playing) {
|
if (next.phase != GamePhase.playing) {
|
||||||
ref.read(tutorialProvider.notifier).skip();
|
ref.read(tutorialProvider.notifier).skip();
|
||||||
}
|
}
|
||||||
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
||||||
|
ref.read(adServiceProvider).onStageCompleted();
|
||||||
|
}
|
||||||
if (next.phase == GamePhase.won) {
|
if (next.phase == GamePhase.won) {
|
||||||
audio.play(Sfx.win);
|
audio.play(Sfx.win);
|
||||||
// recordResult keeps the best run, so re-entry is harmless.
|
// recordResult keeps the best run, so re-entry is harmless.
|
||||||
@@ -378,8 +381,12 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
[
|
[
|
||||||
if (flow != null && flow.hasNext)
|
if (flow != null && flow.hasNext)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed:
|
onPressed: () {
|
||||||
ref.read(seasonFlowProvider.notifier).nextStage,
|
ref.read(seasonFlowProvider.notifier).nextStage();
|
||||||
|
if (!view.endless) {
|
||||||
|
ref.read(adServiceProvider).maybeShowInterstitial();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Text(l10n.nextStage),
|
child: Text(l10n.nextStage),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -393,7 +400,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
[
|
[
|
||||||
if (!view.rescueUsed)
|
if (!view.rescueUsed)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
final earned =
|
||||||
|
await ref.read(adServiceProvider).showRewarded();
|
||||||
|
if (!earned) return;
|
||||||
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
||||||
notifier.addExtraMoves();
|
notifier.addExtraMoves();
|
||||||
},
|
},
|
||||||
@@ -416,7 +426,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
[
|
[
|
||||||
if (!view.rescueUsed)
|
if (!view.rescueUsed)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
final earned =
|
||||||
|
await ref.read(adServiceProvider).showRewarded();
|
||||||
|
if (!earned) return;
|
||||||
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
||||||
notifier.useContinue();
|
notifier.useContinue();
|
||||||
},
|
},
|
||||||
@@ -450,7 +463,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
l10n.stageFailed,
|
l10n.stageFailed,
|
||||||
[
|
[
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: notifier.restart,
|
onPressed: () {
|
||||||
|
ref.read(adServiceProvider).maybeShowInterstitial();
|
||||||
|
notifier.restart();
|
||||||
|
},
|
||||||
child: Text(l10n.playAgain),
|
child: Text(l10n.playAgain),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import '../../game/models/season.dart';
|
|||||||
import '../../game/models/stage.dart';
|
import '../../game/models/stage.dart';
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
|
import '../widgets/banner_ad_slot.dart';
|
||||||
import '../widgets/season_background.dart';
|
import '../widgets/season_background.dart';
|
||||||
import 'game_screen.dart';
|
import 'game_screen.dart';
|
||||||
import 'season_map_screen.dart';
|
import 'season_map_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -32,6 +34,11 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
const SeasonBackground(theme: SeasonTheme.fallback),
|
const SeasonBackground(theme: SeasonTheme.fallback),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -111,6 +118,22 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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 '../../game/models/season.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
import '../theme/palette.dart';
|
import '../theme/palette.dart';
|
||||||
|
import '../widgets/banner_ad_slot.dart';
|
||||||
import '../widgets/map_layout.dart';
|
import '../widgets/map_layout.dart';
|
||||||
import '../widgets/season_background.dart';
|
import '../widgets/season_background.dart';
|
||||||
import '../widgets/tile_painter.dart';
|
import '../widgets/tile_painter.dart';
|
||||||
@@ -74,7 +75,11 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: Stack(
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
SeasonBackground(theme: pack.theme),
|
SeasonBackground(theme: pack.theme),
|
||||||
@@ -124,8 +129,8 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 6,
|
top: 6,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 16,
|
right: 16,
|
||||||
@@ -169,6 +174,11 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.7.1"
|
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:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -301,6 +309,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -325,6 +341,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -349,6 +397,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -794,6 +850,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -812,4 +900,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.2 <4.0.0"
|
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
|
path_provider: ^2.1.5
|
||||||
firebase_core: ^4.10.0
|
firebase_core: ^4.10.0
|
||||||
firebase_analytics: ^12.4.2
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
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