feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线

购买页三档 pro(月/季/年,金额来自 GET /v1/pay/catalog)选档 → 选支付方式 → 下单;
支付页按 session.render_type 多态渲染(crypto_address 地址+精确金额+复制、redirect
外链拉起、qr 预留复制兜底),轮询用 Task 6 的 paymentFlowProvider,409
CURRENCY_MISMATCH 走「换方式开新单」。同时收口 paymentFlowProvider 非 autoDispose
带来的轮询生命周期缺口——支付页 dispose 时停轮询(cancel() 延后到微任务执行,避免
在自身 unmount 期间同步改 state 炸 Riverpod 断言)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 23:50:35 +08:00
parent e66f79fdf6
commit a5fff28134
15 changed files with 783 additions and 14 deletions
+2 -2
View File
@@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 一级视图 + 账户子页。
/// desktop 侧栏暴露 6 个一级项(connect..settings);mobile/tablet 取前 4 项,
/// contact/settings 在 mobile 走账户子页。plans/redeem 是 account 的下钻页。
enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices }
enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment }
/// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。
const Set<NavView> kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices};
const Set<NavView> kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment};
/// 当前视图。
final navViewProvider = StateProvider<NavView>((ref) => NavView.connect);
+15 -8
View File
@@ -63,9 +63,15 @@ class PaymentFlowState {
}
class PaymentFlowController extends StateNotifier<PaymentFlowState> {
PaymentFlowController(this._ref) : super(const PaymentFlowState());
PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState());
final Ref _ref;
/// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer
/// (widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。[cancel] 对
/// `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
@visibleForTesting
PaymentFlowController.fixed(super.state) : _ref = null;
final Ref? _ref;
Timer? _poll;
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
@@ -83,7 +89,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
_stopPolling();
state = PaymentFlowState(phase: PaymentPhase.creating, item: item, method: method);
try {
final order = await _ref
final order = await _ref!
.read(paymentApiProvider)
.createOrder(sku: item.sku, method: method, metadata: _metadata(method));
if (!mounted) return;
@@ -102,7 +108,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
final order = state.order;
if (item == null || order == null) return;
try {
final next = await _ref
final next = await _ref!
.read(paymentApiProvider)
.retry(order.orderNo, method: method, metadata: _metadata(method));
if (!mounted) return;
@@ -111,7 +117,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
if (!mounted) return;
if (e.code == 'CURRENCY_MISMATCH') {
try {
await _ref.read(paymentApiProvider).cancel(order.orderNo);
await _ref!.read(paymentApiProvider).cancel(order.orderNo);
} catch (_) {} // 旧单取消失败不阻断新单
await start(item, method);
return;
@@ -122,10 +128,11 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
Future<void> cancel() async {
final order = state.order;
final ref = _ref;
_stopPolling();
if (order != null) {
if (order != null && ref != null) {
try {
await _ref.read(paymentApiProvider).cancel(order.orderNo);
await ref.read(paymentApiProvider).cancel(order.orderNo);
} catch (_) {}
}
if (mounted) state = const PaymentFlowState();
@@ -137,7 +144,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
if (order == null || _polling) return;
_polling = true;
try {
final st = await _ref.read(paymentApiProvider).orderStatus(order.orderNo);
final st = await _ref!.read(paymentApiProvider).orderStatus(order.orderNo);
if (!mounted) return;
if (st.activated) {
_stopPolling();