nkbmusic/lib/features/support/support_screen.dart
metatroncubeswdev d257fc4c65 Support Nanga Kathai: one-time in-app gifts in local currency
Three consumable products (nk_support_small/medium/large) via Google Play
and App Store billing; a 'give more' option emails support. FAQ, Terms and
Privacy updated: v1 is free, creator accounts come in a later update.
docs/support-payments.md lists the per-country prices and store setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:07:26 -04:00

218 lines
8.6 KiB
Dart

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<ProductDetails> 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<SupportState> {
StreamSubscription<List<PurchaseDetails>>? _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<void> 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<void> 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<void> _onPurchases(List<PurchaseDetails> 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, SupportState>(SupportPurchases.new);
class SupportScreen extends ConsumerStatefulWidget {
const SupportScreen({super.key});
@override
ConsumerState<SupportScreen> createState() => _SupportScreenState();
}
class _SupportScreenState extends ConsumerState<SupportScreen> {
@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),
),
],
),
);
}
}