import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import '../../app/theme.dart'; import '../settings/settings_screen.dart'; /// One-time "Support Nanga Kathai" amounts. Create these as consumable in-app products with the /// same IDs in Play Console and App Store Connect; each store charges in the buyer's own /// currency (₹, CA$, US$, AED, SAR…) and the app shows the price exactly as the store gives it. const supportProductIds = {'nk_support_small', 'nk_support_medium', 'nk_support_large'}; /// Prices are set per country in the stores, e.g. India ₹300 / ₹500 / ₹1,000, /// US and Canada $10 / $25 / $50 (see docs/support-payments.md). const _labels = { 'nk_support_small': ('Supporter', Icons.favorite_outline), 'nk_support_medium': ('Friend of Nanga Kathai', Icons.music_note_outlined), 'nk_support_large': ('Champion of Badaga music', Icons.favorite_rounded), }; enum SupportStatus { loading, ready, unavailable, buying, thanks, failed } class SupportState { const SupportState(this.status, [this.products = const []]); final SupportStatus status; final List products; SupportState copyWith(SupportStatus status) => SupportState(status, products); } /// Started at app launch, so a purchase that finishes while the app was closed is still /// completed (Google refunds purchases that are never acknowledged). class SupportPurchases extends Notifier { StreamSubscription>? _sub; @override SupportState build() { _sub = InAppPurchase.instance.purchaseStream.listen(_onPurchases, onError: (_) { state = state.copyWith(SupportStatus.failed); }); ref.onDispose(() => _sub?.cancel()); return const SupportState(SupportStatus.loading); } Future loadProducts() async { if (state.products.isNotEmpty) return; state = const SupportState(SupportStatus.loading); try { if (!await InAppPurchase.instance.isAvailable()) { state = const SupportState(SupportStatus.unavailable); return; } final res = await InAppPurchase.instance.queryProductDetails(supportProductIds); final products = [...res.productDetails]..sort((a, b) => a.rawPrice.compareTo(b.rawPrice)); state = SupportState(products.isEmpty ? SupportStatus.unavailable : SupportStatus.ready, products); } catch (_) { state = const SupportState(SupportStatus.unavailable); } } Future buy(ProductDetails product) async { state = state.copyWith(SupportStatus.buying); try { await InAppPurchase.instance.buyConsumable(purchaseParam: PurchaseParam(productDetails: product)); } catch (_) { state = state.copyWith(SupportStatus.failed); } } void reset() => state = state.copyWith(state.products.isEmpty ? SupportStatus.loading : SupportStatus.ready); Future _onPurchases(List purchases) async { for (final p in purchases) { switch (p.status) { case PurchaseStatus.purchased || PurchaseStatus.restored: state = state.copyWith(SupportStatus.thanks); case PurchaseStatus.error: state = state.copyWith(SupportStatus.failed); case PurchaseStatus.canceled: state = state.copyWith(SupportStatus.ready); case PurchaseStatus.pending: break; } if (p.pendingCompletePurchase) await InAppPurchase.instance.completePurchase(p); } } } final supportProvider = NotifierProvider(SupportPurchases.new); class SupportScreen extends ConsumerStatefulWidget { const SupportScreen({super.key}); @override ConsumerState createState() => _SupportScreenState(); } class _SupportScreenState extends ConsumerState { @override void initState() { super.initState(); Future.microtask(() => ref.read(supportProvider.notifier).loadProducts()); } @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; final support = ref.watch(supportProvider); final notifier = ref.read(supportProvider.notifier); if (support.status == SupportStatus.thanks) { return Scaffold( appBar: AppBar(), body: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Icon(Icons.favorite_rounded, color: NkColors.sun, size: 64), const SizedBox(height: 16), Text('Thank you!', textAlign: TextAlign.center, style: text.headlineMedium), const SizedBox(height: 8), const Text( 'Your support keeps Badaga music free for everyone and helps us bring more artists to Nanga Kathai.', textAlign: TextAlign.center, style: TextStyle(color: NkColors.muted, height: 1.5), ), const SizedBox(height: 24), FilledButton( onPressed: () { notifier.reset(); Navigator.of(context).maybePop(); }, child: const Text('Done'), ), ], ), ), ); } return Scaffold( appBar: AppBar(title: const Text('Support Nanga Kathai')), body: ListView( padding: const EdgeInsets.fromLTRB(20, 0, 20, 32), children: [ Text('Help keep Badaga music free', style: text.headlineSmall), const SizedBox(height: 8), const Text( 'Nanga Kathai is free, with no ads. If it brings you joy, a one-time gift helps us pay for the servers, ' 'restore old recordings and build the tools that will let singers earn from their songs.', style: TextStyle(color: NkColors.muted, height: 1.5), ), const SizedBox(height: 20), switch (support.status) { SupportStatus.loading => const Padding( padding: EdgeInsets.all(32), child: Center(child: CircularProgressIndicator()), ), SupportStatus.unavailable => const Card( color: NkColors.surface, child: Padding( padding: EdgeInsets.all(16), child: Text( 'Support is not available on this phone right now. Please check that you are signed in to ' 'Google Play or the App Store, and try again later.', style: TextStyle(color: NkColors.muted, height: 1.5), ), ), ), _ => Column( children: [ for (final p in support.products) Card( color: NkColors.surface, child: ListTile( leading: Icon(_labels[p.id]?.$2 ?? Icons.favorite_outline, color: NkColors.sun), title: Text(_labels[p.id]?.$1 ?? p.title, style: const TextStyle(fontWeight: FontWeight.w700)), trailing: FilledButton( onPressed: support.status == SupportStatus.buying ? null : () => notifier.buy(p), child: Text(p.price), ), ), ), if (support.status == SupportStatus.failed) const Padding( padding: EdgeInsets.only(top: 8), child: Text('The payment did not go through. You have not been charged.', style: TextStyle(color: NkColors.muted)), ), ], ), }, const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.volunteer_activism_outlined, color: NkColors.sun), title: const Text('Want to give more, or sponsor an album?'), subtitle: const Text('Write to us at $supportEmail', style: TextStyle(color: NkColors.muted)), onTap: () => contactSupport(subject: 'Supporting Nanga Kathai'), ), const SizedBox(height: 8), const Text( 'One-time payment through Google Play or the App Store, in your local currency. ' 'Nothing renews and nothing is unlocked: every song stays free for everyone.', style: TextStyle(color: NkColors.muted, fontSize: 12, height: 1.5), ), ], ), ); } }