diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 0f41e82..be4ecb6 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -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 } diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 79a4947..6f93355 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -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'; } diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index df4d680..1ab4019 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -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 => '请复制内容后在支付宝内打开'; } diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 7ad6188..2a6af06 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -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), diff --git a/client/lib/screens/payment_page.dart b/client/lib/screens/payment_page.dart new file mode 100644 index 0000000..b3a56c4 --- /dev/null +++ b/client/lib/screens/payment_page.dart @@ -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 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 的相位),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 _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 _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), + ]), + ), + ); + } +} diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart new file mode 100644 index 0000000..02f6bf2 --- /dev/null +++ b/client/lib/screens/purchase_page.dart @@ -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 _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async { + final c = context.pangolin; + final method = await showModalBottomSheet( + 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), + ]), + ), + ); + } +} diff --git a/client/lib/shell/desktop_shell.dart b/client/lib/shell/desktop_shell.dart index 011ee64..effbd59 100644 --- a/client/lib/shell/desktop_shell.dart +++ b/client/lib/shell/desktop_shell.dart @@ -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()), ]), diff --git a/client/lib/state/navigation_provider.dart b/client/lib/state/navigation_provider.dart index a9fa899..9ea0451 100644 --- a/client/lib/state/navigation_provider.dart +++ b/client/lib/state/navigation_provider.dart @@ -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 kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices}; +const Set kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment}; /// 当前视图。 final navViewProvider = StateProvider((ref) => NavView.connect); diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart index 0d57b58..9cbbc7e 100644 --- a/client/lib/state/payment_provider.dart +++ b/client/lib/state/payment_provider.dart @@ -63,9 +63,15 @@ class PaymentFlowState { } class PaymentFlowController extends StateNotifier { - 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 { _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 { 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 { 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 { Future 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 { 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(); diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index 6a649e7..b4d70fc 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -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 _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, }; } diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 5942a33..5c0bdfd 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 5cc3661..bd21057 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -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: diff --git a/client/test/widget/payment_pages_test.dart b/client/test/widget/payment_pages_test.dart new file mode 100644 index 0000000..64dd82d --- /dev/null +++ b/client/test/widget/payment_pages_test.dart @@ -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> catalog() async => const []; + + @override + Future createOrder({required String sku, required String method, Map? metadata}) async => + const PayOrder( + orderNo: 'pay1', + session: PaySession(renderType: 'crypto_address', payload: {'address': 'TXYZabc123', 'amount': '4.20'}), + ); + + @override + Future orderStatus(String orderNo) async { + statusCalls++; + return PayOrderStatus(orderNo: orderNo, payStatus: 'pending', activated: false, expiresAt: null); + } + + @override + Future retry(String orderNo, {required String method, Map? metadata}) async => + const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'})); + + @override + Future 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( + 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: '支付页卸载后轮询应已停止'); + }); +} diff --git a/client/windows/flutter/generated_plugin_registrant.cc b/client/windows/flutter/generated_plugin_registrant.cc index a55a983..e6fb69b 100644 --- a/client/windows/flutter/generated_plugin_registrant.cc +++ b/client/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include 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")); } diff --git a/client/windows/flutter/generated_plugins.cmake b/client/windows/flutter/generated_plugins.cmake index 6adb6fa..752feb5 100644 --- a/client/windows/flutter/generated_plugins.cmake +++ b/client/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows screen_retriever_windows tray_manager + url_launcher_windows window_manager )