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>
218 lines
8.6 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|