feat(iap): remove_ads purchase/restore service + adsRemoved notifier
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, int>(
|
||||
EndlessBestNotifier.new,
|
||||
);
|
||||
|
||||
final adsRemovedProvider =
|
||||
NotifierProvider<AdsRemovedNotifier, bool>(AdsRemovedNotifier.new);
|
||||
|
||||
final analyticsProvider = Provider<AnalyticsService>(
|
||||
(ref) => AnalyticsService(DebugAnalyticsBackend()),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user