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/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; /// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。 /// 商品来自 [PaymentFlowCatalog.loadStoreActivities];Android 走 [NativeIapCoordinator.purchaseGooglePlay]。 class PurchaseScreen extends StatefulWidget { const PurchaseScreen({super.key}); @override State createState() => _PurchaseScreenState(); } class _PurchaseScreenState extends State { List _products = []; bool _loading = true; String? _loadError; bool _paying = false; int? _selectedIndex; @override void initState() { super.initState(); // 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) _loadProducts(isInitial: true); }); } 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; }); } 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; } 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 sink = _PurchaseSink( context: context, onRefresh: () { if (mounted) setState(() => _paying = false); }, onSuccess: () { if (mounted) setState(() {}); }, ); await NativeIapCoordinator.purchaseGooglePlay( sink: sink, userId: uid, activityId: aid, storeProductId: pid, createPaymentApp: currentBackendAppType(), ); if (mounted) { setState(() { _paying = false; }); } } @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( gradient: PencilTheme.yellowWhitePageGradient, ), 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.]+'); 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(); } @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 bonus = item.bonus; 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.w600, color: PencilTheme.stone600, ), ), const SizedBox(height: 8), Text( 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( origin, style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w600, color: const Color(0xFFDB6525), decoration: TextDecoration.lineThrough, decorationColor: const Color(0xFFDB6525), ), ), ), ], if (bonus != null && bonus > 0) ...[ const SizedBox(height: 6), Align( alignment: Alignment.centerRight, child: Text( '+$bonus Bonus', style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w500, color: PencilTheme.stone600, ), ), ), ], ], ), ), if (pct != null && pct > 0) Positioned( right: -4, top: -8, child: SizedBox( width: 60, height: 33, child: Stack( alignment: Alignment.center, children: [ Image.asset( 'assets/images/credit_tag.png', fit: BoxFit.fill, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), Padding( padding: const EdgeInsets.only(top: 4), 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), ), ), ), ], ), ), ), ); } }