import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import '../../core/adjust/adjust_events.dart'; import '../../core/api/api_config.dart'; import '../../core/api/services/payment_api.dart'; import '../../core/auth/auth_service.dart'; import '../../core/user/account_refresh.dart'; import '../../core/log/app_logger.dart'; import '../../core/theme/app_colors.dart'; import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; import '../../shared/widgets/top_nav_bar.dart'; import 'google_play_purchase_service.dart'; import 'models/activity_item.dart'; import 'models/payment_method_item.dart'; import 'payment_webview_screen.dart'; /// Recharge screen - matches Pencil tPjdN /// Tier cards (DC6NS, YRaOG, QlNz6) 对接 getGooglePayActivities / getApplePayActivities class RechargeScreen extends StatefulWidget { static final _log = AppLogger('Recharge'); const RechargeScreen({super.key}); @override State createState() => _RechargeScreenState(); } class _RechargeScreenState extends State with WidgetsBindingObserver { List _activities = []; bool _loadingTiers = true; String? _tierError; /// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading String? _loadingProductId; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); refreshAccount(); _fetchActivities(); // 进入充值页时执行补单,处理未核销订单 GooglePlayPurchaseService.runOrderRecovery(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) { setState(() => _loadingProductId = null); } } Future _fetchActivities() async { setState(() { _loadingTiers = true; _tierError = null; }); try { await AuthService.loginComplete; final country = UserState.navigate.value ?? ''; final res = defaultTargetPlatform == TargetPlatform.iOS ? await PaymentApi.getApplePayActivities( vambrace: country.isEmpty ? null : country, ) : await PaymentApi.getGooglePayActivities( vambrace: country.isEmpty ? null : country, ); if (!mounted) return; if (res.isSuccess && res.data != null) { final data = res.data as Map?; final summon = data?['summon'] as List? ?? []; final list = summon .whereType>() .map((e) => ActivityItem.fromJson(e)) .where((e) => e.code.isNotEmpty) .toList(); setState(() { _activities = list; _loadingTiers = false; }); } else { setState(() { _activities = []; _loadingTiers = false; _tierError = res.msg.isNotEmpty ? res.msg : 'Failed to load'; }); } } catch (e) { if (mounted) { setState(() { _activities = []; _loadingTiers = false; _tierError = e.toString(); }); } } } Future _onBuy(ActivityItem item) async { if (_loadingProductId != null) return; setState(() => _loadingProductId = item.code); final price = AdjustEvents.parsePrice(item.actualAmount); if (price != null) { final tierToken = AdjustEvents.tierTokenFromPrice(price); if (tierToken != null) { AdjustEvents.trackTier(tierToken); } } final titleLower = item.title.toLowerCase(); if (titleLower.contains('monthly vip')) { AdjustEvents.trackMonthlyVip(); } else if (titleLower.contains('weekly vip')) { AdjustEvents.trackWeeklyVip(); } final useThirdParty = UserState.enableThirdPartyPayment.value == true; final uid = UserState.userId.value; if (useThirdParty && uid != null && uid.isNotEmpty) { await _runThirdPartyPayment(item, uid); } else { await _runGooglePay(item); } } /// 第三方支付流程:获取支付方式 -> 弹窗选择 -> 创建订单 -> 打开支付页 Future _runThirdPartyPayment(ActivityItem item, String userId) async { final activityId = item.activityId; if (activityId == null || activityId.isEmpty) { if (mounted) { _showSnackBar(context, 'Invalid product', isError: true); } AdjustEvents.trackPaymentFailed(); return; } if (!mounted) return; try { final country = UserState.navigate.value; final methodsRes = await PaymentApi.getPaymentMethods( warrior: activityId, vambrace: country?.isEmpty == true ? null : country, ); if (!mounted) return; if (!methodsRes.isSuccess || methodsRes.data == null) { _showSnackBar( context, methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods', isError: true, ); AdjustEvents.trackPaymentFailed(); return; } final methodsList = methodsRes.data is Map ? (methodsRes.data! as Map)['renew'] as List? : null; if (methodsList == null || methodsList.isEmpty) { _showSnackBar(context, 'No payment methods available', isError: true); AdjustEvents.trackPaymentFailed(); return; } final methods = methodsList .whereType>() .map((e) => PaymentMethodItem.fromJson(e)) .where((m) => m.paymentMethod.isNotEmpty) .toList(); if (methods.isEmpty) { _showSnackBar(context, 'No payment methods available', isError: true); AdjustEvents.trackPaymentFailed(); return; } if (!mounted) return; final selected = await showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (ctx) => _PaymentMethodSheet(methods: methods), ); if (!mounted) return; if (selected == null) { setState(() => _loadingProductId = null); return; } await _createOrderAndOpenUrl( userId: userId, item: item, paymentMethod: selected.paymentMethod, subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod, ); } catch (e) { if (mounted) { _showSnackBar(context, 'Payment error: ${e.toString()}', isError: true); } AdjustEvents.trackPaymentFailed(); } finally { if (mounted) { setState(() => _loadingProductId = null); } } } /// 创建订单;若为 Google Pay 则走统一入口 [_launchGooglePlayPurchase],否则打开支付链接 Future _createOrderAndOpenUrl({ required String userId, required ActivityItem item, required String paymentMethod, String? subPaymentMethod, }) async { if (!mounted) return; try { final activityId = item.activityId ?? ''; final createRes = await PaymentApi.createPayment( sentinel: ApiConfig.appId, asset: userId, warrior: activityId, resource: paymentMethod, ceremony: subPaymentMethod, ); if (!mounted) return; if (!createRes.isSuccess) { _showSnackBar( context, createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order', isError: true, ); AdjustEvents.trackPaymentFailed(); return; } final data = createRes.data is Map ? createRes.data as Map : null; final orderId = data?['federation']?.toString(); if (_isGooglePay(paymentMethod, subPaymentMethod)) { await _launchGooglePlayPurchase(item, serverOrderId: orderId, userId: userId); return; } final payUrl = data?['convert']?.toString(); if (payUrl != null && payUrl.isNotEmpty) { if (mounted) { setState(() => _loadingProductId = null); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl), ), ); _showSnackBar( context, 'Order created. Complete payment in the page.'); AdjustEvents.trackPurchaseSuccess(); } } else { if (mounted) { setState(() => _loadingProductId = null); _showSnackBar( context, 'Order created. Awaiting payment confirmation.'); } AdjustEvents.trackPurchaseSuccess(); } } catch (e) { if (mounted) { _showSnackBar(context, 'Payment error: ${e.toString()}', isError: true); } AdjustEvents.trackPaymentFailed(); } finally { if (mounted) { setState(() => _loadingProductId = null); } } } /// ceremony==GooglePay 或 resource==GOOGLEPAY 时走谷歌内购并上报 static bool _isGooglePay(String paymentMethod, String? subPaymentMethod) { final r = paymentMethod.trim().toLowerCase(); final c = (subPaymentMethod ?? '').trim().toLowerCase(); return r == 'googlepay' || c == 'googlepay'; } /// 三方为 false 时走谷歌应用内支付:先创建订单再调起内购(与第三方选 Google Pay 一致) Future _runGooglePay(ActivityItem item) async { if (defaultTargetPlatform != TargetPlatform.android) { _showSnackBar(context, 'Google Pay is only available on Android.', isError: true); AdjustEvents.trackPaymentFailed(); return; } final userId = UserState.userId.value; if (userId == null || userId.isEmpty) { _showSnackBar(context, 'Please sign in to continue.', isError: true); AdjustEvents.trackPaymentFailed(); return; } final activityId = item.activityId; if (activityId == null || activityId.isEmpty) { if (mounted) _showSnackBar(context, 'Invalid product', isError: true); AdjustEvents.trackPaymentFailed(); return; } try { final createRes = await PaymentApi.createPayment( sentinel: ApiConfig.appId, asset: userId, warrior: activityId, resource: 'GooglePay', ceremony: 'GooglePay', ); if (!mounted) return; if (!createRes.isSuccess) { _showSnackBar( context, createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order', isError: true, ); AdjustEvents.trackPaymentFailed(); return; } final data = createRes.data is Map ? createRes.data as Map : null; final orderId = data?['federation']?.toString(); await _launchGooglePlayPurchase(item, serverOrderId: orderId, userId: userId); } catch (e) { if (mounted) { _showSnackBar(context, 'Payment error: ${e.toString()}', isError: true); } AdjustEvents.trackPaymentFailed(); } finally { if (mounted) setState(() => _loadingProductId = null); } } /// 谷歌内购统一入口:调起内购 → 回调 googlepay → 成功后再核销并刷新账户。 /// [serverOrderId] 有值时(来自 createPayment)用作 federation,否则用 Google orderId。 Future _launchGooglePlayPurchase(ActivityItem item, {String? serverOrderId, String? userId}) async { if (!mounted) return; try { if (mounted) setState(() => _loadingProductId = null); final result = await GooglePlayPurchaseService.launchPurchaseAndReturnData( item.code); if (!mounted) return; if (result == null) { _showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); AdjustEvents.trackPaymentFailed(); return; } final uid = userId ?? UserState.userId.value; if (uid == null || uid.isEmpty) { _showSnackBar(context, 'Please sign in to confirm your purchase.', isError: true); AdjustEvents.trackPaymentFailed(); return; } final federation = (serverOrderId != null && serverOrderId.isNotEmpty) ? serverOrderId : result.orderId; if (serverOrderId != null && serverOrderId.isNotEmpty) { await GooglePlayPurchaseService.saveFederationForGoogleOrderId( result.orderId, serverOrderId); } RechargeScreen._log.d('googlepay 入参: federation=$federation'); final googlepayRes = await PaymentApi.googlepay( sample: result.payload.signature, merchant: result.payload.purchaseData, federation: federation, asset: uid, ); if (!mounted) return; if (googlepayRes.isSuccess) { final resData = googlepayRes.data is Map ? googlepayRes.data as Map : null; final line = (resData?['line']?.toString() ?? '').toUpperCase(); if (line == 'SUCCESS') { await GooglePlayPurchaseService.completeAndConsumePurchase( result.purchaseDetails); if (serverOrderId != null && serverOrderId.isNotEmpty) { await GooglePlayPurchaseService.removeFederationForGoogleOrderId( result.orderId); } await refreshAccount(); } if (mounted) { _showSnackBar(context, 'Purchase completed.'); } AdjustEvents.trackPurchaseSuccess(); } else { _showSnackBar( context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true); AdjustEvents.trackPaymentFailed(); } } catch (e) { if (mounted) { _showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true); } AdjustEvents.trackPaymentFailed(); } finally { if (mounted) { setState(() => _loadingProductId = null); } } } void _showSnackBar(BuildContext context, String message, {bool isError = false}) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: isError ? Colors.red.shade700 : null, ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, appBar: PreferredSize( preferredSize: const Size.fromHeight(56), child: TopNavBar( title: 'Recharge', showBackButton: true, onBack: _loadingProductId != null ? () {} : () => Navigator.of(context).pop(), ), ), body: Stack( children: [ SingleChildScrollView( padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _CreditsSection( currentCredits: UserCreditsData.of(context)?.creditsDisplay ?? '--', ), Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.screenPaddingLarge, vertical: AppSpacing.xxl, ), child: Text( 'Select Tier', style: AppTypography.bodyMedium.copyWith( color: AppColors.textPrimary, ), ), ), if (_loadingTiers) const Padding( padding: EdgeInsets.all(AppSpacing.xxl), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (_tierError != null) Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.screenPadding), child: Column( children: [ Text( _tierError!, style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), ), const SizedBox(height: AppSpacing.md), TextButton( onPressed: _fetchActivities, child: const Text('Retry'), ), ], ), ) else if (_activities.isEmpty) Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.screenPadding), child: Text( 'No packages available', style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), ), ) else ...List.generate(_activities.length, (i) { final item = _activities[i]; final isRecommended = false; final isPopular = false; return Padding( padding: EdgeInsets.only( bottom: i < _activities.length - 1 ? AppSpacing.xl : 0, ), child: _TierCardFromActivity( item: item, badge: isRecommended ? _TierBadge.recommended : isPopular ? _TierBadge.popular : _TierBadge.none, loading: _loadingProductId == item.code, onBuy: () => _onBuy(item), ), ); }), ], ), ), if (_loadingProductId != null) Positioned.fill( child: IgnorePointer( child: Container( color: AppColors.overlayDark, child: const Center( child: SizedBox( width: 32, height: 32, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(AppColors.surface), ), ), ), ), ), ), ], ), ); } } /// 支付方式选择弹窗(三方支付列表)- 1:1 匹配 Pencil PaymentMethodModal class _PaymentMethodSheet extends StatefulWidget { const _PaymentMethodSheet({required this.methods}); final List methods; @override State<_PaymentMethodSheet> createState() => _PaymentMethodSheetState(); } class _PaymentMethodSheetState extends State<_PaymentMethodSheet> { PaymentMethodItem? _selected; @override void initState() { super.initState(); _selected = widget.methods.first; } @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; return Container( decoration: const BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.only( topLeft: Radius.circular(24), topRight: Radius.circular(24), ), ), padding: EdgeInsets.fromLTRB(24, 20, 24, 32 + bottomPadding), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Select Payment Method', style: AppTypography.bodyLarge.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.w700, fontSize: 18, ), ), const SizedBox(height: 20), ...List.generate(widget.methods.length, (i) { final m = widget.methods[i]; final isSelected = _selected == m; return Padding( padding: EdgeInsets.only( bottom: i < widget.methods.length - 1 ? 12 : 0, ), child: _PaymentMethodItem( item: m, isSelected: isSelected, onTap: () => setState(() => _selected = m), ), ); }), const SizedBox(height: 20), SizedBox( height: 48, child: ElevatedButton( onPressed: _selected != null ? () => Navigator.of(context).pop(_selected) : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, foregroundColor: AppColors.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( 'Pay', style: AppTypography.bodyRegular.copyWith( color: AppColors.surface, fontWeight: FontWeight.w600, fontSize: 16, ), ), ), ), ], ), ), ); } } /// 单条支付方式项 - 图标、名称、Recommended(deny取反)、单选 class _PaymentMethodItem extends StatelessWidget { const _PaymentMethodItem({ required this.item, required this.isSelected, required this.onTap, }); final PaymentMethodItem item; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), height: 64, decoration: BoxDecoration( color: isSelected ? const Color(0x158B5CF6) : AppColors.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? AppColors.primary : AppColors.border, width: isSelected ? 2 : 1, ), ), child: Row( children: [ Expanded( child: Row( children: [ _PaymentIcon(iconUrl: item.icon), const SizedBox(width: 12), Expanded( child: Row( children: [ Flexible( child: Text( item.displayName, style: AppTypography.bodyRegular.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.w600, fontSize: 16, ), overflow: TextOverflow.ellipsis, ), ), if (item.recommend) ...[ const SizedBox(width: 6), Text( 'Recommended', style: AppTypography.caption.copyWith( color: AppColors.primary, fontWeight: FontWeight.w600, fontSize: 11, ), ), ], ], ), ), ], ), ), _RadioIndicator(selected: isSelected), ], ), ), ); } } /// 支付方式图标 - 优先接口 greylist,否则 fallback,正方形 40x40(在 64 高度内最大),完整显示不裁切不变形 class _PaymentIcon extends StatelessWidget { const _PaymentIcon({this.iconUrl}); final String? iconUrl; static const double _size = 40; @override Widget build(BuildContext context) { return SizedBox.square( dimension: _size, child: iconUrl != null && iconUrl!.isNotEmpty ? Image.network( iconUrl!, fit: BoxFit.contain, errorBuilder: (_, __, ___) => Icon( LucideIcons.credit_card, size: 24, color: AppColors.primary, ), ) : Icon( LucideIcons.credit_card, size: 24, color: AppColors.primary, ), ); } } /// 单选指示器 - 选中实心紫,未选中空心灰 class _RadioIndicator extends StatelessWidget { const _RadioIndicator({required this.selected}); final bool selected; @override Widget build(BuildContext context) { return Container( width: 20, height: 20, decoration: BoxDecoration( shape: BoxShape.circle, color: selected ? AppColors.primary : Colors.transparent, border: Border.all( color: selected ? AppColors.primary : AppColors.border, width: selected ? 0 : 1, ), ), ); } } enum _TierBadge { none, recommended, popular } class _TierCardFromActivity extends StatelessWidget { const _TierCardFromActivity({ required this.item, required this.badge, required this.loading, required this.onBuy, }); final ActivityItem item; final _TierBadge badge; final bool loading; final VoidCallback onBuy; @override Widget build(BuildContext context) { final content = Container( margin: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xxl, vertical: AppSpacing.xl, ), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: [ BoxShadow( color: AppColors.shadowLight, blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.creditsDisplay, style: AppTypography.bodyLarge.copyWith( color: AppColors.textPrimary, ), ), SizedBox( height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( item.priceDisplayWithDollar, style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), ), if (item.originAmount != null && item.originAmount!.isNotEmpty && item.originAmount != item.actualAmount) ...[ const SizedBox(width: AppSpacing.sm), Text( item.originAmount!, style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, decoration: TextDecoration.lineThrough, decorationColor: AppColors.textSecondary, ), ), ], if (item.bonus > 0) ...[ const SizedBox(width: AppSpacing.sm), Text( 'Bonus: ${item.bonus} credits', style: AppTypography.caption.copyWith( color: AppColors.primary, ), ), ], ], ), ], ), ), _BuyButton(onTap: onBuy, loading: loading), ], ), ); if (badge == _TierBadge.none) { return content; } return Stack( clipBehavior: Clip.none, children: [ content, Positioned( top: 0, right: AppSpacing.screenPadding, child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), decoration: BoxDecoration( color: badge == _TierBadge.recommended ? AppColors.primaryLight : AppColors.accentOrangeLight, borderRadius: const BorderRadius.only( topRight: Radius.circular(16), bottomLeft: Radius.circular(16), ), ), child: Text( badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular', style: AppTypography.label.copyWith( color: badge == _TierBadge.recommended ? AppColors.primary : AppColors.accentOrange, fontWeight: FontWeight.w600, ), ), ), ), ], ); } } class _CreditsSection extends StatelessWidget { const _CreditsSection({required this.currentCredits}); final String currentCredits; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.all(AppSpacing.screenPadding), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xxl, vertical: AppSpacing.xl, ), decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.primary.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( color: AppColors.primaryShadow.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( children: [ Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface), const SizedBox(width: AppSpacing.md), Text( currentCredits, style: AppTypography.bodyLarge.copyWith( fontSize: 32, fontWeight: FontWeight.w700, color: AppColors.surface, ), ), const Spacer(), Text( 'Current Credits', style: AppTypography.caption.copyWith( color: AppColors.surface.withValues(alpha: 0.8), ), ), ], ), ); } } class _BuyButton extends StatelessWidget { const _BuyButton({required this.onTap, this.loading = false}); final VoidCallback onTap; final bool loading; @override Widget build(BuildContext context) { return GestureDetector( onTap: loading ? null : onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, vertical: AppSpacing.md, ), decoration: BoxDecoration( color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary, borderRadius: BorderRadius.circular(12), ), child: loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(AppColors.surface), ), ) : Text( 'Buy', style: AppTypography.bodyRegular.copyWith( color: AppColors.surface, fontWeight: FontWeight.w600, ), ), ), ); } }