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:
ZL-Q
2026-04-28 10:45:29 +08:00
parent b453ff7345
commit 87f92987b2
58 changed files with 3741 additions and 336 deletions
+134
View File
@@ -0,0 +1,134 @@
{
"identifier" : "EryaoProducts",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "new_user_pack_001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "新用户专属优惠套餐",
"displayName" : "新手包",
"locale" : "zh_CN"
}
],
"productID" : "com.meeyao.qianwen.new_user_pack",
"referenceName" : "新手包",
"type" : "Consumable"
},
{
"displayPrice" : "6.00",
"familyShareable" : false,
"internalID" : "basic_pack_001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "基础信用点套餐",
"displayName" : "基础包",
"locale" : "zh_CN"
}
],
"productID" : "com.meeyao.qianwen.basic_pack",
"referenceName" : "基础包",
"type" : "Consumable"
},
{
"displayPrice" : "18.00",
"familyShareable" : false,
"internalID" : "popular_pack_001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "热门信用点套餐",
"displayName" : "热门包",
"locale" : "zh_CN"
}
],
"productID" : "com.meeyao.qianwen.popular_pack",
"referenceName" : "热门包",
"type" : "Consumable"
},
{
"displayPrice" : "68.00",
"familyShareable" : false,
"internalID" : "premium_pack_001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "高级信用点套餐",
"displayName" : "高级包",
"locale" : "zh_CN"
}
],
"productID" : "com.meeyao.qianwen.premium_pack",
"referenceName" : "高级包",
"type" : "Consumable"
}
],
"settings" : {
"_applicationInternalID" : "6738123456",
"_developerTeamID" : "YOUR_TEAM_ID",
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 756460800,
"_locale" : "zh_CN",
"_storefront" : "CHN",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
],
"version" : {
"major" : 4,
"minor" : 0
}
}
+11
View File
@@ -412,6 +412,15 @@ class _EryaoAppState extends State<EryaoApp> {
});
}
void _handleBalanceChanged(int newBalance) {
if (!mounted) {
return;
}
setState(() {
_creditsBalance = newBalance;
});
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -451,6 +460,7 @@ class _EryaoAppState extends State<EryaoApp> {
_refreshProfile(userEmail: state.user!.email);
return HomeScreen(
account: state.user!.email,
userId: state.user!.id,
sessionStore: _sessionStore,
currentLocale: _locale,
profileSettings: _profileSettings,
@@ -467,6 +477,7 @@ class _EryaoAppState extends State<EryaoApp> {
onDeleteHistorySession: _handleHistorySessionDeleted,
onLogout: _authBloc.logout,
onDeleteAccount: _deleteAccount,
onBalanceChanged: _handleBalanceChanged,
);
}
+3
View File
@@ -10,6 +10,9 @@ class Env {
if (Platform.isAndroid) {
return 'http://10.0.2.2:5775';
}
if (Platform.isIOS) {
return 'http://192.168.1.63:5775';
}
return 'http://localhost:5775';
}
@@ -31,6 +31,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
return l10n.errorAsrUnavailable;
case 'PROFILE_DELETE_FAILED':
return l10n.errorProfileDeleteFailed;
case 'PAYMENT_PRODUCT_NOT_FOUND':
return l10n.paymentProductNotFound;
case 'PAYMENT_PRODUCT_MISMATCH':
return l10n.paymentVerifyFailed;
case 'PAYMENT_ENVIRONMENT_MISMATCH':
return l10n.paymentVerifyFailed;
case 'PAYMENT_TRANSACTION_INVALID':
return l10n.paymentVerifyFailed;
case 'PAYMENT_TRANSACTION_REVOKED':
return l10n.paymentVerifyFailed;
case 'PAYMENT_TRANSACTION_CONFLICT':
return l10n.paymentVerifyFailed;
case 'PAYMENT_STARTER_PACK_INELIGIBLE':
return l10n.paymentStarterPackIneligible;
default:
break;
}
@@ -169,7 +169,7 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
final palette = Theme.of(context).extension<AppColorPalette>()!;
final l10n = AppLocalizations.of(context)!;
return PopScope<void>(
canPop: false,
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
@@ -27,6 +27,7 @@ class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.account,
required this.userId,
required this.sessionStore,
required this.currentLocale,
required this.profileSettings,
@@ -43,9 +44,11 @@ class HomeScreen extends StatefulWidget {
required this.onDeleteHistorySession,
required this.onLogout,
required this.onDeleteAccount,
required this.onBalanceChanged,
});
final String account;
final String userId;
final SessionStore sessionStore;
final Locale currentLocale;
final ProfileSettingsV1 profileSettings;
@@ -56,15 +59,16 @@ class HomeScreen extends StatefulWidget {
final NotificationRepository notificationRepository;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
onProfileSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function(DivinationResultData result)
onDivinationCompleted;
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final void Function(int newBalance) onBalanceChanged;
@override
State<HomeScreen> createState() => _HomeScreenState();
@@ -132,6 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
_ProfileTab(
account: widget.account,
userId: widget.userId,
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
inviteRepository: _inviteRepository,
@@ -142,6 +147,7 @@ class _HomeScreenState extends State<HomeScreen> {
onUploadAvatar: widget.onUploadAvatar,
onLogout: widget.onLogout,
onDeleteAccount: widget.onDeleteAccount,
onBalanceChanged: widget.onBalanceChanged,
),
],
),
@@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
class _ProfileTab extends StatelessWidget {
const _ProfileTab({
required this.account,
required this.userId,
required this.settings,
required this.coinBalance,
required this.inviteRepository,
@@ -571,9 +578,11 @@ class _ProfileTab extends StatelessWidget {
required this.onUploadAvatar,
required this.onLogout,
required this.onDeleteAccount,
required this.onBalanceChanged,
});
final String account;
final String userId;
final ProfileSettingsV1 settings;
final int coinBalance;
final InviteRepository inviteRepository;
@@ -581,15 +590,17 @@ class _ProfileTab extends StatelessWidget {
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
onSaveProfile;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final void Function(int newBalance) onBalanceChanged;
@override
Widget build(BuildContext context) {
return SettingsScreen(
account: account,
userId: userId,
settings: settings,
coinBalance: coinBalance,
inviteRepository: inviteRepository,
@@ -600,6 +611,7 @@ class _ProfileTab extends StatelessWidget {
onUploadAvatar: onUploadAvatar,
onLogout: onLogout,
onDeleteAccount: onDeleteAccount,
onBalanceChanged: onBalanceChanged,
);
}
}
@@ -703,14 +715,23 @@ class _WelcomeDialog extends StatefulWidget {
class _WelcomeDialogState extends State<_WelcomeDialog> {
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToBottom = false;
bool _hasCheckedInitialScroll = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScrollState();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_hasCheckedInitialScroll) {
_hasCheckedInitialScroll = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScrollState();
});
}
}
@override
@@ -730,7 +751,7 @@ class _WelcomeDialogState extends State<_WelcomeDialog> {
}
final max = _scrollController.position.maxScrollExtent;
final current = _scrollController.offset;
final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md;
final canReadAll = max <= 50.0 || current >= max - AppSpacing.md;
if (_hasScrolledToBottom == canReadAll) {
return;
}
@@ -0,0 +1,18 @@
import '../../../../data/network/api_client.dart';
import '../models/apple_purchase_models.dart';
class ApplePaymentApi {
const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<VerifyTransactionResponse> verifyTransaction(
VerifyTransactionRequest request,
) async {
final json = await _apiClient.postJson(
'/api/v1/payments/apple/transactions/verify',
data: request.toJson(),
);
return VerifyTransactionResponse.fromJson(json);
}
}
@@ -0,0 +1,56 @@
class VerifyTransactionRequest {
const VerifyTransactionRequest({
required this.productCode,
required this.appStoreProductId,
required this.transactionId,
required this.signedTransactionInfo,
this.appAccountToken,
});
final String productCode;
final String appStoreProductId;
final String transactionId;
final String signedTransactionInfo;
final String? appAccountToken;
Map<String, dynamic> toJson() => {
'productCode': productCode,
'appStoreProductId': appStoreProductId,
'transactionId': transactionId,
'signedTransactionInfo': signedTransactionInfo,
if (appAccountToken != null) 'appAccountToken': appAccountToken,
};
}
enum VerifyTransactionStatus { granted, alreadyGranted }
class VerifyTransactionResponse {
const VerifyTransactionResponse({
required this.status,
required this.productCode,
required this.transactionId,
required this.creditsAdded,
required this.newBalance,
required this.ledgerEventId,
});
final VerifyTransactionStatus status;
final String productCode;
final String transactionId;
final int creditsAdded;
final int newBalance;
final String ledgerEventId;
factory VerifyTransactionResponse.fromJson(Map<String, dynamic> json) {
return VerifyTransactionResponse(
status: json['status'] == 'already_granted'
? VerifyTransactionStatus.alreadyGranted
: VerifyTransactionStatus.granted,
productCode: json['productCode'] as String,
transactionId: json['transactionId'] as String,
creditsAdded: json['creditsAdded'] as int,
newBalance: json['newBalance'] as int,
ledgerEventId: json['ledgerEventId'] as String,
);
}
}
@@ -0,0 +1,255 @@
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../../../points/data/models/package_info.dart';
import '../apis/apple_payment_api.dart';
import '../models/apple_purchase_models.dart';
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
class PurchaseResult {
const PurchaseResult({
required this.state,
this.creditsAdded,
this.errorMessage,
});
final PurchaseFlowState state;
final int? creditsAdded;
final String? errorMessage;
}
class AppleIapService with ChangeNotifier {
AppleIapService({
required ApiClient apiClient,
required String userId,
}) : _paymentApi = ApplePaymentApi(apiClient: apiClient),
_inAppPurchase = InAppPurchase.instance,
_appAccountToken = _hashUserId(userId);
final ApplePaymentApi _paymentApi;
final InAppPurchase _inAppPurchase;
final String? _appAccountToken;
final Logger _logger = getLogger('features.payments.apple_iap_service');
static String? _hashUserId(String userId) {
if (userId.isEmpty) return null;
final bytes = utf8.encode(userId);
final digest = md5.convert(bytes);
return digest.toString();
}
StreamSubscription<List<PurchaseDetails>>? _subscription;
Map<String, ProductDetails> _storeKitProducts = {};
PurchaseFlowState _state = PurchaseFlowState.idle;
String? _lastError;
ApiProblem? _lastApiProblem;
PurchaseFlowState get state => _state;
String? get lastError => _lastError;
ApiProblem? get lastApiProblem => _lastApiProblem;
void init() {
final Stream<List<PurchaseDetails>> purchaseUpdated;
purchaseUpdated = _inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
}
Future<void> loadStoreKitProducts(List<PackageInfo> packages) async {
final ids = packages
.map((p) => p.appStoreProductId)
.where((id) => id.isNotEmpty)
.toSet();
if (ids.isEmpty) return;
final response = await _inAppPurchase.queryProductDetails(ids);
if (response.notFoundIDs.isNotEmpty) {
_logger.warning(
message: 'Some StoreKit products not found',
extra: {'notFound': response.notFoundIDs.join(', ')},
);
}
final products = <String, ProductDetails>{};
for (final detail in response.productDetails) {
products[detail.id] = detail;
}
_storeKitProducts = products;
}
ProductDetails? getStoreKitProduct(String appStoreProductId) {
return _storeKitProducts[appStoreProductId];
}
Future<bool> purchase(PackageInfo package) async {
if (_state == PurchaseFlowState.purchasing ||
_state == PurchaseFlowState.verifying) {
return false;
}
final product = _storeKitProducts[package.appStoreProductId];
if (product == null) {
_logger.warning(
message: 'StoreKit product not found for purchase',
extra: {'productId': package.appStoreProductId},
);
_setError('Product not available');
return false;
}
_setState(PurchaseFlowState.purchasing);
final purchaseParam = PurchaseParam(
productDetails: product,
applicationUserName: _appAccountToken,
);
final bought = await _inAppPurchase.buyConsumable(
purchaseParam: purchaseParam,
);
if (!bought) {
_setError('Failed to initiate purchase');
return false;
}
return true;
}
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
_handlePurchase(purchase);
}
}
Future<void> _handlePurchase(PurchaseDetails purchase) async {
switch (purchase.status) {
case PurchaseStatus.purchased:
await _verifyAndComplete(purchase);
case PurchaseStatus.canceled:
await _inAppPurchase.completePurchase(purchase);
_setState(PurchaseFlowState.idle);
case PurchaseStatus.error:
await _inAppPurchase.completePurchase(purchase);
_setError(purchase.error?.message ?? 'Purchase failed');
case PurchaseStatus.pending:
_setState(PurchaseFlowState.purchasing);
case PurchaseStatus.restored:
await _inAppPurchase.completePurchase(purchase);
}
}
Future<void> _verifyAndComplete(PurchaseDetails purchase) async {
_setState(PurchaseFlowState.verifying);
try {
final request = VerifyTransactionRequest(
productCode: _productCodeFromStoreKitId(purchase.productID),
appStoreProductId: purchase.productID,
transactionId: purchase.purchaseID ?? '',
signedTransactionInfo: purchase.verificationData.serverVerificationData,
appAccountToken: _appAccountToken,
);
final response = await _paymentApi.verifyTransaction(request);
await _inAppPurchase.completePurchase(purchase);
_state = PurchaseFlowState.success;
_lastError = null;
_lastApiProblem = null;
_logger.info(
message: 'Purchase verified and completed',
extra: {
'transactionId': purchase.purchaseID,
'creditsAdded': response.creditsAdded,
'status': response.status.name,
},
);
notifyListeners();
} catch (e, stackTrace) {
_logger.error(
message: 'Purchase verification failed',
error: e,
stackTrace: stackTrace,
extra: {'transactionId': purchase.purchaseID},
);
if (_isRetryableError(e)) {
_setState(PurchaseFlowState.idle);
return;
}
await _inAppPurchase.completePurchase(purchase);
if (e is ApiProblem) {
_lastApiProblem = e;
_lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed';
} else {
_lastApiProblem = null;
_lastError = _extractErrorMessage(e);
}
_state = PurchaseFlowState.failed;
notifyListeners();
}
}
bool _isRetryableError(Object error) {
if (error is ApiProblem) {
return error.status >= 500 || error.status == 0;
}
return true;
}
String _extractErrorMessage(Object error) {
if (error is ApiProblem) {
return error.detail.isNotEmpty ? error.detail : 'Verification failed';
}
return 'Verification failed';
}
String _productCodeFromStoreKitId(String storeKitId) {
return switch (storeKitId) {
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
'com.meeyao.qianwen.basic_pack' => 'basic_pack',
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
_ => storeKitId,
};
}
void _setState(PurchaseFlowState state) {
_state = state;
if (state != PurchaseFlowState.failed) {
_lastError = null;
_lastApiProblem = null;
}
notifyListeners();
}
void _setError(String message) {
_state = PurchaseFlowState.failed;
_lastError = message;
_lastApiProblem = null;
_logger.warning(message: 'Purchase flow error', extra: {'error': message});
notifyListeners();
}
void resetState() {
_state = PurchaseFlowState.idle;
_lastError = null;
_lastApiProblem = null;
notifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
@@ -5,6 +5,7 @@ enum PackageType { starter, regular }
class PackageInfo {
const PackageInfo({
required this.productCode,
required this.appStoreProductId,
required this.type,
required this.price,
required this.credits,
@@ -14,6 +15,7 @@ class PackageInfo {
});
final ProductCode productCode;
final String appStoreProductId;
final PackageType type;
final double price;
final int credits;
@@ -24,6 +26,7 @@ class PackageInfo {
factory PackageInfo.fromJson(Map<String, dynamic> json) {
return PackageInfo(
productCode: _parseProductCode(json['productCode'] as String),
appStoreProductId: json['appStoreProductId'] as String,
type: json['type'] == 'starter'
? PackageType.starter
: PackageType.regular,
@@ -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;
@@ -34,117 +34,108 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return PopScope<ProfileSettingsV1>(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
Navigator.of(context).pop(_settings);
},
child: Scaffold(
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
title: Text(l10n.settingsGeneralTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
leading: IconButton(
onPressed: () => Navigator.of(context).pop(_settings),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
SectionLabel(text: l10n.settingsSectionGeneral),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.language_rounded,
title: l10n.settingsInterfaceLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.interfaceLanguage,
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: () => _selectLanguage(
_settings.preferences.interfaceLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
interfaceLanguage: lang,
),
);
}),
),
),
SettingsMenuTile(
icon: Icons.smart_toy_rounded,
title: l10n.settingsAiLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => _selectLanguage(
_settings.preferences.aiLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
aiLanguage: lang,
),
);
}),
),
),
],
),
title: Text(l10n.settingsGeneralTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
SectionLabel(text: l10n.settingsSectionGeneral),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.language_rounded,
title: l10n.settingsInterfaceLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.interfaceLanguage,
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: () => _selectLanguage(
_settings.preferences.interfaceLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
interfaceLanguage: lang,
),
);
}),
),
),
SettingsMenuTile(
icon: Icons.smart_toy_rounded,
title: l10n.settingsAiLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => _selectLanguage(
_settings.preferences.aiLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
aiLanguage: lang,
),
);
}),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
SectionLabel(text: l10n.settingsSectionPrivacy),
SettingsGroupCard(
children: [
SettingsSwitchTile(
icon: Icons.security_rounded,
title: l10n.settingsDoNotSellTitle,
value: _settings.privacy.canSell,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onChanged: (value) => _updatePrivacy(canSell: value),
),
],
),
const SizedBox(height: AppSpacing.lg),
SectionLabel(text: l10n.settingsSectionNotification),
SettingsGroupCard(
children: [
SettingsSwitchTile(
icon: Icons.notifications_rounded,
title: l10n.settingsNotificationAllow,
value: _settings.notification.allowNotifications,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onChanged: (value) =>
_updateNotification(allowNotifications: value),
),
SettingsSwitchTile(
icon: Icons.vibration_rounded,
title: l10n.settingsNotificationVibration,
value: _settings.notification.allowVibration,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onChanged: (value) =>
_updateNotification(allowVibration: value),
),
],
),
],
),
const SizedBox(height: AppSpacing.lg),
SectionLabel(text: l10n.settingsSectionPrivacy),
SettingsGroupCard(
children: [
SettingsSwitchTile(
icon: Icons.security_rounded,
title: l10n.settingsDoNotSellTitle,
value: _settings.privacy.canSell,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onChanged: (value) => _updatePrivacy(canSell: value),
),
],
),
const SizedBox(height: AppSpacing.lg),
SectionLabel(text: l10n.settingsSectionNotification),
SettingsGroupCard(
children: [
SettingsSwitchTile(
icon: Icons.notifications_rounded,
title: l10n.settingsNotificationAllow,
value: _settings.notification.allowNotifications,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onChanged: (value) =>
_updateNotification(allowNotifications: value),
),
SettingsSwitchTile(
icon: Icons.vibration_rounded,
title: l10n.settingsNotificationVibration,
value: _settings.notification.allowVibration,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onChanged: (value) =>
_updateNotification(allowVibration: value),
),
],
),
],
),
);
}
@@ -405,7 +405,7 @@ class _BindCodeSection extends StatelessWidget {
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isBinding ? null : onBind,
onPressed: null,
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(
@@ -415,16 +415,7 @@ class _BindCodeSection extends StatelessWidget {
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: isBinding
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(l10n.settingsInviteBindButton),
child: Text(l10n.settingsComingSoon),
),
),
],
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../models/legal_document_type.dart';
import '../utils/legal_document_assets.dart';
import '../widgets/settings_section_widgets.dart';
import 'legal_document_screen.dart';
class LegalCenterScreen extends StatelessWidget {
const LegalCenterScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final locale = Localizations.localeOf(context);
final documents = [
LegalDocumentType.aboutUs,
LegalDocumentType.privacyPolicy,
LegalDocumentType.termsOfService,
];
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.settingsLegalCenterTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
SectionLabel(text: l10n.settingsSectionAbout),
SettingsGroupCard(
children: [
for (int i = 0; i < documents.length; i++)
SettingsMenuTile(
icon: legalDocumentIcon(documents[i]),
title: legalDocumentTitle(l10n, documents[i]),
subtitle: legalDocumentSubtitle(l10n, documents[i]),
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: i != documents.length - 1,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentScreen(
title: legalDocumentTitle(l10n, documents[i]),
assetPath: legalDocumentAssetPath(locale, documents[i]),
),
),
),
),
],
),
],
),
);
}
}
@@ -7,19 +7,22 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart';
import '../../data/models/profile_settings.dart';
import '../../data/repositories/invite_repository.dart';
import '../models/legal_document_type.dart';
import '../utils/legal_document_assets.dart';
import 'account_delete_screen.dart';
import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart';
import 'feedback_screen.dart';
import 'general_settings_screen.dart';
import 'invite_screen.dart';
import 'legal_center_screen.dart';
import 'legal_document_screen.dart';
import 'profile_edit_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({
super.key,
required this.account,
required this.userId,
required this.settings,
required this.coinBalance,
required this.inviteRepository,
@@ -30,9 +33,11 @@ class SettingsScreen extends StatefulWidget {
required this.onLogout,
required this.onDeleteAccount,
required this.onSaveProfile,
required this.onBalanceChanged,
});
final String account;
final String userId;
final ProfileSettingsV1 settings;
final int coinBalance;
final InviteRepository inviteRepository;
@@ -43,7 +48,8 @@ class SettingsScreen extends StatefulWidget {
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
onSaveProfile;
final void Function(int newBalance) onBalanceChanged;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -119,35 +125,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
background: colors.surfaceContainerHighest,
onTap: _openInvite,
),
SettingsMenuTile(
icon: Icons.description_outlined,
title: l10n.settingsLegalCenterTitle,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: _openLegalCenter,
),
],
),
const SizedBox(height: AppSpacing.xl),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.feedback_outlined,
title: l10n.settingsFeedbackTitle,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => FeedbackScreen(apiClient: widget.apiClient),
),
),
),
],
),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.person_rounded,
title: l10n.settingsAccountAndDataTitle,
@@ -159,6 +147,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
const SizedBox(height: AppSpacing.xl),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.info_outline_rounded,
title: l10n.aboutUs,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
onTap: () => _openLegalDocument(LegalDocumentType.aboutUs),
),
SettingsMenuTile(
icon: Icons.security_rounded,
title: l10n.privacyPolicy,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy),
),
SettingsMenuTile(
icon: Icons.description_outlined,
title: l10n.termsOfService,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => _openLegalDocument(LegalDocumentType.termsOfService),
),
],
),
const SizedBox(height: AppSpacing.xl),
FilledButton(
onPressed: _confirmLogout,
style: FilledButton.styleFrom(
@@ -180,7 +195,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _openCoinCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => CoinCenterScreen(balance: widget.coinBalance),
builder: (_) => CoinCenterScreen(
balance: widget.coinBalance,
userId: widget.userId,
onBalanceChanged: widget.onBalanceChanged,
),
),
);
}
@@ -229,9 +248,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _openLegalCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
void _openLegalDocument(LegalDocumentType type) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentScreen(
title: legalDocumentTitle(l10n, type),
assetPath: legalDocumentAssetPath(locale, type),
),
),
);
}
@@ -3,8 +3,6 @@ import 'package:flutter/material.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';
class SectionLabel extends StatelessWidget {
const SectionLabel({super.key, required this.text});
@@ -420,12 +418,20 @@ class CoinPackageCard extends StatelessWidget {
required this.price,
required this.amount,
this.badge,
this.onPurchase,
this.isPurchasing = false,
this.isAvailable = true,
this.unavailableMessage,
});
final String title;
final String price;
final int amount;
final String? badge;
final VoidCallback? onPurchase;
final bool isPurchasing;
final bool isAvailable;
final String? unavailableMessage;
@override
Widget build(BuildContext context) {
@@ -483,32 +489,43 @@ class CoinPackageCard extends StatelessWidget {
],
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Text(
price,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.primary),
if (!isAvailable && unavailableMessage != null)
Text(
unavailableMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.error,
),
const Spacer(),
FilledButton(
onPressed: () {
Toast.show(
)
else
Row(
children: [
Text(
price,
style: Theme.of(
context,
l10n.settingsPurchasePending,
type: ToastType.info,
);
},
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
).textTheme.headlineMedium?.copyWith(color: colors.primary),
),
child: Text(l10n.settingsPurchaseButton),
),
],
),
const Spacer(),
FilledButton(
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: isPurchasing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Text(l10n.settingsPurchaseButton),
),
],
),
],
),
),
+7 -1
View File
@@ -505,5 +505,11 @@
"feedbackContentRequired": "Please enter feedback content",
"feedbackContentTooLong": "Feedback content cannot exceed 500 characters",
"feedbackTooManyImages": "Maximum 3 images allowed",
"feedbackImageTooLarge": "Image size cannot exceed 5MB"
"feedbackImageTooLarge": "Image size cannot exceed 5MB",
"paymentSuccess": "Purchase successful",
"paymentVerifyFailed": "Purchase verification failed, please try again later",
"paymentProductNotFound": "Product temporarily unavailable",
"paymentStarterPackIneligible": "Starter pack is limited to one purchase per user",
"paymentProductUnavailable": "Product temporarily unavailable",
"paymentPending": "Apple is processing, please wait"
}
+36
View File
@@ -2432,6 +2432,42 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'图片大小不能超过5MB'**
String get feedbackImageTooLarge;
/// No description provided for @paymentSuccess.
///
/// In zh, this message translates to:
/// **'购买成功'**
String get paymentSuccess;
/// No description provided for @paymentVerifyFailed.
///
/// In zh, this message translates to:
/// **'购买验证失败,请稍后重试'**
String get paymentVerifyFailed;
/// No description provided for @paymentProductNotFound.
///
/// In zh, this message translates to:
/// **'商品暂时不可用'**
String get paymentProductNotFound;
/// No description provided for @paymentStarterPackIneligible.
///
/// In zh, this message translates to:
/// **'新手包每位用户仅限购买一次'**
String get paymentStarterPackIneligible;
/// No description provided for @paymentProductUnavailable.
///
/// In zh, this message translates to:
/// **'商品暂时不可用'**
String get paymentProductUnavailable;
/// No description provided for @paymentPending.
///
/// In zh, this message translates to:
/// **'Apple 正在处理中,请稍候'**
String get paymentPending;
}
class _AppLocalizationsDelegate
+20
View File
@@ -1282,4 +1282,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get feedbackImageTooLarge => 'Image size cannot exceed 5MB';
@override
String get paymentSuccess => 'Purchase successful';
@override
String get paymentVerifyFailed =>
'Purchase verification failed, please try again later';
@override
String get paymentProductNotFound => 'Product temporarily unavailable';
@override
String get paymentStarterPackIneligible =>
'Starter pack is limited to one purchase per user';
@override
String get paymentProductUnavailable => 'Product temporarily unavailable';
@override
String get paymentPending => 'Apple is processing, please wait';
}
+36
View File
@@ -1225,6 +1225,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get feedbackImageTooLarge => '图片大小不能超过5MB';
@override
String get paymentSuccess => '购买成功';
@override
String get paymentVerifyFailed => '购买验证失败,请稍后重试';
@override
String get paymentProductNotFound => '商品暂时不可用';
@override
String get paymentStarterPackIneligible => '新手包每位用户仅限购买一次';
@override
String get paymentProductUnavailable => '商品暂时不可用';
@override
String get paymentPending => 'Apple 正在处理中,请稍候';
}
/// The translations for Chinese, using the Han script (`zh_Hant`).
@@ -2204,4 +2222,22 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get feedbackImageTooLarge => '圖片大小不能超過5MB';
@override
String get paymentSuccess => '購買成功';
@override
String get paymentVerifyFailed => '購買驗證失敗,請稍後重試';
@override
String get paymentProductNotFound => '商品暫時不可用';
@override
String get paymentStarterPackIneligible => '新手包每位用戶僅限購買一次';
@override
String get paymentProductUnavailable => '商品暫時不可用';
@override
String get paymentPending => 'Apple 正在處理中,請稍候';
}
+7 -1
View File
@@ -505,5 +505,11 @@
"feedbackContentRequired": "请输入反馈内容",
"feedbackContentTooLong": "反馈内容不能超过500字",
"feedbackTooManyImages": "最多只能上传3张图片",
"feedbackImageTooLarge": "图片大小不能超过5MB"
"feedbackImageTooLarge": "图片大小不能超过5MB",
"paymentSuccess": "购买成功",
"paymentVerifyFailed": "购买验证失败,请稍后重试",
"paymentProductNotFound": "商品暂时不可用",
"paymentStarterPackIneligible": "新手包每位用户仅限购买一次",
"paymentProductUnavailable": "商品暂时不可用",
"paymentPending": "Apple 正在处理中,请稍候"
}
+7 -1
View File
@@ -407,5 +407,11 @@
"feedbackContentRequired": "請輸入回饋內容",
"feedbackContentTooLong": "回饋內容不能超過500字",
"feedbackTooManyImages": "最多只能上傳3張圖片",
"feedbackImageTooLarge": "圖片大小不能超過5MB"
"feedbackImageTooLarge": "圖片大小不能超過5MB",
"paymentSuccess": "購買成功",
"paymentVerifyFailed": "購買驗證失敗,請稍後重試",
"paymentProductNotFound": "商品暫時不可用",
"paymentStarterPackIneligible": "新手包每位用戶僅限購買一次",
"paymentProductUnavailable": "商品暫時不可用",
"paymentPending": "Apple 正在處理中,請稍候"
}
+3
View File
@@ -49,6 +49,9 @@ dependencies:
cupertino_icons: ^1.0.8
device_info_plus: ^12.4.0
package_info_plus: ^9.0.1
in_app_purchase: ^3.2.3
in_app_purchase_storekit: ^0.4.8
crypto: ^3.0.7
dev_dependencies:
flutter_test:
@@ -0,0 +1,75 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/payments/data/models/apple_purchase_models.dart';
void main() {
group('VerifyTransactionRequest', () {
test('toJson includes all required fields', () {
const request = VerifyTransactionRequest(
productCode: 'basic_pack',
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
);
final json = request.toJson();
expect(json['productCode'], 'basic_pack');
expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack');
expect(json['transactionId'], '1000000123456789');
expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...');
expect(json.containsKey('appAccountToken'), false);
});
test('toJson includes appAccountToken when provided', () {
const request = VerifyTransactionRequest(
productCode: 'basic_pack',
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
appAccountToken: 'abc123def456',
);
final json = request.toJson();
expect(json['appAccountToken'], 'abc123def456');
});
});
group('VerifyTransactionResponse', () {
test('parses granted status correctly', () {
final json = {
'status': 'granted',
'productCode': 'basic_pack',
'transactionId': '1000000123456789',
'creditsAdded': 100,
'newBalance': 180,
'ledgerEventId': 'payment.apple_iap:1000000123456789',
};
final response = VerifyTransactionResponse.fromJson(json);
expect(response.status, VerifyTransactionStatus.granted);
expect(response.productCode, 'basic_pack');
expect(response.transactionId, '1000000123456789');
expect(response.creditsAdded, 100);
expect(response.newBalance, 180);
expect(response.ledgerEventId, 'payment.apple_iap:1000000123456789');
});
test('parses already_granted status correctly', () {
final json = {
'status': 'already_granted',
'productCode': 'basic_pack',
'transactionId': '1000000123456789',
'creditsAdded': 0,
'newBalance': 180,
'ledgerEventId': 'payment.apple_iap:1000000123456789',
};
final response = VerifyTransactionResponse.fromJson(json);
expect(response.status, VerifyTransactionStatus.alreadyGranted);
expect(response.creditsAdded, 0);
});
});
}