Files
pangolin/client/lib/screens/payment_page.dart
T
wangjia 262773a9de fix(client): dispose 不再静默取消支付订单,redirect launchUrl 加失败兜底
- PaymentFlowController 新增 stopPolling():只停轮询 Timer,不发远程 cancel、
  不改 state;PaymentScreen.dispose() 改调它,不再复用 cancel()——离开支付页
  (切左导航 tab)不再等同于放弃订单,cancel() 只留给「取消订单」按钮的显式路径。
- redirect 分支 launchUrl 前加 canLaunchUrl 判定 + try-catch,失败时用
  showPangolinToast 给可见提示(新增 l10n openAlipayFailed,zh/en 均补)。
- purchase_page.dart::_choose 下单后严格判 phase == awaitingPayment 才跳转
  支付页,建单失败(failed)不再误跳。
- 回归测试:payment_pages_test.dart 补两条用例区分 dispose(不触发 cancel、
  state 不归 idle)与「取消订单」按钮(远程 cancel + state 归 idle);
  payment_flow_test.dart 补 stopPolling() 单元测试。
2026-07-11 00:06:03 +08:00

290 lines
12 KiB
Dart

// 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 '../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<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
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<void> _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<void> _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),
),
]),
);
}
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: [
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<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
final other = s.method == 'crypto' ? 'alipay' : '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),
]),
),
);
}
}