From 6d2ffebb92fcad54f145a13ae855d07d8234a923 Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 13:55:44 +0900 Subject: [PATCH] feat(iap): remove_ads purchase/restore service + adsRemoved notifier --- lib/services/iap_service.dart | 73 +++++++++++++++++++++++++++++++ lib/state/ads_notifier.dart | 17 +++++++ lib/state/providers.dart | 4 ++ test/state/ads_notifier_test.dart | 22 ++++++++++ 4 files changed, 116 insertions(+) create mode 100644 lib/services/iap_service.dart create mode 100644 lib/state/ads_notifier.dart create mode 100644 test/state/ads_notifier_test.dart 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/providers.dart b/lib/state/providers.dart index 34d1f52..49cd009 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -6,6 +6,7 @@ import '../data/streak.dart'; import '../game/models/season.dart'; import '../services/analytics_service.dart'; import '../services/audio_service.dart'; +import 'ads_notifier.dart'; import 'endless_best_notifier.dart'; import 'game_session_notifier.dart'; import 'progress_notifier.dart'; @@ -71,6 +72,9 @@ final endlessBestProvider = NotifierProvider( EndlessBestNotifier.new, ); +final adsRemovedProvider = + NotifierProvider(AdsRemovedNotifier.new); + final analyticsProvider = Provider( (ref) => AnalyticsService(DebugAnalyticsBackend()), ); diff --git a/test/state/ads_notifier_test.dart b/test/state/ads_notifier_test.dart new file mode 100644 index 0000000..7341eaa --- /dev/null +++ b/test/state/ads_notifier_test.dart @@ -0,0 +1,22 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/state/ads_notifier.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); + }); +}