This commit is contained in:
2026-08-08 10:57:54 +08:00
parent d0d48c1ffc
commit 604af909b0
184 changed files with 12521 additions and 12722 deletions
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../../core/providers/app_providers.dart';
import '../../config/application/app_config_controller.dart';
/// Observable state for the in-app purchase flow.
class IapState {
const IapState({
this.product,
this.isLoading = false,
this.isPurchasing = false,
this.errorMessage,
this.productsAvailable = false,
this.lastEvent,
});
final ProductDetails? product;
final bool isLoading;
final bool isPurchasing;
final String? errorMessage;
final bool productsAvailable;
/// Last human-readable event (e.g. "解锁成功").
final String? lastEvent;
IapState copyWith({
ProductDetails? product,
bool? isLoading,
bool? isPurchasing,
String? errorMessage,
bool? productsAvailable,
String? lastEvent,
}) {
return IapState(
product: product ?? this.product,
isLoading: isLoading ?? this.isLoading,
isPurchasing: isPurchasing ?? this.isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable ?? this.productsAvailable,
lastEvent: lastEvent,
);
}
}
final iapControllerProvider = NotifierProvider<IapController, IapState>(
IapController.new,
);
class IapController extends Notifier<IapState> {
late final InAppPurchase _store = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
@override
IapState build() {
ref.onDispose(() {
_sub?.cancel();
_sub = null;
});
_init();
return const IapState(isLoading: true);
}
Future<void> _init() async {
final available = await _store.isAvailable();
if (!available) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '无法连接到 App Store,请检查网络设置',
);
return;
}
_sub = _store.purchaseStream.listen(
_onPurchaseUpdated,
onError: (Object e) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付失败:$e');
},
);
await _loadProduct();
}
Future<void> _loadProduct() async {
final response = await _store.queryProductDetails({kUnlockProductId});
final error = response.error;
if (error != null) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '商品信息加载失败:${error.message}',
);
return;
}
final products = response.productDetails;
if (products.isEmpty) {
// No product configured in App Store Connect yet.
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: null,
);
return;
}
final product = products.first;
state = state.copyWith(
isLoading: false,
productsAvailable: true,
product: product,
errorMessage: null,
);
}
Future<void> buy() async {
final product = state.product;
if (product == null) {
state = state.copyWith(errorMessage: '商品尚未就绪,请稍后再试');
return;
}
state = state.copyWith(
isPurchasing: true,
errorMessage: null,
lastEvent: null,
);
final param = PurchaseParam(productDetails: product);
final ok = await _store.buyNonConsumable(purchaseParam: param);
if (!ok) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付已取消');
}
}
Future<void> restore() async {
state = state.copyWith(
isLoading: true,
errorMessage: null,
lastEvent: null,
);
await _store.restorePurchases();
// The purchase stream will deliver restored purchases; clear loading after
// a short delay in case nothing arrives.
Future<void>.delayed(const Duration(seconds: 2), () {
if (state.isLoading) {
state = state.copyWith(
isLoading: false,
lastEvent: state.lastEvent ?? '未找到历史购买记录',
);
}
});
}
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
_handle(purchase);
}
}
Future<void> _handle(PurchaseDetails purchase) async {
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
if (purchase.pendingCompletePurchase) {
await _store.completePurchase(purchase);
}
await ref
.read(appConfigProvider.notifier)
.markUnlocked(
fromRestore: purchase.status == PurchaseStatus.restored,
);
state = IapState(
product: state.product,
productsAvailable: state.productsAvailable,
lastEvent: purchase.status == PurchaseStatus.restored
? '权益已恢复'
: '解锁成功!已获得无限记分权益',
);
break;
case PurchaseStatus.error:
state = state.copyWith(
isPurchasing: false,
errorMessage: '支付失败:${purchase.error?.message ?? '未知错误'}',
);
break;
case PurchaseStatus.canceled:
state = state.copyWith(isPurchasing: false, lastEvent: '支付已取消');
break;
case PurchaseStatus.pending:
// Purchase awaiting approval/processing; keep the loading state.
state = state.copyWith(isPurchasing: true);
break;
}
}
/// Debug-only unlock used so the app remains usable without App Store
/// Connect configuration during development.
Future<void> debugUnlock() async {
await ref.read(appConfigProvider.notifier).debugUnlock();
state = state.copyWith(lastEvent: '已通过开发模式解锁');
}
void clearEvent() {
state = state.copyWith(lastEvent: null, errorMessage: null);
}
}
@visibleForTesting
IapState iapStateForTest({
ProductDetails? product,
bool isLoading = false,
bool isPurchasing = false,
String? errorMessage,
bool productsAvailable = false,
String? lastEvent,
}) {
return IapState(
product: product,
isLoading: isLoading,
isPurchasing: isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable,
lastEvent: lastEvent,
);
}
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/iap/application/iap_controller.dart';
class IapUnlockDialog extends ConsumerWidget {
const IapUnlockDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final iap = ref.watch(iapControllerProvider);
final product = iap.product;
ref.listen(iapControllerProvider, (prev, next) {
if (next.lastEvent != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.lastEvent!)),
);
if (next.lastEvent!.contains('解锁') ||
next.lastEvent!.contains('恢复')) {
Navigator.of(context).pop();
}
} else if (next.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.errorMessage!)),
);
}
});
return AlertDialog(
title: const Text('解锁记分本'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'免费试用次数已用完。购买后即可永久解锁无限记分权益,无需联网即可使用。',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_PriceRow(
title: product?.title ?? '记分本永久解锁',
price: iap.productsAvailable && product != null
? product.price
: '',
),
const SizedBox(height: 8),
_FeatureItem(text: '无限次记分训练'),
_FeatureItem(text: '本地永久保存训练数据'),
_FeatureItem(text: '一次购买,永久使用'),
if (iap.isLoading) ...[
const SizedBox(height: 12),
const Center(child: CircularProgressIndicator()),
],
],
),
),
actions: [
TextButton(
onPressed: iap.isPurchasing
? null
: () => Navigator.of(context).pop(),
child: const Text('暂不解锁'),
),
TextButton(
onPressed: iap.isPurchasing ? null : () => ref.read(iapControllerProvider.notifier).restore(),
child: const Text('恢复购买'),
),
FilledButton(
onPressed: iap.isPurchasing || iap.isLoading
? null
: () => _unlock(context, ref, iap),
child: iap.isPurchasing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('立即解锁'),
),
],
);
}
Future<void> _unlock(
BuildContext context,
WidgetRef ref,
IapState iap,
) async {
// In debug builds with no App Store product configured, fall back to a
// dev unlock so the app remains usable for testing.
if (kDebugMode && !iap.productsAvailable) {
await ref.read(iapControllerProvider.notifier).debugUnlock();
if (context.mounted) Navigator.of(context).pop();
return;
}
await ref.read(iapControllerProvider.notifier).buy();
}
}
class _PriceRow extends StatelessWidget {
const _PriceRow({required this.title, required this.price});
final String title;
final String price;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.surfaceMuted,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
price,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
),
),
],
),
);
}
}
class _FeatureItem extends StatelessWidget {
const _FeatureItem({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.check_circle, color: AppTheme.primary, size: 18),
const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 13)),
],
),
);
}
}