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
+29
View File
@@ -199,4 +199,33 @@ abstract class AppText {
String get ob2Sub;
String get ob3Title;
String get ob3Sub;
// ── 购买 / 支付 ──
String get purchaseTitle; // 购买套餐 / Purchase
String get paymentTitle; // 支付 / Payment
String get buyNow; // 立即购买 / Buy now
String get payMethodAlipay; // 支付宝 / Alipay
String get payMethodCrypto; // USDT (TRC20)
String get choosePayMethod; // 选择支付方式 / Choose payment method
String get proMonthly; // 专业版·月付 / Pro · Monthly
String get proQuarterly; // 专业版·季付 / Pro · Quarterly
String get proYearly; // 专业版·年付 / Pro · Yearly
String get perQuarter; // /季 / /quarter
String get perYear; // /年 / /year
String get payAmountLabel; // 转账金额 / Amount
String get payAddressLabel; // 收款地址 / Address
String get payNetworkLabel; // 网络 / Network
String get payExactAmountHint; // 金额须精确一致,到账后自动开通 / Send the exact amount; activates automatically
String get copied; // 已复制 / Copied
String get openAlipay; // 打开支付宝支付 / Pay with Alipay
String get openAlipayHint; // 完成支付后返回,本页会自动刷新 / Return after paying; this page refreshes automatically
String get awaitingPayment; // 等待付款 / Awaiting payment
String get paySucceeded; // 已开通 / Activated
String get payExpiresAt; // 有效期至 / Valid until
String get payDone; // 完成 / Done
String get payFailed; // 支付失败 / Payment failed
String get payRetry; // 重试 / Retry
String get switchPayMethod; // 换一种支付方式 / Switch payment method
String get cancelOrder; // 取消订单 / Cancel order
String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
}
+55
View File
@@ -332,4 +332,59 @@ class StringsEn extends AppText {
String get ob3Title => 'Private by design';
@override
String get ob3Sub => 'End-to-end encrypted. We keep zero browsing logs.';
@override
String get purchaseTitle => 'Purchase';
@override
String get paymentTitle => 'Payment';
@override
String get buyNow => 'Buy now';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'Choose payment method';
@override
String get proMonthly => 'Pro · Monthly';
@override
String get proQuarterly => 'Pro · Quarterly';
@override
String get proYearly => 'Pro · Yearly';
@override
String get perQuarter => '/quarter';
@override
String get perYear => '/year';
@override
String get payAmountLabel => 'Amount';
@override
String get payAddressLabel => 'Address';
@override
String get payNetworkLabel => 'Network';
@override
String get payExactAmountHint => 'Send the exact amount; activates automatically';
@override
String get copied => 'Copied';
@override
String get openAlipay => 'Pay with Alipay';
@override
String get openAlipayHint => 'Return after paying; this page refreshes automatically';
@override
String get awaitingPayment => 'Awaiting payment';
@override
String get paySucceeded => 'Activated';
@override
String get payExpiresAt => 'Valid until';
@override
String get payDone => 'Done';
@override
String get payFailed => 'Payment failed';
@override
String get payRetry => 'Retry';
@override
String get switchPayMethod => 'Switch payment method';
@override
String get cancelOrder => 'Cancel order';
@override
String get qrNotSupported => 'Copy and open in Alipay';
}
+55
View File
@@ -326,4 +326,59 @@ class StringsZh extends AppText {
String get ob3Title => '安全 · 无日志';
@override
String get ob3Sub => '端到端加密,我们不记录你的任何浏览数据。';
@override
String get purchaseTitle => '购买套餐';
@override
String get paymentTitle => '支付';
@override
String get buyNow => '立即购买';
@override
String get payMethodAlipay => '支付宝';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => '选择支付方式';
@override
String get proMonthly => '专业版·月付';
@override
String get proQuarterly => '专业版·季付';
@override
String get proYearly => '专业版·年付';
@override
String get perQuarter => '/季';
@override
String get perYear => '/年';
@override
String get payAmountLabel => '转账金额';
@override
String get payAddressLabel => '收款地址';
@override
String get payNetworkLabel => '网络';
@override
String get payExactAmountHint => '金额须精确一致,到账后自动开通';
@override
String get copied => '已复制';
@override
String get openAlipay => '打开支付宝支付';
@override
String get openAlipayHint => '完成支付后返回,本页会自动刷新';
@override
String get awaitingPayment => '等待付款';
@override
String get paySucceeded => '已开通';
@override
String get payExpiresAt => '有效期至';
@override
String get payDone => '完成';
@override
String get payFailed => '支付失败';
@override
String get payRetry => '重试';
@override
String get switchPayMethod => '换一种支付方式';
@override
String get cancelOrder => '取消订单';
@override
String get qrNotSupported => '请复制内容后在支付宝内打开';
}
+25 -1
View File
@@ -12,6 +12,8 @@ import '../state/navigation_provider.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/pangolin_icons.dart';
import 'payment_page.dart';
import 'purchase_page.dart';
/// 格式化到期日为 YYYY-MM-DD(本地时区)。
String _fmtDate(DateTime d) {
@@ -49,7 +51,29 @@ class AccountPage extends ConsumerWidget {
final body = ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
children: [
_PlanBanner(t: t, isFree: isFree, email: email, expiresLabel: expiresLabel, onUpgrade: () => open(NavView.plans, PlansScreen(t: t))),
_PlanBanner(
t: t,
isFree: isFree,
email: email,
expiresLabel: expiresLabel,
// mobile/tablet 全屏 push(desktop 走 open() 的 navView 分支,mobilePage 参数被忽略,
// 由 desktop_shell 自己那份 PlansScreen(onChoose: ...) 接线,见 desktop_shell.dart)。
onUpgrade: () => open(
NavView.plans,
PlansScreen(
t: t,
onChoose: (code) => code == 'pro'
? Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))),
),
))
: Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(t: t))),
),
),
),
const SizedBox(height: 18),
// 账户信息
_SectionLabel(text: t.accInfoTitle),
+266
View File
@@ -0,0 +1,266 @@
// 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 的相位),cancel() 停轮询 + 视同放弃本单
// (与「取消订单」按钮同一路径),避免永久轮询。
//
// 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发
// cancel() 的 state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的
// Element,炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道),
// 光 catchError 接不住。用 scheduleMicrotask 挪到本次 unmount 收尾之后再执行——
// 3s 轮询周期下,微任务级别的延迟不影响“停轮询”这个目的。执行时点若容器已把
// controller 一并 dispose(如整页 ProviderScope 随之销毁),用公开的 `mounted` 短路,
// 不调用已销毁实例。
if (_phase == PaymentPhase.awaitingPayment) {
final ctl = _ctl;
if (ctl != null) {
scheduleMicrotask(() {
if (ctl.mounted) ctl.cancel();
});
}
}
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);
}
// 卡片配方 = 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: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'),
mode: LaunchMode.externalApplication),
),
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),
]),
),
);
}
}
+135
View File
@@ -0,0 +1,135 @@
// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。
// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/payment.dart';
import '../pangolin_theme.dart';
import '../state/payment_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/plan_card.dart';
class PurchaseScreen extends ConsumerWidget {
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
/// 下单成功(进入 awaitingPayment)后由壳导航到支付页。
final VoidCallback? onOrderCreated;
final bool embedded;
String _name(String sku) => switch (sku) {
'pro_month' => t.proMonthly,
'pro_quarter' => t.proQuarterly,
'pro_year' => t.proYearly,
_ => sku,
};
String _period(String sku) => switch (sku) {
'pro_month' => t.perMonth,
'pro_quarter' => t.perQuarter,
'pro_year' => t.perYear,
_ => '',
};
Future<void> _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async {
final c = context.pangolin;
final method = await showModalBottomSheet<String>(
context: context,
backgroundColor: c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(PangolinRadius.xl)),
),
builder: (sheetCtx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(t.choosePayMethod,
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
),
),
ListTile(
leading: Icon(PangolinIcons.creditCard, color: c.fg2),
title: Text(t.payMethodAlipay, style: PangolinText.body.copyWith(color: c.fg1)),
trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
onTap: () => Navigator.of(sheetCtx).pop('alipay'),
),
ListTile(
leading: Icon(PangolinIcons.globe, color: c.fg2),
title: Text(t.payMethodCrypto, style: PangolinText.body.copyWith(color: c.fg1)),
trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
onTap: () => Navigator.of(sheetCtx).pop('crypto'),
),
const SizedBox(height: 12),
]),
),
);
if (method == null || !context.mounted) return;
await ref.read(paymentFlowProvider.notifier).start(item, method);
if (!context.mounted) return;
if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) {
onOrderCreated?.call();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final catalog = ref.watch(payCatalogProvider);
final body = catalog.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
error: (_, __) => Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry',
style: PangolinText.body.copyWith(color: c.fg3)),
),
),
data: (items) => ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final it in items)
Padding(
padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0),
child: PlanCard(
name: _name(it.sku),
price: it.priceLabel(),
period: _period(it.sku),
features: t.featsPro,
ctaLabel: t.buyNow,
featured: it.sku == 'pro_year',
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
onPressed: () => _choose(context, ref, it),
),
),
],
),
);
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.purchaseTitle,
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
);
}
}
+19 -3
View File
@@ -9,6 +9,8 @@ import '../screens/account_page.dart';
import '../screens/connect_page.dart';
import '../screens/contact_page.dart';
import '../screens/nodes_page.dart';
import '../screens/payment_page.dart';
import '../screens/purchase_page.dart';
import '../screens/settings_page.dart';
import '../screens/stats_page.dart';
import '../state/app_providers.dart';
@@ -46,6 +48,8 @@ class DesktopShell extends ConsumerWidget {
NavView.plans: t.choosePlan,
NavView.redeem: t.redeemTitle,
NavView.devices: t.myDevices,
NavView.purchase: t.purchaseTitle,
NavView.payment: t.paymentTitle,
};
void go(NavView v) => ref.read(navViewProvider.notifier).state = v;
@@ -66,15 +70,27 @@ class DesktopShell extends ConsumerWidget {
return const SettingsPage();
// 账户下钻子页(内容区,顶栏带返回)
case NavView.plans:
return PlansScreen(t: t, embedded: true, onChoose: (_) => go(NavView.redeem));
// 只有 pro 走 pay v2 购买通道;team 等仍走既有兑换/联系人工流程。
return PlansScreen(t: t, embedded: true, onChoose: (code) => go(code == 'pro' ? NavView.purchase : NavView.redeem));
case NavView.redeem:
return RedeemScreen(t: t, embedded: true);
case NavView.devices:
return DevicesScreen(t: t, embedded: true);
case NavView.purchase:
return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment));
case NavView.payment:
return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account));
}
}
final isSub = kAccountSubViews.contains(view);
// 子页返回目标:payment → purchase(换方式/失败重试仍在购买语境内);
// 其余下钻子页(含 purchase 本身)→ account。
VoidCallback? backTarget() {
if (view == NavView.payment) return () => go(NavView.purchase);
if (kAccountSubViews.contains(view)) return () => go(NavView.account);
return null;
}
return ColoredBox(
color: c.bg,
child: Row(children: [
@@ -83,7 +99,7 @@ class DesktopShell extends ConsumerWidget {
child: Column(children: [
ContentTopBar(
title: titles[view] ?? t.brand,
onBack: isSub ? () => go(NavView.account) : null,
onBack: backTarget(),
),
Expanded(child: content()),
]),
+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();
+4
View File
@@ -60,6 +60,9 @@ class PangolinIcons {
static const alertTriangle= LucideIcons.alertTriangle;
static const edit = LucideIcons.pencil;
// ── 支付 ──
static const copy = LucideIcons.copy;
static IconData? byName(String name) => _byName[name];
static const Map<String, IconData> _byName = {
@@ -72,5 +75,6 @@ class PangolinIcons {
'credit-card': creditCard, 'shopping-bag': shoppingBag, 'mail': mail, 'lock': lock,
'log-out': logOut, 'send': send, 'message-circle': messageCircle, 'external-link': externalLink,
'laptop': laptop, 'smartphone': smartphone, 'monitor-smartphone': monitorSmartphone,
'copy': copy,
};
}
@@ -11,6 +11,7 @@ import package_info_plus
import screen_retriever_macos
import shared_preferences_foundation
import tray_manager
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -20,5 +21,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+1
View File
@@ -25,6 +25,7 @@ dependencies:
launch_at_startup: ^0.5.1
tray_manager: ^0.5.3
window_manager: ^0.5.1
url_launcher: ^6.3.0 # 支付 redirect(如支付宝)拉起外部浏览器/App
dev_dependencies:
flutter_test:
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/l10n/strings_zh.dart';
import 'package:pangolin_vpn/models/payment.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
import 'package:pangolin_vpn/screens/payment_page.dart';
import 'package:pangolin_vpn/screens/purchase_page.dart';
import 'package:pangolin_vpn/services/payment_api.dart';
import 'package:pangolin_vpn/state/payment_provider.dart';
import 'package:pangolin_vpn/widgets/plan_card.dart';
import '../helpers/harness.dart';
const _items = [
PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'),
PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'),
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'),
];
/// 轮询生命周期测试专用假 API:只记 orderStatus 调用次数,其余按最小实现返回。
class _PollingFakePaymentApi implements PaymentApi {
int statusCalls = 0;
@override
Future<List<PayCatalogItem>> catalog() async => const [];
@override
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async =>
const PayOrder(
orderNo: 'pay1',
session: PaySession(renderType: 'crypto_address', payload: {'address': 'TXYZabc123', 'amount': '4.20'}),
);
@override
Future<PayOrderStatus> orderStatus(String orderNo) async {
statusCalls++;
return PayOrderStatus(orderNo: orderNo, payStatus: 'pending', activated: false, expiresAt: null);
}
@override
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'}));
@override
Future<void> cancel(String orderNo) async {}
}
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
testWidgets('购买页:三档 PlanCard + 价格', (tester) async {
// 默认测试视口(800×600)放不下三张 PlanCard(ListView 非懒加载但受
// Viewport cacheExtent 限制,第三张滚动区外不构建)——加高视口,
// 不改断言本身。
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(wrapThemed(
PurchaseScreen(t: t, embedded: true),
overrides: [payCatalogProvider.overrideWith((ref) async => _items)],
));
await tester.pumpAndSettle();
expect(find.byType(PlanCard), findsNWidgets(3));
expect(find.text('¥29.99'), findsOneWidget);
expect(find.text('¥199.99'), findsOneWidget);
expect(find.text(t.proQuarterly), findsOneWidget);
});
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {
final flow = PaymentFlowState(
phase: PaymentPhase.awaitingPayment,
item: _items.first,
method: 'crypto',
order: const PayOrder(
orderNo: 'pay1',
session: PaySession(renderType: 'crypto_address', payload: {
'address': 'TXYZabc123',
'amount': '4.201234',
'amount_minor': 4201234,
'currency': 'USDT',
'network': 'TRC20',
}),
),
);
await tester.pumpWidget(wrapThemed(
PaymentScreen(t: t, embedded: true),
overrides: [
paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow)),
],
));
await tester.pump();
expect(find.text('TXYZabc123'), findsOneWidget);
expect(find.textContaining('4.201234'), findsOneWidget);
expect(find.text(t.payExactAmountHint), findsOneWidget);
expect(find.text(t.awaitingPayment), findsOneWidget);
});
testWidgets('支付页 redirect:外链按钮 + 引导文案', (tester) async {
final flow = PaymentFlowState(
phase: PaymentPhase.awaitingPayment,
item: _items.first,
method: 'alipay',
order: const PayOrder(
orderNo: 'pay1',
session: PaySession(renderType: 'redirect', payload: {'url': 'https://alipay.example/x'}),
),
);
await tester.pumpWidget(wrapThemed(
PaymentScreen(t: t, embedded: true),
overrides: [paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))],
));
await tester.pump();
expect(find.text(t.openAlipay), findsOneWidget);
expect(find.text(t.openAlipayHint), findsOneWidget);
});
testWidgets('支付页成功态:已开通 + 完成', (tester) async {
final flow = PaymentFlowState(
phase: PaymentPhase.succeeded,
item: _items.first,
method: 'crypto',
status: PayOrderStatus(
orderNo: 'pay1', payStatus: 'succeeded', activated: true,
expiresAt: DateTime.utc(2026, 8, 10)),
);
await tester.pumpWidget(wrapThemed(
PaymentScreen(t: t, embedded: true),
overrides: [paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))],
));
await tester.pump();
expect(find.text(t.paySucceeded), findsOneWidget);
expect(find.text(t.payDone), findsOneWidget);
});
testWidgets('支付页 dispose 后停止轮询(离开页面不再调 orderStatus)', (tester) async {
final api = _PollingFakePaymentApi();
final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]);
addTearDown(container.dispose);
await container.read(paymentFlowProvider.notifier).start(_items.first, 'crypto');
expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment);
final showPage = ValueNotifier(true);
addTearDown(showPage.dispose);
await tester.pumpWidget(UncontrolledProviderScope(
container: container,
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: PangolinTheme.light,
home: ValueListenableBuilder<bool>(
valueListenable: showPage,
builder: (_, show, __) => show ? PaymentScreen(t: t, embedded: true) : const SizedBox.shrink(),
),
),
));
await tester.pump();
// 页面挂载期间:轮询按 3s 间隔正常触发。
await tester.pump(const Duration(seconds: 4));
final callsWhileMounted = api.statusCalls;
expect(callsWhileMounted, greaterThan(0));
// 卸载支付页(触发 dispose)→ 停止轮询,之后不应再新增 orderStatus 调用。
showPage.value = false;
await tester.pump();
await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(seconds: 4));
expect(api.statusCalls, callsWhileMounted, reason: '支付页卸载后轮询应已停止');
});
}
@@ -9,6 +9,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <tray_manager/tray_manager_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
TrayManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("TrayManagerPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
screen_retriever_windows
tray_manager
url_launcher_windows
window_manager
)