// payment_page.dart — 支付页:按 session.render_type 多态渲染,轮询到 // activated 切成功态。crypto_address=地址+精确金额+复制;redirect=外链拉起; // qr=预留(复制内容兜底)。 import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_text.dart'; import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; import '../widgets/pangolin_button.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; class PaymentScreen extends ConsumerStatefulWidget { const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false}); final AppText t; final VoidCallback? onBack; /// 成功态「完成」/取消订单后的退出导航(壳层决定去向)。 final VoidCallback? onDone; final bool embedded; @override ConsumerState createState() => _PaymentScreenState(); } class _PaymentScreenState extends ConsumerState { AppText get t => widget.t; VoidCallback? get onBack => widget.onBack; VoidCallback? get onDone => widget.onDone; bool get embedded => widget.embedded; // dispose() 时 Riverpod 的 ref 已提前失效(ConsumerStatefulElement 在 // unmount 阶段收口 ref,dispose() 里再 ref.read 会抛 // "Cannot use ref after the widget was disposed")——必须用 build() 里 // 缓存下来的 controller/phase,不能在 dispose() 现取。 PaymentFlowController? _ctl; PaymentPhase _phase = PaymentPhase.idle; @override void dispose() { // paymentFlowProvider 非 autoDispose、轮询 Timer 无 expiry 自停:离开支付页时, // 若订单仍在等待付款(唯一存在活跃 Timer 的相位),只停轮询 Timer // (stopPolling(),不发远程 cancel、不改 state)——用户点其他导航 tab 离开支付页 // 不等于放弃订单,订单在后台仍然存活,回来时(或轮询式重进)状态还在。真正的 // 「放弃本单」只有显式点「取消订单」按钮时才走 cancel()(远程 cancel + 状态归 idle)。 // // 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发 // state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的 Element, // 炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道),光 // catchError 接不住。stopPolling() 本身不写 state,理论上不触发该问题,但仍用 // scheduleMicrotask 挪到本次 unmount 收尾之后再执行,统一收口、避免任何时序脆弱性。 // 执行时点若容器已把 controller 一并 dispose(如整页 ProviderScope 随之销毁),用 // 公开的 `mounted` 短路,不调用已销毁实例。 if (_phase == PaymentPhase.awaitingPayment) { final ctl = _ctl; if (ctl != null) { scheduleMicrotask(() { if (ctl.mounted) ctl.stopPolling(); }); } } super.dispose(); } bool get _zh => t.lang == AppLang.zh; Future _copy(BuildContext context, String text) async { await Clipboard.setData(ClipboardData(text: text)); if (context.mounted) showPangolinToast(context, t.copied); } // redirect 分支拉起外部 app(支付宝):桌面无支付宝、或平台层 PlatformException 时 // launchUrl 会失败/抛异常——canLaunchUrl 判定 + try-catch 双保险,失败给可见 toast // 而非静默无反应。 Future _openRedirectUrl(BuildContext context, String url) async { final uri = Uri.tryParse(url); if (uri == null) { if (context.mounted) showPangolinToast(context, t.openAlipayFailed); return; } try { final can = await canLaunchUrl(uri); if (!can) { if (context.mounted) showPangolinToast(context, t.openAlipayFailed); return; } final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok && context.mounted) showPangolinToast(context, t.openAlipayFailed); } catch (_) { if (context.mounted) showPangolinToast(context, t.openAlipayFailed); } } // 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。 Widget _card(PangolinScheme c, {required Widget child}) => Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm, ), child: child, ); Widget _kvRow(BuildContext context, String label, String value, {bool mono = true}) { final c = context.pangolin; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 76, child: Text(label, style: PangolinText.sm.copyWith(color: c.fg3)), ), Expanded( child: SelectableText(value, style: (mono ? PangolinText.mono : PangolinText.body) .copyWith(color: c.fg1, fontSize: 14)), ), IconButton( visualDensity: VisualDensity.compact, icon: Icon(PangolinIcons.copy, size: 16, color: c.fg3), onPressed: () => _copy(context, value), ), ]), ); } String _planName(String sku) => switch (sku) { 'pro_month' => t.proMonthly, 'pro_quarter' => t.proQuarterly, 'pro_year' => t.proYearly, _ => sku, }; // 订单信息卡:套餐名 + 支付渠道 + 金额(金额按所选渠道币种显价)。 Widget _orderCard(BuildContext context, PaymentFlowState s) { final c = context.pangolin; final ch = s.method == 'crypto' ? PayChannel.usdt : PayChannel.cny; final chanLabel = ch == PayChannel.usdt ? t.payChannelUsdt : t.payChannelCny; final item = s.item; return _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.orderDetailTitle.toUpperCase(), style: PangolinText.caption .copyWith(color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)), const SizedBox(height: 10), if (item != null) _orderRow(c, t.orderPlanLabel, _planName(item.sku)), _orderRow(c, t.payChannel, chanLabel), if (item != null) _orderRow(c, t.orderAmountLabel, item.priceLabel(ch), amount: true), ])); } Widget _orderRow(PangolinScheme c, String k, String v, {bool amount = false}) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text(k, style: PangolinText.sm.copyWith(color: c.fg3)), Flexible( child: Text(v, textAlign: TextAlign.right, style: (amount ? PangolinText.mono : PangolinText.sm).copyWith( color: amount ? c.accent : c.fg1, fontWeight: FontWeight.w700)), ), ], ), ); Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) { final c = context.pangolin; final session = s.order!.session; final payload = session.payload; Widget renderBody; switch (session.renderType) { case 'crypto_address': renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _kvRow(context, t.payNetworkLabel, '${payload['currency'] ?? 'USDT'} · ${payload['network'] ?? 'TRC20'}'), _kvRow(context, t.payAddressLabel, '${payload['address'] ?? ''}'), _kvRow(context, t.payAmountLabel, '${payload['amount'] ?? ''}'), const SizedBox(height: 6), Text(t.payExactAmountHint, style: PangolinText.caption.copyWith(color: c.warning)), ])); case 'redirect': renderBody = _card(c, child: Column(children: [ PangolinButton( label: t.openAlipay, icon: PangolinIcons.externalLink, expand: true, onPressed: () => _openRedirectUrl(context, '${payload['url'] ?? ''}'), ), const SizedBox(height: 10), Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)), ])); case 'qr': // 预留:复制内容兜底,不引入二维码渲染依赖 renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _kvRow(context, t.payAmountLabel, '¥${payload['display_amount'] ?? ''}', mono: true), _kvRow(context, t.payAddressLabel, '${payload['qr_content'] ?? ''}'), const SizedBox(height: 6), Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)), ])); default: renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}', style: PangolinText.body.copyWith(color: c.fg2))); } return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ _orderCard(context, s), const SizedBox(height: 14), renderBody, const SizedBox(height: 16), Row(children: [ SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent)), const SizedBox(width: 10), Text(t.awaitingPayment, style: PangolinText.sm.copyWith(color: c.fg2)), ]), const SizedBox(height: 22), PangolinButton( label: t.switchPayMethod, variant: PangolinButtonVariant.secondary, expand: true, onPressed: () => _pickAndSwitch(context, ref, s), ), const SizedBox(height: 10), PangolinButton( label: t.cancelOrder, variant: PangolinButtonVariant.ghost, expand: true, onPressed: () async { await ref.read(paymentFlowProvider.notifier).cancel(); onDone?.call(); }, ), ]); } Future _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async { final other = s.method == 'crypto' ? 'nezha' : 'crypto'; await ref.read(paymentFlowProvider.notifier).switchMethod(other); } Widget _succeeded(BuildContext context, PaymentFlowState s) { final c = context.pangolin; final exp = s.status?.expiresAt; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(PangolinIcons.checkCircle, size: 56, color: c.success), const SizedBox(height: 16), Text(t.paySucceeded, style: PangolinText.h2.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), if (exp != null) ...[ const SizedBox(height: 8), Text('${t.payExpiresAt} ${exp.toLocal().toString().split(' ').first}', style: PangolinText.sm.copyWith(color: c.fg2)), ], const SizedBox(height: 24), PangolinButton(label: t.payDone, expand: true, onPressed: () => onDone?.call()), ]), ), ); } Widget _failed(BuildContext context, WidgetRef ref, PaymentFlowState s) { final c = context.pangolin; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text(t.payFailed, style: PangolinText.h3.copyWith(color: c.danger)), const SizedBox(height: 8), Text((_zh ? s.errorZh : s.errorEn) ?? '', style: PangolinText.sm.copyWith(color: c.fg3)), const SizedBox(height: 20), PangolinButton( label: t.payRetry, expand: true, onPressed: () { final item = s.item; if (item != null) ref.read(paymentFlowProvider.notifier).start(item, s.method); }, ), ]), ), ); } @override Widget build(BuildContext context) { final c = context.pangolin; final s = ref.watch(paymentFlowProvider); _ctl = ref.read(paymentFlowProvider.notifier); _phase = s.phase; final body = switch (s.phase) { PaymentPhase.creating => const Center(child: CircularProgressIndicator()), PaymentPhase.awaitingPayment => _awaiting(context, ref, s), PaymentPhase.succeeded => _succeeded(context, s), PaymentPhase.failed => _failed(context, ref, s), PaymentPhase.idle => Center( child: Text(_zh ? '暂无进行中的订单' : 'No active order', style: PangolinText.body.copyWith(color: c.fg3))), }; if (embedded) return body; return Scaffold( backgroundColor: c.bg, body: SafeArea( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), child: Row(children: [ IconButton( onPressed: onBack ?? () => Navigator.of(context).maybePop(), icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), ), Text(t.paymentTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), ]), ), Expanded(child: body), ]), ), ); } }