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