import 'dart:async'; import 'dart:io'; import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../core/app_env.dart'; import '../../core/payment/google_play_order_recovery.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import '../../widgets/pencil_yellow_white_background.dart'; import '../web/app_web_view_screen.dart'; import 'third_party_payment_sheet.dart'; /// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。 /// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。 /// - [ExtConfigData.allowThirdPartyPayment] 为 `true` 时:先 [PaymentApi.getPaymentMethods],再 /// [ThirdPartyCheckoutCoordinator.createOrder];**与 app_client [RechargeScreen._createOrderAndOpenUrl] 一致**: /// - [CreatePaymentResponse.payUrl] 为空(线网常为无 `convert`)→ [NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment] /// (仅拉起 Play + [PaymentApi.googlepay]),**不**轮询订单。 /// - [payUrl] 非空 → WebView + [ThirdPartyPaymentWatch]。 /// - 否则 Android 直接 [NativeIapCoordinator.purchaseGooglePlay];iOS 无内购接线时提示。 class PurchaseScreen extends StatefulWidget { const PurchaseScreen({super.key}); @override State createState() => _PurchaseScreenState(); } class _PurchaseScreenState extends State with WidgetsBindingObserver { List _products = []; bool _loading = true; String? _loadError; bool _paying = false; int? _selectedIndex; ThirdPartyPaymentWatch? _thirdPartyWatch; void _resetPayingState() { if (!mounted) return; setState(() { _paying = false; _selectedIndex = null; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _thirdPartyWatch?.dispose(); super.dispose(); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); // Defer network + setState until after first frame so the route can paint and // the main isolate stays responsive (avoids input ANR when opening this screen). WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; unawaited(runGooglePlayOrderRecovery()); _loadProducts(isInitial: true); }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 对齐 app_client:从 Google Play 返回时先解锁支付按钮,避免取消/失败后长时间转圈等待超时。 if (state == AppLifecycleState.resumed && _paying && mounted) { _resetPayingState(); } } Future _loadProducts({bool isInitial = false}) async { if (!isInitial) { setState(() { _loading = true; _loadError = null; }); } final res = await PaymentFlowCatalog.loadStoreActivities(); if (!mounted) return; if (!res.isSuccess || res.data == null) { setState(() { _loading = false; _loadError = res.msg.isNotEmpty ? res.msg : 'Failed to load products'; _products = []; }); return; } final list = res.data!.productList ?? []; setState(() { _loading = false; _products = list; }); } bool _useThirdPartyFromExt(ExtConfigData? ext) => ext?.allowThirdPartyPayment == true; Future _pickPaymentMethod( List methods, PaymentProductItem product, ) { return showThirdPartyPaymentMethodSheet( context, methods: methods, product: product, summaryTierCredits: product.credits, summaryTierBonus: product.bonus, ); } Future _onBuyThirdParty(PaymentProductItem item, int index) async { final uid = UserState.userId.value; if (uid == null || uid.isEmpty) return; final aidStr = item.activityId!.trim(); final aidInt = int.tryParse(aidStr); if (aidInt == null) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Invalid activity id.'))); return; } setState(() { _paying = true; _selectedIndex = index; }); final methodsRes = await PaymentApi.getPaymentMethods(activityId: aidInt); if (!mounted) return; if (!methodsRes.isSuccess || methodsRes.data == null) { setState(() { _paying = false; _selectedIndex = null; }); AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods', ), ), ); return; } final methods = methodsRes.data!.paymentMethods ?? []; if (methods.isEmpty) { setState(() { _paying = false; _selectedIndex = null; }); AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No payment methods available.')), ); return; } setState(() { _paying = false; }); final picked = await _pickPaymentMethod(methods, item); if (!mounted || picked == null) { _resetPayingState(); return; } final pm = picked.paymentMethod?.trim() ?? ''; if (pm.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Invalid payment method.'))); return; } setState(() { _paying = true; _selectedIndex = index; }); final innerSink = _PurchaseSink( context: context, onRefresh: () { if (mounted) { setState(() { _paying = false; _selectedIndex = null; }); } _thirdPartyWatch?.dispose(); _thirdPartyWatch = null; }, onSuccess: () { if (mounted) setState(() {}); }, ); final sink = PaymentSettlementSinkWithAnalytics( inner: innerSink, analyticsProduct: item, ); final outcome = await ThirdPartyCheckoutCoordinator.createOrder( userId: uid, activityId: aidStr, paymentMethod: pm, paymentType: pm, subPaymentMethod: picked.subPaymentMethod, ); if (!mounted) return; if (!outcome.isSuccess) { setState(() { _paying = false; _selectedIndex = null; }); AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(outcome.message ?? 'Create order failed')), ); return; } final orderId = outcome.orderId!; final payUrl = outcome.payUrl; if (NativeIapCoordinator.shouldLaunchGooglePlayBillingInsteadOfWeb( payUrl, )) { if (!Platform.isAndroid) { setState(() { _paying = false; _selectedIndex = null; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Google Play billing is only available on Android.'), ), ); return; } final storePid = item.productId!.trim(); final created = outcome.createResponse; if (created is! CreatePaymentResponse) { setState(() { _paying = false; _selectedIndex = null; }); AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invalid payment response.')), ); return; } await NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment( sink: sink, userId: uid, storeProductId: storePid, createResponse: created, createPaymentApp: currentBackendAppType(), ); _resetPayingState(); return; } if (payUrl != null && payUrl.trim().isNotEmpty) { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AppWebViewScreen(title: 'Payment', initialUrl: payUrl.trim()), ), ); } if (!mounted) return; _thirdPartyWatch?.dispose(); _thirdPartyWatch = ThirdPartyPaymentWatch(userId: uid, sink: sink); _thirdPartyWatch!.start(orderId: orderId); } Future _onBuy(PaymentProductItem item, int index) async { final uid = UserState.userId.value; if (uid == null || uid.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Please sign in first.'))); return; } final aid = item.activityId; final pid = item.productId; if (aid == null || aid.isEmpty || pid == null || pid.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Product data is incomplete.')), ); return; } AnalyticsEvents.trackTierSelection(item); final ext = ExtConfigRuntime.data.value; if (_useThirdPartyFromExt(ext)) { await _onBuyThirdParty(item, index); return; } if (!Platform.isAndroid) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'In-app purchases on iOS use the App Store flow. This build only wires Google Play on Android.', ), ), ); return; } setState(() { _paying = true; _selectedIndex = index; }); final innerSink = _PurchaseSink( context: context, onRefresh: () { if (mounted) { setState(() { _paying = false; _selectedIndex = null; }); } }, onSuccess: () { if (mounted) setState(() {}); }, ); final sink = PaymentSettlementSinkWithAnalytics( inner: innerSink, analyticsProduct: item, ); try { await NativeIapCoordinator.purchaseGooglePlay( sink: sink, userId: uid, activityId: aid, storeProductId: pid, createPaymentApp: currentBackendAppType(), ); } finally { _resetPayingState(); } } @override Widget build(BuildContext context) { return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), child: Row( children: [ PencilRoundCloseButton( onPressed: () => Navigator.of(context).pop(), ), const Spacer(), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 6), child: Text( 'Purchase Point', textAlign: TextAlign.center, style: TextStyle( fontFamily: 'BonheurRoyale', fontSize: 44, height: 1.05, color: const Color(0xFF5C3D2E), ), ), ), ValueListenableBuilder( valueListenable: UserState.credits, builder: (_, credits, _) { return _CreditHeaderSection( creditsText: credits.toStringAsFixed(2), ); }, ), Expanded( child: _loading ? const Center( child: CircularProgressIndicator( color: PencilTheme.underlineGold, ), ) : _loadError != null ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( _loadError!, textAlign: TextAlign.center, style: GoogleFonts.inter( color: PencilTheme.stone600, ), ), const SizedBox(height: 16), TextButton( onPressed: _loadProducts, child: Text( 'Retry', style: GoogleFonts.inter( fontWeight: FontWeight.w600, color: PencilTheme.underlineGold, ), ), ), ], ), ), ) : _products.isEmpty ? Center( child: Text( 'No products available', style: GoogleFonts.inter(color: PencilTheme.stone600), ), ) : _ProductGrid( products: _products, paying: _paying, selectedIndex: _selectedIndex, onTap: _onBuy, ), ), ], ), ), ), ); } } class _PurchaseSink implements PaymentSettlementSink { _PurchaseSink({ required this.context, required this.onRefresh, required this.onSuccess, }); final BuildContext context; final VoidCallback onRefresh; final VoidCallback onSuccess; @override void onPaymentSettled(PaymentSettlement settlement) { onRefresh(); if (!context.mounted) return; switch (settlement.type) { case PaymentFlowOutcomeType.success: UserAccountRefresh.fetchAndNotify( app: currentBackendAppType(), userId: UserState.userId.value, onAccount: (a) { if (a.credits != null) UserState.setCredits(a.credits!); }, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( settlement.message ?? 'Payment successful', style: GoogleFonts.inter(), ), ), ); onSuccess(); break; case PaymentFlowOutcomeType.failure: ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( settlement.message ?? 'Payment failed', style: GoogleFonts.inter(), ), ), ); break; case PaymentFlowOutcomeType.cancelled: ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( settlement.message ?? 'Cancelled', style: GoogleFonts.inter(), ), ), ); break; case PaymentFlowOutcomeType.timeout: case PaymentFlowOutcomeType.nativePendingHostVerification: ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( settlement.message ?? 'Payment pending', style: GoogleFonts.inter(), ), ), ); break; } } } class _CreditHeaderSection extends StatelessWidget { const _CreditHeaderSection({required this.creditsText}); final String creditsText; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ // Explicit height: as a non-flex child above [Expanded], this Column gets // unbounded max height; [Stack] must have bounded constraints. Container( width: double.infinity, height: 120, decoration: const BoxDecoration( color: Color(0xFFFCE952), borderRadius: BorderRadius.only( topLeft: Radius.circular(22), topRight: Radius.circular(22), ), ), child: Stack( clipBehavior: Clip.none, children: [ Positioned( left: 17, top: 11, child: Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 8, ), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFAE238), Color(0xFFF5BE5D)], ), borderRadius: BorderRadius.circular(999), border: Border.all(color: Colors.white, width: 1.5), ), child: Text( 'Credit :', style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white, ), ), ), ), Positioned( left: 0, right: 0, top: 56, child: Text( creditsText, textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 36, fontWeight: FontWeight.w800, color: const Color(0xFF1F2937), ), ), ), ], ), ), Image.asset( 'assets/images/xiabiao.png', width: 60, height: 17, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox(height: 17), ), ], ), ); } } class _ProductGrid extends StatelessWidget { const _ProductGrid({ required this.products, required this.paying, required this.selectedIndex, required this.onTap, }); final List products; final bool paying; final int? selectedIndex; final void Function(PaymentProductItem item, int index) onTap; @override Widget build(BuildContext context) { return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 20, 16, 28), itemCount: (products.length + 1) ~/ 2, itemBuilder: (context, row) { final i0 = row * 2; final i1 = i0 + 1; return Padding( padding: const EdgeInsets.only(bottom: 20), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _ProductCard( item: products[i0], index: i0, paying: paying, selected: selectedIndex == i0, onTap: onTap, ), ), const SizedBox(width: 15), Expanded( child: i1 < products.length ? _ProductCard( item: products[i1], index: i1, paying: paying, selected: selectedIndex == i1, onTap: onTap, ) : const SizedBox.shrink(), ), ], ), ); }, ); } } class _ProductCard extends StatelessWidget { const _ProductCard({ required this.item, required this.index, required this.paying, required this.selected, required this.onTap, }); final PaymentProductItem item; final int index; final bool paying; final bool selected; final void Function(PaymentProductItem item, int index) onTap; static final _money = RegExp(r'[\d.]+'); /// 赠送展示:仅 [PaymentProductItem.bonus](线网常为 `contrast` 映射),`+N bonus`。 static String? _bonusDisplayLine(int total) { if (total <= 0) return null; return '+$total bonus'; } static int? _discountPercent(String? actual, String? origin) { final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? ''); final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? ''); if (a == null || o == null || o <= 0 || a >= o) return null; return ((1 - a / o) * 100).round(); } /// 展示用:金额前加 `$`(已有 `$` / `¥` 则不改)。 static String _withDollarPrefix(String amount) { final t = amount.trim(); if (t.isEmpty || t == '—') return t; if (t.startsWith(r'$') || t.startsWith('¥') || t.startsWith('¥')) { return t; } return r'$' + t; } @override Widget build(BuildContext context) { final rawTitle = item.title; final creditsTopLabel = item.credits != null ? 'Credits:${item.credits}' : (rawTitle != null && rawTitle.trim().isNotEmpty) ? 'Credits:${rawTitle.trim()}' : 'Credits:—'; final actual = item.actualAmount ?? '—'; final origin = item.originAmount; final bonusLine = _bonusDisplayLine(item.bonus ?? 0); final pct = _discountPercent(item.actualAmount, item.originAmount); return Material( color: const Color(0xE6FEE56A), borderRadius: BorderRadius.circular(10), child: InkWell( onTap: paying ? null : () => onTap(item, index), borderRadius: BorderRadius.circular(10), child: SizedBox( height: 130, child: Stack( clipBehavior: Clip.none, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( creditsTopLabel, style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w800, color: PencilTheme.stone600, ), ), const SizedBox(height: 8), Text( _withDollarPrefix(actual), style: GoogleFonts.inter( fontSize: 26, fontWeight: FontWeight.w800, color: const Color(0xFF0A0A0A), ), ), if (origin != null && origin.isNotEmpty) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: const Color(0x80FFE3E3), borderRadius: BorderRadius.circular(8), ), child: Text( _withDollarPrefix(origin), style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w600, color: const Color(0xFFDB6525), decoration: TextDecoration.lineThrough, decorationColor: const Color(0xFFDB6525), ), ), ), ], if (bonusLine != null) ...[ const SizedBox(height: 6), Align( alignment: Alignment.centerRight, child: Text( bonusLine, textAlign: TextAlign.right, style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w500, color: PencilTheme.stone600, ), ), ), ], ], ), ), if (pct != null && pct > 0) Positioned( right: -4, top: -18, child: SizedBox( width: 60, height: 33, child: Stack( alignment: Alignment.center, children: [ Image.asset( 'assets/images/credit_tag.png', fit: BoxFit.fill, width: 80, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), Padding( padding: const EdgeInsets.only(top: 4, right: 10), child: Text( '$pct% Off', textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 10, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic, color: Colors.white, ), ), ), ], ), ), ), if (paying && selected) Positioned.fill( child: Container( alignment: Alignment.center, color: Colors.white24, child: const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), ], ), ), ), ); } }