6d22127210
ci-pangolin / Lint — shellcheck (pull_request) Successful in 13s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 28s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 38s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m42s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m51s
330 lines
14 KiB
Dart
330 lines
14 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 '../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';
|
|
import '../widgets/sub_scaffold.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 || url.isEmpty) {
|
|
if (context.mounted) showPangolinToast(context, t.openAlipayFailed);
|
|
return;
|
|
}
|
|
// payload.url 是收银台 https 页(二维码 + 打开支付宝 + 轮询),不是裸 alipays 深链。
|
|
// 不用 canLaunchUrl 预判:Android 11+ 包可见性下它对 https 常误判 false(即本次
|
|
// 「couldn't open ali」根因)。直接试:外部浏览器/支付宝 → 失败退 app 内浏览视图
|
|
// (Custom Tab/WebView,收银台页在 app 内显示,没装支付宝也能扫码/走 H5)。
|
|
try {
|
|
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return;
|
|
} catch (_) {}
|
|
try {
|
|
if (await launchUrl(uri, mode: LaunchMode.inAppBrowserView)) return;
|
|
} 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}: ${t.payUnsupportedRenderType}',
|
|
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();
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
// switchMethod 内部已把网络/超时/未知异常兜到 failed 相位(与 start/resume 对齐,
|
|
// 会经 build() 的 phase 分支自动切到 _failed() 呈现)——这里的 try/catch 是最后一道
|
|
// 防线,防止任何逃逸异常变成无提示的 unhandled rejection(照抄 _openRedirectUrl 的写法)。
|
|
Future<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
|
|
final other = s.method == 'crypto' ? 'nezha' : 'crypto';
|
|
try {
|
|
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
|
} catch (_) {
|
|
if (context.mounted) showPangolinToast(context, t.orderCreateFailed);
|
|
}
|
|
}
|
|
|
|
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(t.payNoActiveOrder,
|
|
style: PangolinText.body.copyWith(color: c.fg3))),
|
|
};
|
|
|
|
return SubScaffold(
|
|
title: t.paymentTitle,
|
|
onBack: onBack,
|
|
embedded: embedded,
|
|
child: body,
|
|
);
|
|
}
|
|
}
|