feat: 实现 iOS Apple Pay 内购支付功能
前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
This commit is contained in:
@@ -1,21 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem_mapper.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../../data/storage/local_kv_store.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../payments/data/services/apple_iap_service.dart';
|
||||
import '../../../points/data/apis/points_api.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class CoinCenterScreen extends StatefulWidget {
|
||||
const CoinCenterScreen({super.key, required this.balance});
|
||||
const CoinCenterScreen({
|
||||
super.key,
|
||||
required this.balance,
|
||||
required this.userId,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final int balance;
|
||||
final String userId;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
|
||||
@@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
final Logger _logger = getLogger('features.settings.coin_center_screen');
|
||||
List<PackageInfo>? _packages;
|
||||
bool _isLoading = true;
|
||||
AppleIapService? _iapService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
|
||||
final api = PointsApi(apiClient.rawDio);
|
||||
final result = await api.getPackages();
|
||||
|
||||
final service = AppleIapService(
|
||||
apiClient: apiClient,
|
||||
userId: widget.userId,
|
||||
);
|
||||
service.init();
|
||||
service.addListener(_onPurchaseStateChanged);
|
||||
|
||||
if (await InAppPurchase.instance.isAvailable()) {
|
||||
await service.loadStoreKitProducts(result.packages);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
_isLoading = false;
|
||||
_iapService = service;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
@@ -61,6 +90,60 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onPurchaseStateChanged() {
|
||||
final service = _iapService;
|
||||
if (service == null || !mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (service.state) {
|
||||
case PurchaseFlowState.success:
|
||||
Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
|
||||
_refreshBalance();
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.failed:
|
||||
final apiProblem = service.lastApiProblem;
|
||||
final error = apiProblem != null
|
||||
? mapApiProblemToMessage(apiProblem, l10n)
|
||||
: (service.lastError ?? l10n.paymentVerifyFailed);
|
||||
Toast.show(context, error, type: ToastType.error);
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.purchasing:
|
||||
case PurchaseFlowState.verifying:
|
||||
case PurchaseFlowState.idle:
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _refreshBalance() async {
|
||||
try {
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
final response = await apiClient.getJson('/api/v1/points/balance');
|
||||
final newBalance = response['availableBalance'] as int;
|
||||
widget.onBalanceChanged(newBalance);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.warning(
|
||||
message: 'Failed to refresh balance after purchase: $e',
|
||||
extra: {'stackTrace': stackTrace.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_iapService?.removeListener(_onPurchaseStateChanged);
|
||||
_iapService?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -120,13 +203,13 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SectionLabel(text: l10n.settingsCoinRechargeSection),
|
||||
..._buildPackageCards(l10n),
|
||||
..._buildPackageCards(l10n, colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n) {
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n, ColorScheme colors) {
|
||||
if (_isLoading) {
|
||||
return [
|
||||
const Padding(
|
||||
@@ -140,22 +223,66 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
return [];
|
||||
}
|
||||
|
||||
final isBusy = _iapService?.state == PurchaseFlowState.purchasing ||
|
||||
_iapService?.state == PurchaseFlowState.verifying;
|
||||
final isPending = _iapService?.state == PurchaseFlowState.purchasing;
|
||||
|
||||
return List.generate(_packages!.length, (index) {
|
||||
final pkg = _packages![index];
|
||||
final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
final isAvailable = storeKitProduct != null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: AppSpacing.md),
|
||||
if (isPending && !isBusy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Text(
|
||||
l10n.paymentPending,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
CoinPackageCard(
|
||||
title: _getPackageTitle(pkg, l10n),
|
||||
price: pkg.priceDisplay,
|
||||
price: _getDisplayPrice(pkg),
|
||||
amount: pkg.credits,
|
||||
badge: _getPackageBadge(pkg, l10n),
|
||||
onPurchase: () => _handlePurchase(pkg),
|
||||
isPurchasing: isBusy,
|
||||
isAvailable: isAvailable,
|
||||
unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _getDisplayPrice(PackageInfo pkg) {
|
||||
final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
if (product != null) {
|
||||
return product.price;
|
||||
}
|
||||
return pkg.priceDisplay;
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PackageInfo pkg) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_iapService == null) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isIOS) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
await _iapService!.purchase(pkg);
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
|
||||
Reference in New Issue
Block a user