import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/network/api_exception.dart'; import '../../core/theme/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../providers/payment_provider.dart'; import '../../services/pay_launcher.dart'; import '../../services/payment_service.dart'; import '../../services/user_service.dart'; import '../feed/widgets/feed_overlays.dart'; /// 充值页:余额展示 / 套餐网格 / 自定义金额 / 支付方式 / 立即支付 class RechargeScreen extends ConsumerWidget { const RechargeScreen({super.key}); static const Map _methodIcons = { 1: Icons.account_balance, 2: Icons.chat_bubble, 3: Icons.paid, }; @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(paymentProvider); final user = ref.watch(authProvider).user; return Scaffold( appBar: AppBar(title: const Text('充值')), body: state.isLoading && state.result == null ? const Center(child: CircularProgressIndicator()) : state.loadFailed && state.result == null ? _LoadFailed( onRetry: () => ref.read(paymentProvider.notifier).load(), ) : RefreshIndicator( onRefresh: () => ref.read(paymentProvider.notifier).load(), child: ListView( padding: const EdgeInsets.all(16), children: [ // 当前积分 _PointsHeader(points: user?.points ?? 0), const SizedBox(height: 20), // 套餐网格 if (state.result?.packages.isNotEmpty == true) _PackageGrid( packages: state.result!.packages, selectedId: state.selectedPackageId, onSelect: (id) => ref.read(paymentProvider.notifier).selectPackage(id), ), // 自定义金额 if (state.result?.custom.enabled == true) ...[ const SizedBox(height: 12), _CustomAmountInput( custom: state.result!.custom, value: state.customYuan, onChanged: (v) => ref .read(paymentProvider.notifier) .setCustomYuan(v), ), ], const SizedBox(height: 20), // 支付方式 if (state.result?.payMethods.isNotEmpty == true) _PayMethodSelector( methods: state.result!.payMethods, selected: state.selectedMethod, onSelect: (m) => ref.read(paymentProvider.notifier).selectMethod(m), ), const SizedBox(height: 24), // 立即支付 ElevatedButton( onPressed: state.creating ? null : () => _pay(context, ref), child: state.creating ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( '立即支付${state.amountYuan != null ? ' ¥${state.amountYuan}' : ''}', style: const TextStyle(fontSize: 16), ), ), const SizedBox(height: 12), const Center( child: Text( '1 元 = 10 积分,充值后积分实时到账', style: TextStyle( color: AppColors.textWeak, fontSize: 12), ), ), ], ), ), ); } Future _pay(BuildContext context, WidgetRef ref) async { final notifier = ref.read(paymentProvider.notifier); final current = ref.read(paymentProvider); if (current.amountYuan == null || current.selectedMethod == null) { showTopToast(context, '请选择充值金额与支付方式'); return; } try { final order = await notifier.createOrder(); if (!context.mounted) return; if (order.isUsdt) { context.push('/recharge/usdt', extra: order); return; } await _launchAndPoll(context, order); } on ApiException catch (e) { if (!context.mounted) return; if (e.code == 1001) { showTopToast(context, '充值金额不在可选档位,请重新选择'); } else { showTopToast(context, e.message); } } catch (_) { if (context.mounted) showTopToast(context, '下单失败,请稍后重试'); } } /// 调起原生 SDK;无有效调起参数时降级为「外部完成支付」轮询弹窗 Future _launchAndPoll( BuildContext context, RechargeOrderInfo order) async { // 服务端未配置商户密钥(dev):返回 dev_mock,直接走轮询兜底 if (order.payParams['dev_mock'] == true) { _showAliWechatSheet(context, order); return; } final result = await PayLauncher.launch(order); if (!context.mounted) return; switch (result) { case PayLaunchResult.success: case PayLaunchResult.unsupported: // 成功仍需服务端查单确认;参数不可用时降级到手动轮询 _showAliWechatSheet(context, order); case PayLaunchResult.canceled: showTopToast(context, '已取消支付'); case PayLaunchResult.failed: showTopToast(context, '调起支付失败,请确认已安装对应 App'); } } /// 支付宝/微信:底部轮询弹窗(无 SDK 集成期以「我已完成支付」兜底) void _showAliWechatSheet(BuildContext context, RechargeOrderInfo order) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppColors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => _AliWechatPollSheet(order: order), ); } } /// 顶部积分余额 class _PointsHeader extends StatelessWidget { const _PointsHeader({required this.points}); final int points; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ const Icon(Icons.account_balance_wallet_outlined, color: AppColors.gold), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('当前积分', style: TextStyle( color: AppColors.textSecondary, fontSize: 12)), Text('$points', style: const TextStyle( fontSize: 28, fontWeight: FontWeight.w800, color: AppColors.gold)), ], ), ], ), ); } } /// 套餐网格 class _PackageGrid extends StatelessWidget { const _PackageGrid({ required this.packages, required this.selectedId, required this.onSelect, }); final List packages; final int? selectedId; final ValueChanged onSelect; @override Widget build(BuildContext context) { return Wrap( spacing: 10, runSpacing: 10, children: packages.map((pkg) { final selected = selectedId == pkg.id; return GestureDetector( onTap: () => onSelect(pkg.id), child: Container( width: (MediaQuery.of(context).size.width - 42) / 2, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(10), border: selected ? Border.all(color: AppColors.primary, width: 1.5) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('¥${pkg.amountYuan}', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700)), if (pkg.bonus > 0) Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( gradient: AppColors.goldGradient, borderRadius: BorderRadius.circular(4), ), child: Text('赠 ${pkg.bonus}', style: const TextStyle( color: Colors.black, fontSize: 10, fontWeight: FontWeight.w700)), ), ], ), const SizedBox(height: 4), Text('到账 ${pkg.totalPoints} 积分', style: const TextStyle( color: AppColors.textSecondary, fontSize: 12)), ], ), ), ); }).toList(), ); } } /// 自定义金额输入 class _CustomAmountInput extends StatefulWidget { const _CustomAmountInput({ required this.custom, required this.value, required this.onChanged, }); final CustomRechargeConfig custom; final int? value; final ValueChanged onChanged; @override State<_CustomAmountInput> createState() => _CustomAmountInputState(); } class _CustomAmountInputState extends State<_CustomAmountInput> { late final TextEditingController _ctrl; @override void initState() { super.initState(); _ctrl = TextEditingController( text: widget.value == null ? '' : widget.value.toString()); } @override void didUpdateWidget(covariant _CustomAmountInput oldWidget) { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value) { final newText = widget.value == null ? '' : widget.value.toString(); if (_ctrl.text != newText) { _ctrl.text = newText; } } } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField( controller: _ctrl, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( hintText: '自定义金额(${widget.custom.minYuan}-${widget.custom.maxYuan} 元)', prefixIcon: const Icon(Icons.edit_outlined), suffixText: '元', ), onChanged: (v) { final n = int.tryParse(v); widget.onChanged(n); }, ); } } /// 支付方式单选 class _PayMethodSelector extends StatelessWidget { const _PayMethodSelector({ required this.methods, required this.selected, required this.onSelect, }); final List methods; final int? selected; final ValueChanged onSelect; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('支付方式', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(height: 10), ...methods.map((m) { final active = selected == m.method; final icon = RechargeScreen._methodIcons[m.method] ?? Icons.payment; return ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: Icon(icon, color: AppColors.gold), title: Text(m.name), trailing: active ? const Icon(Icons.check_circle, color: AppColors.primary) : const Icon(Icons.radio_button_off, color: AppColors.textWeak), onTap: () => onSelect(m.method), ); }), ], ); } } /// 支付宝/微信轮询弹窗(底部 Sheet) class _AliWechatPollSheet extends ConsumerStatefulWidget { const _AliWechatPollSheet({required this.order}); final RechargeOrderInfo order; @override ConsumerState<_AliWechatPollSheet> createState() => _AliWechatPollSheetState(); } class _AliWechatPollSheetState extends ConsumerState<_AliWechatPollSheet> { @override Widget build(BuildContext context) { final poll = ref.watch(orderPollProvider(widget.order)); // 状态变化时自动关闭(成功/超时/关闭) WidgetsBinding.instance.addPostFrameCallback((_) { if (poll.status == OrderPollStatus.paid) { _refreshUser(); _showResult('支付成功', '已到账 ${widget.order.points} 积分'); } else if (poll.status == OrderPollStatus.closed) { _showResult('订单已关闭', '该订单已被取消或超时'); } else if (poll.status == OrderPollStatus.timeout) { _showResult('查询超时', '请前往「钱包-充值记录」稍后查看结果'); } }); return SafeArea( child: Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.textWeak, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 16), Text( '支付中…', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Text( '订单号:${widget.order.orderNo}', style: const TextStyle( color: AppColors.textSecondary, fontSize: 12), ), const SizedBox(height: 16), const SizedBox( width: 36, height: 36, child: CircularProgressIndicator(strokeWidth: 3), ), const SizedBox(height: 16), Text( widget.order.isUsdt ? '请完成 USDT 转账' : '请在外部完成支付,完成后返回本页', textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 20), TextButton( onPressed: () => _cancel(context), child: const Text('取消订单', style: TextStyle(color: AppColors.textSecondary)), ), ], ), ), ); } void _cancel(BuildContext context) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.surface, title: const Text('取消订单', style: TextStyle(fontSize: 16)), content: const Text('取消后该笔订单将关闭,是否继续?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('继续支付'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('取消订单', style: TextStyle(color: AppColors.primary)), ), ], ), ); if (ok == true) { await ref.read(orderPollProvider(widget.order).notifier).cancel(); } } void _refreshUser() async { // 支付成功后刷新全局用户信息(积分余额) try { final user = await UserService.fetchMe(); await ref.read(authProvider.notifier).onLoginSuccess(user); } catch (_) {} } void _showResult(String title, String body) { if (!context.mounted) return; Navigator.of(context).pop(); showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.surface, title: Text(title, style: const TextStyle(fontSize: 16)), content: Text(body), actions: [ TextButton( onPressed: () { Navigator.pop(ctx); context.pop(); }, child: const Text('确定'), ), ], ), ); } @override void dispose() { // 停止轮询避免后台泄漏 ref.read(orderPollProvider(widget.order).notifier).stop(); super.dispose(); } } class _LoadFailed extends StatelessWidget { const _LoadFailed({required this.onRetry}); final VoidCallback onRetry; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)), const SizedBox(height: 12), TextButton(onPressed: onRetry, child: const Text('点击重试')), ], ), ); } }