feat(client): pay v2 支付渠道 switch + 订单列表/详情端到端

购买/支付(Phase B):
- 新 SegSwitch 段控 widget(镜像 .segswitch 原子)
- 购买页重构:顶部支付渠道 switch(默认 USDT→加密货币 / 人民币→支付宝),套餐价随渠道分币种显示,删底部弹窗选方式;点购买直达支付页
- 支付页加订单信息卡(套餐/渠道/金额按币种)
- models:PayChannel + PayCatalogItem.priceUsdtMicro/priceLabel(ch)/perMonthLabel(ch)

订单(P0,全新):
- payment_api.listOrders() + orders_provider(列表 + 详情 family)
- OrdersScreen 列表→详情(状态 pill / 可复制订单号 / 继续支付·重新购买)
- 导航:NavView.orders + desktop_shell 分支 + account 入口

配套:
- l10n +19 getter(支付渠道 4 + 订单 15)×6 语言,l10n 闸绿
- 图标单源迁移 lucide_icons_flutter + pangolin_icons.dart(codegen)
- 联系渠道订正(Telegram/邮件 support@yanmeiai.com/官网) + 前端去广告&时长
- 测试:购买 switch 分币种 + 订单列表/空态/详情 + 假 API listOrders;golden 重录
- flutter analyze 0 error,flutter test 226 全绿

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-12 00:07:15 +08:00
parent f6ce5267d5
commit 272eca04ce
66 changed files with 1398 additions and 262 deletions
+25 -3
View File
@@ -4,8 +4,8 @@
// `localeProvider` 段控切换。所有界面文案必须经此处取用,禁止在组件内
// 写死中/英字面量。zh / en 两份资源分别在 strings_zh.dart / strings_en.dart。
//
// 红线词约束(设计铁律 §13):资源文件不得出现 VPN / 翻墙 / 科学上网 /
// 突破封锁 / 自由穿越 / Go anywhere。CI `ci/scan-redline.sh` 对本目录做扫描。
// 红线词约束(设计铁律 §13):资源文件不得出现审查规避/敏感定位词,
// 具体清单见 design/CLAUDE.md §1 铁律 13;CI `ci/scan-redline.sh` 对本目录做扫描。
//
// 注意:这是手写资源(非 flutter gen-l10n 代码生成),保证离线可编译;
// 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。
@@ -502,6 +502,7 @@ abstract class AppText {
String get contactIntro;
String get contactEmail;
String get contactStore;
String get contactWebsite;
String get contactHoursTitle;
String get contactHours;
@@ -564,5 +565,26 @@ abstract class AppText {
String get payRetry; // 重试 / Retry
String get switchPayMethod; // 换一种支付方式 / Switch payment method
String get cancelOrder; // 取消订单 / Cancel order
String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
String get qrNotSupported;
// ── 支付渠道 switch + 订单 ──
String get payChannel;
String get payChannelUsdt;
String get payChannelCny;
String get perMonthSuffix;
String get ordersTitle;
String get orderDetailTitle;
String get ordersEmpty;
String get orderNoLabel;
String get orderPlanLabel;
String get orderMethodLabel;
String get orderAmountLabel;
String get orderCreatedLabel;
String get orderPaidLabel;
String get orderStatusPaid;
String get orderStatusCanceled;
String get orderContinuePay;
String get orderRefresh;
String get orderRebuy;
String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay
}
+44 -2
View File
@@ -1,6 +1,6 @@
// strings_en.dart — English copy resources (single-language display)
//
// Red-line words forbidden: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere.
// Red-line words forbidden — see design/CLAUDE.md §1 rule 13 (no censorship-circumvention terms).
// Positioning: network acceleration / experience optimization + privacy.
// Plan numbers follow design/CLAUDE.md §7.
import 'app_text.dart';
@@ -65,7 +65,7 @@ class StringsEn extends AppText {
@override
String get watchAdMore => 'Watch ad for more time';
@override
String get quotaExhaustedNotice => 'Daily free time used up. Watch an ad to add time or upgrade.';
String get quotaExhaustedNotice => "You've used today's free time. Upgrade to keep connecting.";
@override
String get adPlaying => 'Ad playing…';
@override
@@ -311,6 +311,8 @@ class StringsEn extends AppText {
@override
String get contactStore => 'Self-serve store';
@override
String get contactWebsite => 'Website';
@override
String get contactHoursTitle => 'Support hours';
@override
String get contactHours => 'Daily 9:00 24:00 (GMT+8)';
@@ -427,4 +429,44 @@ class StringsEn extends AppText {
String get cancelOrder => 'Cancel order';
@override
String get qrNotSupported => 'Copy and open in Alipay';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => 'Payment channel';
@override
String get payChannelUsdt => 'USDT · Crypto';
@override
String get payChannelCny => 'CNY · Alipay';
@override
String get perMonthSuffix => ' /mo';
@override
String get ordersTitle => 'My orders';
@override
String get orderDetailTitle => 'Order details';
@override
String get ordersEmpty => 'No orders yet';
@override
String get orderNoLabel => 'Order no.';
@override
String get orderPlanLabel => 'Plan';
@override
String get orderMethodLabel => 'Payment method';
@override
String get orderAmountLabel => 'Amount';
@override
String get orderCreatedLabel => 'Created';
@override
String get orderPaidLabel => 'Paid at';
@override
String get orderStatusPaid => 'Activated';
@override
String get orderStatusCanceled => 'Canceled';
@override
String get orderContinuePay => 'Continue payment';
@override
String get orderRefresh => 'Refresh';
@override
String get orderRebuy => 'Buy again';
@override
String get payMethodNezha => 'Nazhapay';
}
+102 -2
View File
@@ -1,6 +1,6 @@
// strings_es.dart — Recursos de texto en español (visualización monolingüe)
//
// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere.
// Palabras prohibidas: términos de evasión de censura. Lista en design/CLAUDE.md §1 regla 13.
// Posicionamiento: aceleración de red / optimización de la experiencia + privacidad.
// La numeración de planes sigue design/CLAUDE.md §7.
import 'app_text.dart';
@@ -65,7 +65,7 @@ class StringsEs extends AppText {
@override
String get watchAdMore => 'Mira un anuncio para más tiempo';
@override
String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.';
String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mejora tu plan para seguir conectado.';
@override
String get adPlaying => 'Reproduciendo anuncio…';
@override
@@ -311,6 +311,8 @@ class StringsEs extends AppText {
@override
String get contactStore => 'Tienda autoservicio';
@override
String get contactWebsite => 'Sitio web';
@override
String get contactHoursTitle => 'Horario de soporte';
@override
String get contactHours => 'Todos los días 9:00 24:00 (GMT+8)';
@@ -370,4 +372,102 @@ class StringsEs extends AppText {
String get ob3Title => 'Privado por diseño';
@override
String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.';
// ── 购买 / 支付 ──
@override
String get purchaseTitle => 'Comprar plan';
@override
String get paymentTitle => 'Pago';
@override
String get buyNow => 'Comprar ahora';
@override
String get payMethodAlipay => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'Elige el método de pago';
@override
String get proMonthly => 'Pro · Mensual';
@override
String get proQuarterly => 'Pro · Trimestral';
@override
String get proYearly => 'Pro · Anual';
@override
String get perQuarter => '/trimestre';
@override
String get perYear => '/año';
@override
String get payAmountLabel => 'Importe a transferir';
@override
String get payAddressLabel => 'Dirección de pago';
@override
String get payNetworkLabel => 'Red';
@override
String get payExactAmountHint => 'Envía el importe exacto; se activa automáticamente';
@override
String get copied => 'Copiado';
@override
String get openAlipay => 'Pagar con Alipay';
@override
String get openAlipayHint => 'Vuelve tras pagar; esta página se actualiza automáticamente';
@override
String get openAlipayFailed => 'No se pudo abrir Alipay; comprueba que esté instalado o inténtalo de nuevo';
@override
String get awaitingPayment => 'Esperando el pago';
@override
String get paySucceeded => 'Activado';
@override
String get payExpiresAt => 'Válido hasta';
@override
String get payDone => 'Listo';
@override
String get payFailed => 'El pago falló';
@override
String get payRetry => 'Reintentar';
@override
String get switchPayMethod => 'Cambiar método de pago';
@override
String get cancelOrder => 'Cancelar pedido';
@override
String get qrNotSupported => 'Copia el contenido y ábrelo en Alipay';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => 'Canal de pago';
@override
String get payChannelUsdt => 'USDT · Cripto';
@override
String get payChannelCny => 'CNY · Alipay';
@override
String get perMonthSuffix => ' /mes';
@override
String get ordersTitle => 'Mis pedidos';
@override
String get orderDetailTitle => 'Detalles del pedido';
@override
String get ordersEmpty => 'Aún no hay pedidos';
@override
String get orderNoLabel => 'N.º de pedido';
@override
String get orderPlanLabel => 'Plan';
@override
String get orderMethodLabel => 'Método de pago';
@override
String get orderAmountLabel => 'Importe';
@override
String get orderCreatedLabel => 'Creado';
@override
String get orderPaidLabel => 'Pagado';
@override
String get orderStatusPaid => 'Activado';
@override
String get orderStatusCanceled => 'Cancelado';
@override
String get orderContinuePay => 'Continuar pago';
@override
String get orderRefresh => 'Actualizar';
@override
String get orderRebuy => 'Comprar de nuevo';
@override
String get payMethodNezha => 'Nazhapay';
}
+102 -2
View File
@@ -1,6 +1,6 @@
// strings_ja.dart — 日本語の文言リソース(単一言語表示)
//
// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere
// 禁止ワード: 検閲回避・センシティブな表現。一覧は design/CLAUDE.md §1 ルール13を参照
// ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。
// プラン番号は design/CLAUDE.md §7 に準拠。
import 'app_text.dart';
@@ -65,7 +65,7 @@ class StringsJa extends AppText {
@override
String get watchAdMore => '広告を見て時間を追加';
@override
String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください';
String get quotaExhaustedNotice => '本日の無料時間を使い切りました。アップグレードすると続けて接続できます';
@override
String get adPlaying => '広告を再生中…';
@override
@@ -311,6 +311,8 @@ class StringsJa extends AppText {
@override
String get contactStore => 'セルフサービスストア';
@override
String get contactWebsite => '公式サイト';
@override
String get contactHoursTitle => 'サポート時間';
@override
String get contactHours => '毎日 9:00 24:00 (GMT+8)';
@@ -370,4 +372,102 @@ class StringsJa 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 => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'お支払い方法を選択';
@override
String get proMonthly => 'Pro · 月額';
@override
String get proQuarterly => 'Pro · 3か月';
@override
String get proYearly => 'Pro · 年額';
@override
String get perQuarter => '/3か月';
@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 => 'Alipayで支払う';
@override
String get openAlipayHint => 'お支払い後に戻ると、このページは自動的に更新されます';
@override
String get openAlipayFailed => 'Alipayを開けませんでした。インストール済みか確認するか、後でもう一度お試しください';
@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 => '内容をコピーしてAlipayで開いてください';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => '支払いチャネル';
@override
String get payChannelUsdt => 'USDT · 暗号資産';
@override
String get payChannelCny => '人民元 · Alipay';
@override
String get perMonthSuffix => ' /月';
@override
String get ordersTitle => '注文履歴';
@override
String get orderDetailTitle => '注文の詳細';
@override
String get ordersEmpty => '注文はありません';
@override
String get orderNoLabel => '注文番号';
@override
String get orderPlanLabel => 'プラン';
@override
String get orderMethodLabel => '支払い方法';
@override
String get orderAmountLabel => '金額';
@override
String get orderCreatedLabel => '作成日時';
@override
String get orderPaidLabel => '支払日時';
@override
String get orderStatusPaid => '有効化済み';
@override
String get orderStatusCanceled => 'キャンセル済み';
@override
String get orderContinuePay => '支払いを続ける';
@override
String get orderRefresh => '更新';
@override
String get orderRebuy => '再購入';
@override
String get payMethodNezha => 'Nazhapay';
}
+102 -2
View File
@@ -1,6 +1,6 @@
// strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시)
//
// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere.
// 금지어: 검열 우회·민감 표현. 목록은 design/CLAUDE.md §1 규칙 13 참조.
// 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시.
// 요금제 번호는 design/CLAUDE.md §7 을 따름.
import 'app_text.dart';
@@ -65,7 +65,7 @@ class StringsKo extends AppText {
@override
String get watchAdMore => '광고 시청으로 시간 추가';
@override
String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.';
String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 업그레이드하면 계속 연결할 수 있습니다.';
@override
String get adPlaying => '광고 재생 중…';
@override
@@ -311,6 +311,8 @@ class StringsKo extends AppText {
@override
String get contactStore => '셀프 스토어';
@override
String get contactWebsite => '공식 웹사이트';
@override
String get contactHoursTitle => '지원 시간';
@override
String get contactHours => '매일 9:00 24:00 (GMT+8)';
@@ -370,4 +372,102 @@ class StringsKo 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 => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => '결제 수단 선택';
@override
String get proMonthly => 'Pro · 월간';
@override
String get proQuarterly => 'Pro · 분기';
@override
String get proYearly => 'Pro · 연간';
@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 => 'Alipay로 결제';
@override
String get openAlipayHint => '결제 후 돌아오면 이 페이지가 자동으로 새로고침됩니다';
@override
String get openAlipayFailed => 'Alipay를 열 수 없습니다. 설치되어 있는지 확인하거나 다시 시도하세요';
@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 => '내용을 복사해 Alipay에서 여세요';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => '결제 채널';
@override
String get payChannelUsdt => 'USDT · 암호화폐';
@override
String get payChannelCny => '위안화 · Alipay';
@override
String get perMonthSuffix => ' /월';
@override
String get ordersTitle => '내 주문';
@override
String get orderDetailTitle => '주문 상세';
@override
String get ordersEmpty => '주문이 없습니다';
@override
String get orderNoLabel => '주문 번호';
@override
String get orderPlanLabel => '플랜';
@override
String get orderMethodLabel => '결제 수단';
@override
String get orderAmountLabel => '금액';
@override
String get orderCreatedLabel => '생성 시각';
@override
String get orderPaidLabel => '결제 시각';
@override
String get orderStatusPaid => '활성화됨';
@override
String get orderStatusCanceled => '취소됨';
@override
String get orderContinuePay => '결제 계속';
@override
String get orderRefresh => '새로고침';
@override
String get orderRebuy => '다시 구매';
@override
String get payMethodNezha => 'Nazhapay';
}
+102 -2
View File
@@ -1,6 +1,6 @@
// strings_ru.dart — русские текстовые ресурсы (отображение на одном языке)
//
// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН.
// Запрещённые слова: термины обхода цензуры. Список см. design/CLAUDE.md §1 правило 13.
// Позиционирование: ускорение сети / оптимизация опыта + приватность.
// Номера тарифов соответствуют design/CLAUDE.md §7.
import 'app_text.dart';
@@ -65,7 +65,7 @@ class StringsRu extends AppText {
@override
String get watchAdMore => 'Смотрите рекламу, чтобы добавить время';
@override
String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.';
String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Оформите подписку, чтобы продолжить подключение.';
@override
String get adPlaying => 'Реклама воспроизводится…';
@override
@@ -311,6 +311,8 @@ class StringsRu extends AppText {
@override
String get contactStore => 'Магазин самообслуживания';
@override
String get contactWebsite => 'Веб-сайт';
@override
String get contactHoursTitle => 'Часы поддержки';
@override
String get contactHours => 'Ежедневно 9:00 24:00 (GMT+8)';
@@ -370,4 +372,102 @@ class StringsRu 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 => 'Alipay';
@override
String get payMethodCrypto => 'USDT (TRC20)';
@override
String get choosePayMethod => 'Выберите способ оплаты';
@override
String get proMonthly => 'Pro · Месяц';
@override
String get proQuarterly => 'Pro · Квартал';
@override
String get proYearly => 'Pro · Год';
@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 => 'Оплатить через Alipay';
@override
String get openAlipayHint => 'Вернитесь после оплаты — страница обновится автоматически';
@override
String get openAlipayFailed => 'Не удалось открыть Alipay. Проверьте, установлен ли он, или повторите';
@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 => 'Скопируйте и откройте в Alipay';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => 'Способ оплаты';
@override
String get payChannelUsdt => 'USDT · Крипто';
@override
String get payChannelCny => 'Юань · Alipay';
@override
String get perMonthSuffix => ' /мес';
@override
String get ordersTitle => 'Мои заказы';
@override
String get orderDetailTitle => 'Детали заказа';
@override
String get ordersEmpty => 'Заказов пока нет';
@override
String get orderNoLabel => 'Номер заказа';
@override
String get orderPlanLabel => 'Тариф';
@override
String get orderMethodLabel => 'Способ оплаты';
@override
String get orderAmountLabel => 'Сумма';
@override
String get orderCreatedLabel => 'Создан';
@override
String get orderPaidLabel => 'Оплачен';
@override
String get orderStatusPaid => 'Активирован';
@override
String get orderStatusCanceled => 'Отменён';
@override
String get orderContinuePay => 'Продолжить оплату';
@override
String get orderRefresh => 'Обновить';
@override
String get orderRebuy => 'Купить снова';
@override
String get payMethodNezha => 'Nazhapay';
}
+44 -2
View File
@@ -1,6 +1,6 @@
// strings_zh.dart — 中文文案资源(单显)
//
// 红线词禁用:VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere
// 红线词禁用:审查规避/敏感定位词,清单见 design/CLAUDE.md §1 铁律 13
// 脱敏口径:网络加速 / 体验优化 + 隐私保护。套餐数字以 design/CLAUDE.md §7 为准。
import 'app_text.dart';
@@ -64,7 +64,7 @@ class StringsZh extends AppText {
@override
String get watchAdMore => '看广告加时';
@override
String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员';
String get quotaExhaustedNotice => '今日免费时长已用完,升级会员可继续使用';
@override
String get adPlaying => '广告播放中…';
@override
@@ -305,6 +305,8 @@ class StringsZh extends AppText {
@override
String get contactStore => '自助发卡商店';
@override
String get contactWebsite => '官网';
@override
String get contactHoursTitle => '服务时间';
@override
String get contactHours => '每日 9:00 24:00 (GMT+8)';
@@ -421,4 +423,44 @@ class StringsZh extends AppText {
String get cancelOrder => '取消订单';
@override
String get qrNotSupported => '请复制内容后在支付宝内打开';
// ── 支付渠道 switch + 订单 ──
@override
String get payChannel => '支付渠道';
@override
String get payChannelUsdt => 'USDT · 加密货币';
@override
String get payChannelCny => '人民币 · 支付宝';
@override
String get perMonthSuffix => ' /月';
@override
String get ordersTitle => '我的订单';
@override
String get orderDetailTitle => '订单详情';
@override
String get ordersEmpty => '暂无订单';
@override
String get orderNoLabel => '订单号';
@override
String get orderPlanLabel => '套餐';
@override
String get orderMethodLabel => '支付方式';
@override
String get orderAmountLabel => '金额';
@override
String get orderCreatedLabel => '创建时间';
@override
String get orderPaidLabel => '付款时间';
@override
String get orderStatusPaid => '已开通';
@override
String get orderStatusCanceled => '已取消';
@override
String get orderContinuePay => '继续支付';
@override
String get orderRefresh => '刷新状态';
@override
String get orderRebuy => '重新购买';
@override
String get payMethodNezha => '哪吒支付';
}
+81 -3
View File
@@ -1,6 +1,15 @@
// payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。
// 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。
/// 支付渠道:结算币种即支付方式(一步定)。usdt→加密货币 / cny→支付宝。
/// 各币种一口价(不做实时汇率换算);实际扣款以 pay 侧为准。
enum PayChannel { usdt, cny }
extension PayChannelX on PayChannel {
String get currency => this == PayChannel.usdt ? 'USDT' : 'CNY';
String get method => this == PayChannel.usdt ? 'crypto' : 'alipay';
}
class PayCatalogItem {
const PayCatalogItem({
required this.sku,
@@ -8,13 +17,15 @@ class PayCatalogItem {
required this.days,
required this.priceMinor,
required this.currency,
this.priceUsdtMicro = 0,
});
final String sku;
final String plan;
final int days;
final int priceMinor;
final String currency;
final int priceMinor; // CNY 分(展示)
final String currency; // CNY
final int priceUsdtMicro; // USDT 微元(展示;各币种一口价)
factory PayCatalogItem.fromJson(Map<String, dynamic> j) => PayCatalogItem(
sku: j['sku'] as String,
@@ -22,9 +33,70 @@ class PayCatalogItem {
days: (j['days'] as num?)?.toInt() ?? 0,
priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0,
currency: j['currency'] as String? ?? 'CNY',
priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0,
);
String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}';
/// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。
int get months {
final m = (days / 30.4).round();
return m < 1 ? 1 : m;
}
/// 按渠道币种给总价文案(USDT $ / CNY ¥)。
String priceLabel(PayChannel ch) => ch == PayChannel.usdt
? '\$${(priceUsdtMicro / 1000000).toStringAsFixed(2)}'
: '¥${(priceMinor / 100).toStringAsFixed(2)}';
/// 按渠道币种给每月折算文案(不含 /月 后缀,后缀由 l10n 提供)。
String perMonthLabel(PayChannel ch) => ch == PayChannel.usdt
? '\$${(priceUsdtMicro / 1000000 / months).toStringAsFixed(2)}'
: '¥${(priceMinor / 100 / months).toStringAsFixed(2)}';
}
/// PayOrderSummary — 订单列表项(server orderView 台账视图)。
/// 金额为结算币种 minor:CNY 分 / USDT 微元。
class PayOrderSummary {
const PayOrderSummary({
required this.orderNo,
required this.sku,
required this.plan,
required this.days,
required this.method,
required this.status,
required this.amountMinor,
required this.currency,
required this.createdAt,
this.paidAt,
});
final String orderNo;
final String sku;
final String plan;
final int days;
final String method; // crypto | alipay | nezha …
final String status; // created | paid | canceled(本地台账)
final int amountMinor;
final String currency; // CNY | USDT
final DateTime? createdAt;
final DateTime? paidAt;
factory PayOrderSummary.fromJson(Map<String, dynamic> j) => PayOrderSummary(
orderNo: j['order_no'] as String? ?? '',
sku: j['sku'] as String? ?? '',
plan: j['plan'] as String? ?? 'pro',
days: (j['days'] as num?)?.toInt() ?? 0,
method: j['method'] as String? ?? '',
status: j['status'] as String? ?? '',
amountMinor: (j['amount_minor'] as num?)?.toInt() ?? 0,
currency: j['currency'] as String? ?? 'CNY',
createdAt: j['created_at'] == null ? null : DateTime.tryParse(j['created_at'] as String),
paidAt: j['paid_at'] == null ? null : DateTime.tryParse(j['paid_at'] as String),
);
/// 结算币种金额文案(USDT 6 位、CNY 2 位)。
String amountLabel() => currency == 'USDT'
? '\$${(amountMinor / 1000000).toStringAsFixed(2)}'
: '¥${(amountMinor / 100).toStringAsFixed(2)}';
}
class PaySession {
@@ -53,23 +125,29 @@ class PayOrder {
);
}
/// PayOrderStatus — 查单/详情响应。轮询只用 activated;订单详情页额外用
/// 台账富字段(server GetOrder 现回带 orderView 全字段)。
class PayOrderStatus {
const PayOrderStatus({
required this.orderNo,
required this.payStatus,
required this.activated,
this.expiresAt,
this.summary,
});
final String orderNo;
final String payStatus;
final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据)
final DateTime? expiresAt;
final PayOrderSummary? summary; // 台账富字段(sku/plan/method/金额/时间…);详情页用
factory PayOrderStatus.fromJson(Map<String, dynamic> j) => PayOrderStatus(
orderNo: j['order_no'] as String? ?? '',
payStatus: j['pay_status'] as String? ?? '',
activated: j['activated'] as bool? ?? false,
expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String),
// 详情端点回带台账字段(列表端点同形);sku 存在才解析。
summary: j['sku'] == null ? null : PayOrderSummary.fromJson(j),
);
}
+3
View File
@@ -12,6 +12,7 @@ import '../state/navigation_provider.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/pangolin_icons.dart';
import 'orders_page.dart';
import 'payment_page.dart';
import 'purchase_page.dart';
@@ -87,6 +88,8 @@ class AccountPage extends ConsumerWidget {
_Card(children: [
_NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.ticket, title: t.ordersTitle, onTap: () => open(NavView.orders, OrdersScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(t: t))),
-35
View File
@@ -10,13 +10,10 @@ import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../state/quota_provider.dart';
import '../widgets/ad_reward_dialog.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/connect_button.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/quota_card.dart';
class ConnectPage extends ConsumerWidget {
const ConnectPage({super.key, required this.isWide, required this.onOpenNodes});
@@ -31,8 +28,6 @@ class ConnectPage extends ConsumerWidget {
final conn = ref.watch(connectionProvider);
final node = ref.watch(effectiveNodeProvider);
final smart = ref.watch(isSmartSelectProvider);
final isFree = ref.watch(isFreePlanProvider);
final quota = ref.watch(quotaProvider);
final stats = ref.watch(vpnStatsProvider).valueOrNull;
// 账户首拉中 → 整页转圈,先拉到 me(决定免费/会员)再渲染,
// 避免「先闪免费广告、me 到了再跳会员」。me 是常驻 Notifier,只首启时转一下。
@@ -53,18 +48,11 @@ class ConnectPage extends ConsumerWidget {
VpnPhase.on => t.capOn,
};
// 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。
final isDesktop = context.formFactor == FormFactor.desktop;
final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off);
void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop);
final button = ConnectButton(
phase: conn.phase,
elapsed: conn.elapsed,
offLabel: t.connectNow,
secureLabel: t.secure,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
);
@@ -82,14 +70,6 @@ class ConnectPage extends ConsumerWidget {
]);
final infoChildren = <Widget>[
if (isFree)
QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue),
_CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes),
];
@@ -114,8 +94,6 @@ class ConnectPage extends ConsumerWidget {
offLabel: t.connectNow,
secureLabel: t.secure,
size: 176,
enabled: connectEnabled,
onDisabledTap: onQuotaBlockedTap,
onTap: () => ref.read(connectionProvider.notifier).toggle(),
),
const SizedBox(height: 24),
@@ -132,19 +110,6 @@ class ConnectPage extends ConsumerWidget {
),
],
_NodePill(t: t, node: node, smart: smart, pingLabel: pingLabel),
if (isFree) ...[
const SizedBox(height: 24),
SizedBox(
width: 340,
child: QuotaCard(
quota: quota,
t: t,
countdown: conn.freeCountdown,
isDesktop: isDesktop,
onWatchAd: onQuotaBlockedTap,
),
),
],
if (conn.phase == VpnPhase.on) ...[
const SizedBox(height: 16),
_SpeedRow(t: t, down: down, up: up, latency: latencyValue),
+3 -4
View File
@@ -19,10 +19,9 @@ class ContactPage extends ConsumerWidget {
final t = ref.watch(appTextProvider);
final channels = <({IconData icon, String name, String sub, bool accent})>[
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@yanmeiai.com', accent: false),
(icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false),
];
return SingleChildScrollView(
+392
View File
@@ -0,0 +1,392 @@
// orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。
//
// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。
// App 内无支付表单——「继续支付 / 重新购买」只引导到购买页(PurchaseScreen)。
// 规格参考 design/prototype/screens/orders.html。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/responsive/form_factor.dart';
import '../l10n/app_text.dart';
import '../models/payment.dart';
import '../pangolin_theme.dart';
import '../state/navigation_provider.dart';
import '../state/orders_provider.dart';
import '../widgets/pangolin_button.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/status_pill.dart';
import 'payment_page.dart';
import 'purchase_page.dart';
// ── 文案映射工具(sku→套餐名 / method→支付方式 / status→状态胶囊)──
String _planName(AppText t, PayOrderSummary o) => switch (o.sku) {
'pro_month' => t.proMonthly,
'pro_quarter' => t.proQuarterly,
'pro_year' => t.proYearly,
_ => o.sku.isNotEmpty ? o.sku : o.plan,
};
String _methodName(AppText t, String method) => switch (method) {
'crypto' => t.payMethodCrypto,
'alipay' => t.payMethodAlipay,
'nezha' => t.payMethodNezha,
_ => method,
};
/// 订单状态 → (胶囊色, 文案)。created=等待付款 / paid=已开通 / canceled=已取消。
({PangolinStatus status, String label}) _statusPill(AppText t, String status) =>
switch (status) {
'created' => (status: PangolinStatus.connecting, label: t.awaitingPayment),
'paid' => (status: PangolinStatus.connected, label: t.orderStatusPaid),
'canceled' => (status: PangolinStatus.neutral, label: t.orderStatusCanceled),
_ => (status: PangolinStatus.neutral, label: status),
};
String _fmtDate(DateTime d) {
final l = d.toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${l.year}-${two(l.month)}-${two(l.day)}';
}
String _fmtDateTime(DateTime d) {
final l = d.toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}';
}
/// 带返回栏的子页骨架(embedded=true 只返回内容,返回/标题由外层 shell 顶栏提供)。
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (embedded) return child;
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(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
/// 订单页入口:内部在「列表」与「详情」两屏间切换(与原型单帧同构)。
class OrdersScreen extends ConsumerStatefulWidget {
const OrdersScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
final bool embedded;
@override
ConsumerState<OrdersScreen> createState() => _OrdersScreenState();
}
class _OrdersScreenState extends ConsumerState<OrdersScreen> {
PayOrderSummary? _selected; // 非空 = 展示详情
@override
Widget build(BuildContext context) {
if (_selected != null) {
return _OrderDetailView(
t: widget.t,
order: _selected!,
embedded: widget.embedded,
onBack: () => setState(() => _selected = null),
);
}
return _OrderListView(
t: widget.t,
embedded: widget.embedded,
onBack: widget.onBack,
onOpen: (o) => setState(() => _selected = o),
);
}
}
// ── 列表屏 ──
class _OrderListView extends ConsumerWidget {
const _OrderListView({required this.t, required this.embedded, required this.onOpen, this.onBack});
final AppText t;
final bool embedded;
final ValueChanged<PayOrderSummary> onOpen;
final VoidCallback? onBack;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final ordersAsync = ref.watch(ordersProvider);
Widget refreshBtn() => Align(
alignment: Alignment.centerRight,
child: InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.full),
onTap: () => ref.invalidate(ordersProvider),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: c.bgSubtle,
borderRadius: BorderRadius.circular(PangolinRadius.full),
border: Border.all(color: c.border),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.refreshCw, size: 14, color: c.fg2),
const SizedBox(width: 6),
Text(t.orderRefresh, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)),
]),
),
),
);
Widget empty() => Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.ticket, size: 26, color: c.fg3),
),
const SizedBox(height: 14),
Text(t.ordersEmpty, style: PangolinText.sm.copyWith(color: c.fg3)),
]),
);
Widget content() => ordersAsync.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.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
data: (orders) => ListView(padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [
refreshBtn(),
const SizedBox(height: 12),
if (orders.isEmpty)
empty()
else
Container(
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
clipBehavior: Clip.antiAlias,
child: Column(children: [
for (var i = 0; i < orders.length; i++)
_row(context, c, orders[i], i < orders.length - 1),
]),
),
]),
);
return _SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, child: content());
}
Widget _row(BuildContext context, PangolinScheme c, PayOrderSummary o, bool divider) {
final pill = _statusPill(t, o.status);
final dateStr = o.createdAt != null ? _fmtDate(o.createdAt!) : '';
final sub = [o.amountLabel(), if (dateStr.isNotEmpty) dateStr].join(' · ');
return InkWell(
onTap: () => onOpen(o),
child: Container(
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(children: [
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(PangolinIcons.ticket, size: 19, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(_planName(t, o), overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
const SizedBox(height: 2),
Text(sub, overflow: TextOverflow.ellipsis,
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
]),
),
const SizedBox(width: 8),
StatusPill(label: pill.label, status: pill.status),
const SizedBox(width: 4),
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
// ── 详情屏 ──
class _OrderDetailView extends ConsumerWidget {
const _OrderDetailView({required this.t, required this.order, required this.embedded, required this.onBack});
final AppText t;
final PayOrderSummary order;
final bool embedded;
final VoidCallback onBack;
// 「继续支付 / 重新购买」→ 购买页(desktop 内容区下钻;mobile/tablet 全屏 push)。
void _goPurchase(BuildContext context, WidgetRef ref) {
final isDesktop = context.formFactor == FormFactor.desktop;
if (isDesktop) {
ref.read(navViewProvider.notifier).state = NavView.purchase;
} else {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PaymentScreen(t: t)),
),
),
));
}
}
Future<void> _copy(BuildContext context, String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(t.copied),
behavior: SnackBarBehavior.floating,
duration: const Duration(milliseconds: 1400),
));
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
// 富详情(拿新鲜 expiresAt/status);summary 缺省回退列表项。
final detailAsync = ref.watch(orderDetailProvider(order.orderNo));
final o = detailAsync.valueOrNull?.summary ?? order;
final expiresAt = detailAsync.valueOrNull?.expiresAt;
final pill = _statusPill(t, o.status);
// 内容区(mobile 套 _SubScaffold;desktop embedded 前置一条返回条回到列表)。
Widget summaryCard() => Container(
width: double.infinity,
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.xl),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_planName(t, o), style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
Text(o.amountLabel(), style: PangolinText.h1.copyWith(color: c.fg1, fontWeight: FontWeight.w700, height: 1)),
const SizedBox(height: 14),
StatusPill(label: pill.label, status: pill.status),
]),
);
Widget kvCard() {
final rows = <Widget>[
_kv(c, t.orderNoLabel, o.orderNo, onCopy: () => _copy(context, o.orderNo)),
_kv(c, t.orderPlanLabel, _planName(t, o)),
_kv(c, t.orderMethodLabel, _methodName(t, o.method)),
_kv(c, t.orderAmountLabel, o.amountLabel()),
if (o.createdAt != null) _kv(c, t.orderCreatedLabel, _fmtDateTime(o.createdAt!)),
if (o.paidAt != null) _kv(c, t.orderPaidLabel, _fmtDateTime(o.paidAt!)),
if (expiresAt != null) _kv(c, t.payExpiresAt, _fmtDate(expiresAt)),
];
return Container(
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
clipBehavior: Clip.antiAlias,
child: Column(children: [
for (var i = 0; i < rows.length; i++) ...[
rows[i],
if (i < rows.length - 1) Divider(height: 1, color: c.border),
],
]),
);
}
Widget actions() {
final list = <Widget>[];
switch (o.status) {
case 'created':
list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _goPurchase(context, ref)));
case 'canceled':
list.add(PangolinButton(label: t.orderRebuy, expand: true, onPressed: () => _goPurchase(context, ref)));
case 'paid':
list.add(PangolinButton(label: t.orderRebuy, variant: PangolinButtonVariant.secondary, expand: true, onPressed: () => _goPurchase(context, ref)));
}
if (list.isEmpty) return const SizedBox.shrink();
return Column(children: [for (final w in list) Padding(padding: const EdgeInsets.only(bottom: 10), child: w)]);
}
Widget body() => ListView(padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [
summaryCard(),
const SizedBox(height: 18),
kvCard(),
const SizedBox(height: 20),
actions(),
]);
if (embedded) {
// desktop 内容区:shell 顶栏返回 = 回账户;这里再给一条返回条回到订单列表。
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
InkWell(
onTap: onBack,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.arrowLeft, size: 18, color: c.accent),
const SizedBox(width: 6),
Text(t.orderDetailTitle, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
]),
),
),
Expanded(child: body()),
]);
}
return _SubScaffold(title: t.orderDetailTitle, onBack: onBack, child: body());
}
Widget _kv(PangolinScheme c, String label, String value, {VoidCallback? onCopy}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 92,
child: Text(label, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w500)),
),
const SizedBox(width: 12),
Expanded(
child: Text(value, textAlign: TextAlign.right,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500)),
),
if (onCopy != null) ...[
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.sm),
onTap: onCopy,
child: Padding(padding: const EdgeInsets.all(2), child: Icon(PangolinIcons.copy, size: 15, color: c.fg3)),
),
],
]),
);
}
}
+45
View File
@@ -9,6 +9,7 @@ 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';
@@ -132,6 +133,48 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
);
}
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;
@@ -171,6 +214,8 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
}
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: [
+41 -52
View File
@@ -1,5 +1,7 @@
// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。
// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。
// 支付渠道即币种即方式(USDT→加密货币 默认 / 人民币→支付宝),顶部 SegSwitch 选定,
// 套餐价随渠道币种刷新;点「立即购买」直接下单进支付页(不再底部弹窗选方式)。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -9,8 +11,9 @@ import '../pangolin_theme.dart';
import '../state/payment_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/plan_card.dart';
import '../widgets/seg_switch.dart';
class PurchaseScreen extends ConsumerWidget {
class PurchaseScreen extends ConsumerStatefulWidget {
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
final AppText t;
@@ -19,6 +22,16 @@ class PurchaseScreen extends ConsumerWidget {
final VoidCallback? onOrderCreated;
final bool embedded;
@override
ConsumerState<PurchaseScreen> createState() => _PurchaseScreenState();
}
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
AppText get t => widget.t;
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
PayChannel _channel = PayChannel.usdt;
String _name(String sku) => switch (sku) {
'pro_month' => t.proMonthly,
'pro_quarter' => t.proQuarterly,
@@ -26,57 +39,16 @@ class PurchaseScreen extends ConsumerWidget {
_ => 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;
Future<void> _buy(PayCatalogItem item) async {
await ref.read(paymentFlowProvider.notifier).start(item, _channel.method);
if (!mounted) return;
if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) {
onOrderCreated?.call();
widget.onOrderCreated?.call();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final c = context.pangolin;
final catalog = ref.watch(payCatalogProvider);
@@ -93,25 +65,42 @@ class PurchaseScreen extends ConsumerWidget {
data: (items) => ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
// 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 8),
child: Text(t.payChannel.toUpperCase(),
style: PangolinText.caption.copyWith(
color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)),
),
SegSwitch(
options: [
(icon: PangolinIcons.globe, label: t.payChannelUsdt),
(icon: PangolinIcons.creditCard, label: t.payChannelCny),
],
selectedIndex: _channel == PayChannel.usdt ? 0 : 1,
onChanged: (i) => setState(
() => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny),
),
const SizedBox(height: 18),
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),
price: it.priceLabel(_channel),
period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}',
features: t.featsPro,
ctaLabel: t.buyNow,
featured: it.sku == 'pro_year',
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
onPressed: () => _choose(context, ref, it),
onPressed: () => _buy(it),
),
),
],
),
);
if (embedded) return body;
if (widget.embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
@@ -120,7 +109,7 @@ class PurchaseScreen extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
onPressed: widget.onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.purchaseTitle,
+7
View File
@@ -26,6 +26,13 @@ class PaymentApi {
Future<PayOrderStatus> orderStatus(String orderNo) async =>
PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo'));
/// 订单列表(倒序;纯本地台账,不逐单回查 pay 上游)。
Future<List<PayOrderSummary>> listOrders() async {
final body = await _c.getJson('/v1/pay/orders');
final items = (body['orders'] as List<dynamic>?) ?? const [];
return [for (final it in items) PayOrderSummary.fromJson(it as Map<String, dynamic>)];
}
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', {
'method': method,
+5 -1
View File
@@ -9,6 +9,7 @@ import '../screens/account_page.dart';
import '../screens/connect_page.dart';
import '../screens/contact_page.dart';
import '../screens/nodes_page.dart';
import '../screens/orders_page.dart';
import '../screens/payment_page.dart';
import '../screens/purchase_page.dart';
import '../screens/settings_page.dart';
@@ -33,7 +34,7 @@ class DesktopShell extends ConsumerWidget {
final items = <NavSidebarItem>[
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
(icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings),
(icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact),
];
@@ -50,6 +51,7 @@ class DesktopShell extends ConsumerWidget {
NavView.devices: t.myDevices,
NavView.purchase: t.purchaseTitle,
NavView.payment: t.paymentTitle,
NavView.orders: t.ordersTitle,
};
void go(NavView v) => ref.read(navViewProvider.notifier).state = v;
@@ -80,6 +82,8 @@ class DesktopShell extends ConsumerWidget {
return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment));
case NavView.payment:
return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account));
case NavView.orders:
return OrdersScreen(t: t, embedded: true);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ class TabletShell extends ConsumerWidget {
final items = <NavSidebarItem>[
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
(icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings),
(icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact),
];
+9 -19
View File
@@ -41,7 +41,6 @@ class ConnectionState {
required this.phase,
this.elapsed = Duration.zero,
this.error,
this.freeCountdown,
});
final VpnPhase phase;
@@ -50,15 +49,10 @@ class ConnectionState {
/// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
final String? error;
/// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。
/// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。
final Duration? freeCountdown;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) =>
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(
phase: phase ?? this.phase,
elapsed: elapsed ?? this.elapsed,
freeCountdown: freeCountdown ?? this.freeCountdown,
);
@override
@@ -66,11 +60,10 @@ class ConnectionState {
other is ConnectionState &&
other.phase == phase &&
other.elapsed == elapsed &&
other.error == error &&
other.freeCountdown == freeCountdown;
other.error == error;
@override
int get hashCode => Object.hash(phase, elapsed, error, freeCountdown);
int get hashCode => Object.hash(phase, elapsed, error);
}
// ── 连通看门狗 ─────────────────────────────────────────────────────
@@ -503,17 +496,14 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (!mounted || state.phase != VpnPhase.on || at == null) return;
final elapsed = _now().difference(at);
Duration? countdown;
// 免费版:到点(锁定额度 − 已用秒 ≤ 0)即自动切断(enforcement,非展示;
// 额度 UI 已移除,不再推算展示用倒计时)。null = 会员/额度不限,不切。
final capSec = _freeRemainingSec;
if (capSec != null) {
final leftSec = capSec - elapsed.inSeconds;
if (leftSec <= 0) {
unawaited(_onFreeQuotaExhausted());
return;
}
countdown = Duration(seconds: leftSec);
if (capSec != null && capSec - elapsed.inSeconds <= 0) {
unawaited(_onFreeQuotaExhausted());
return;
}
state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown);
state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed);
}
/// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。
+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, purchase, payment }
enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment, orders }
/// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。
const Set<NavView> kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment};
const Set<NavView> kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders};
/// 当前视图。
final navViewProvider = StateProvider<NavView>((ref) => NavView.connect);
+19
View File
@@ -0,0 +1,19 @@
// orders_provider.dart — 订单台账数据(列表 + 单条详情)。
// 纯本地台账读取(server 不逐单回查 pay 上游);autoDispose 让每次进入订单页
// 拿到新鲜数据,刷新 = ref.invalidate(...)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/payment.dart';
import 'payment_provider.dart';
/// 订单列表(server 倒序返回)。刷新:`ref.invalidate(ordersProvider)`。
final ordersProvider = FutureProvider.autoDispose<List<PayOrderSummary>>(
(ref) => ref.watch(paymentApiProvider).listOrders(),
);
/// 单条订单富详情(含 summary 台账字段 + activated/expiresAt)。
/// 刷新:`ref.invalidate(orderDetailProvider(orderNo))`。
final orderDetailProvider =
FutureProvider.autoDispose.family<PayOrderStatus, String>(
(ref, orderNo) => ref.watch(paymentApiProvider).orderStatus(orderNo),
);
+8 -10
View File
@@ -253,7 +253,7 @@ class DevicesScreen extends ConsumerWidget {
iconColor: c.fg3,
items: [
AdaptiveMenuItem(
icon: PangolinIcons.edit,
icon: PangolinIcons.pencil,
label: t.devRename,
onSelected: () async {
final newName = await _renameDialog(context, c, d);
@@ -264,7 +264,7 @@ class DevicesScreen extends ConsumerWidget {
),
if (!isLocal) ...[
AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)),
AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)),
AdaptiveMenuItem(icon: PangolinIcons.trash2, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)),
],
],
);
@@ -374,10 +374,9 @@ class _RedeemScreenState extends ConsumerState<RedeemScreen> {
final c = context.pangolin;
final t = widget.t;
final channels = [
(icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true),
(icon: PangolinIcons.mail, name: t.chEmail, sub: 'support@yanmeiai.com', accent: false),
(icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false),
];
return _SubScaffold(
title: t.redeemTitle,
@@ -459,10 +458,9 @@ class ContactScreen extends StatelessWidget {
Widget build(BuildContext context) {
final c = context.pangolin;
final channels = [
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@yanmeiai.com', accent: false),
(icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false),
];
return _SubScaffold(
title: t.contactTitle,
+1 -1
View File
@@ -20,7 +20,7 @@ class BottomTabBar extends ConsumerWidget {
final items = <({IconData icon, String label, NavView view})>[
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
(icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats),
(icon: PangolinIcons.user, label: t.tabMe, view: NavView.account),
];
return Container(
+12 -28
View File
@@ -24,19 +24,11 @@ class ConnectButton extends StatefulWidget {
required this.secureLabel,
this.elapsed = Duration.zero,
this.size = 208,
this.enabled = true,
this.onDisabledTap,
});
final VpnPhase phase;
final VoidCallback onTap;
/// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。
final bool enabled;
/// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。
final VoidCallback? onDisabledTap;
/// off 态圆内文字(如「点击连接」/「CONNECT」)。
final String offLabel;
@@ -63,26 +55,18 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
Widget build(BuildContext context) {
final c = context.pangolin;
final s = widget.phase;
// 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。
final disabled = !widget.enabled && s == VpnPhase.off;
final Color fill = disabled
? c.bgSubtle
: switch (s) {
VpnPhase.off => c.bgSubtle,
VpnPhase.connecting => c.accent,
VpnPhase.on => c.success,
};
final Color fg = disabled
? c.fg3
: (s == VpnPhase.off ? c.accent : PangolinColors.white);
final IconData icon = disabled
? PangolinIcons.lock
: switch (s) {
VpnPhase.off => PangolinIcons.power,
VpnPhase.connecting => PangolinIcons.loader,
VpnPhase.on => PangolinIcons.shieldCheck,
};
final Color fill = switch (s) {
VpnPhase.off => c.bgSubtle,
VpnPhase.connecting => c.accent,
VpnPhase.on => c.success,
};
final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white;
final IconData icon = switch (s) {
VpnPhase.off => PangolinIcons.power,
VpnPhase.connecting => PangolinIcons.loaderCircle,
VpnPhase.on => PangolinIcons.shieldCheck,
};
// off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。
final List<BoxShadow> glow = s == VpnPhase.off
@@ -100,7 +84,7 @@ class _ConnectButtonState extends State<ConnectButton> with SingleTickerProvider
button: true,
label: widget.offLabel,
child: GestureDetector(
onTap: disabled ? widget.onDisabledTap : widget.onTap,
onTap: widget.onTap,
child: AnimatedContainer(
duration: PangolinMotion.slow,
curve: PangolinMotion.easeOut,
+59 -73
View File
@@ -1,80 +1,66 @@
// pangolin_icons.dart — Lucide 图标映射表
import 'package:flutter/widgets.dart';
import 'package:lucide_icons/lucide_icons.dart';
// GENERATED by design/codegen/gen_flutter_icons.mjs —— 勿手改;图标真源=design/prototype/icons.js
// 重新生成:node design/codegen/gen_flutter_icons.mjs
import 'package:lucide_icons_flutter/lucide_icons.dart';
class PangolinIcons {
PangolinIcons._();
// ── 连接 / 状态 ──
static const power = LucideIcons.power;
static const shieldCheck = LucideIcons.shieldCheck;
static const shield = LucideIcons.shield;
static const loader = LucideIcons.loader;
static const zap = LucideIcons.zap;
static const globe = LucideIcons.globe;
static const clock = LucideIcons.clock;
static const playCircle = LucideIcons.playCircle;
static const wifi = LucideIcons.wifi;
// ── 导航 / 操作 ──
static const settings = LucideIcons.settings;
static const eye = LucideIcons.eye;
static const eyeOff = LucideIcons.eyeOff;
static const user = LucideIcons.user;
static const search = LucideIcons.search;
static const check = LucideIcons.check;
static const checkCircle = LucideIcons.checkCircle;
static const x = LucideIcons.x;
static const chevronRight = LucideIcons.chevronRight;
static const chevronDown = LucideIcons.chevronDown;
static const arrowLeft = LucideIcons.arrowLeft;
static const arrowUp = LucideIcons.arrowUp;
static const arrowDown = LucideIcons.arrowDown;
static const refreshCw = LucideIcons.refreshCw;
static const compass = LucideIcons.compass;
static const barChart = LucideIcons.barChart;
static const mapPin = LucideIcons.mapPin;
// ── 主题 ──
static const moon = LucideIcons.moon;
static const sun = LucideIcons.sun;
// ── 账户 / 套餐 / 兑换 ──
static const crown = LucideIcons.crown;
static const ticket = LucideIcons.ticket;
static const creditCard = LucideIcons.creditCard;
static const shoppingBag = LucideIcons.shoppingBag;
static const mail = LucideIcons.mail;
static const lock = LucideIcons.lock;
static const logOut = LucideIcons.logOut;
static const send = LucideIcons.send;
static const messageCircle= LucideIcons.messageCircle;
static const layoutDashboard = LucideIcons.layoutDashboard;
static const link = LucideIcons.link;
static const ticket = LucideIcons.ticket;
static const users = LucideIcons.users;
static const copy = LucideIcons.copy;
static const refreshCw = LucideIcons.refreshCw;
static const qrCode = LucideIcons.qrCode;
static const download = LucideIcons.download;
static const check = LucideIcons.check;
static const checkCircle = LucideIcons.checkCircle;
static const zap = LucideIcons.zap;
static const clock = LucideIcons.clock;
static const shieldCheck = LucideIcons.shieldCheck;
static const shield = LucideIcons.shield;
static const crown = LucideIcons.crown;
static const creditCard = LucideIcons.creditCard;
static const send = LucideIcons.send;
static const messageCircle = LucideIcons.messageCircle;
static const mail = LucideIcons.mail;
static const shoppingBag = LucideIcons.shoppingBag;
static const logOut = LucideIcons.logOut;
static const externalLink = LucideIcons.externalLink;
// ── 设备 ──
static const laptop = LucideIcons.laptop;
static const smartphone = LucideIcons.smartphone;
static const monitorSmartphone = LucideIcons.smartphone; // 回退
static const gift = LucideIcons.gift;
static const smartphone = LucideIcons.smartphone;
static const lock = LucideIcons.lock;
static const chevronRight = LucideIcons.chevronRight;
static const arrowDown = LucideIcons.arrowDown;
static const settings = LucideIcons.settings;
static const trash2 = LucideIcons.trash2;
static const monitor = LucideIcons.monitor;
static const key = LucideIcons.key;
static const sun = LucideIcons.sun;
static const moon = LucideIcons.moon;
static const alertTriangle = LucideIcons.alertTriangle;
static const x = LucideIcons.x;
static const power = LucideIcons.power;
static const loaderCircle = LucideIcons.loaderCircle;
static const globe = LucideIcons.globe;
static const user = LucideIcons.user;
static const search = LucideIcons.search;
static const chevronDown = LucideIcons.chevronDown;
static const arrowLeft = LucideIcons.arrowLeft;
static const arrowUp = LucideIcons.arrowUp;
static const arrowRight = LucideIcons.arrowRight;
static const compass = LucideIcons.compass;
static const chartNoAxesColumn = LucideIcons.chartNoAxesColumn;
static const home = LucideIcons.home;
static const mapPin = LucideIcons.mapPin;
static const laptop = LucideIcons.laptop;
static const monitorSmartphone = LucideIcons.monitorSmartphone;
static const playCircle = LucideIcons.playCircle;
static const wifi = LucideIcons.wifi;
static const eye = LucideIcons.eye;
static const eyeOff = LucideIcons.eyeOff;
static const moreVertical = LucideIcons.moreVertical;
static const trash = LucideIcons.trash2;
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 = {
'power': power, 'shield-check': shieldCheck, 'shield': shield, 'loader-circle': loader,
'zap': zap, 'globe': globe, 'settings': settings, 'user': user, 'search': search,
'check': check, 'check-circle': checkCircle, 'x': x, 'chevron-right': chevronRight,
'chevron-down': chevronDown, 'arrow-left': arrowLeft, 'arrow-up': arrowUp, 'arrow-down': arrowDown,
'refresh-cw': refreshCw, 'compass': compass, 'chart-no-axes-column': barChart, 'chart': barChart,
'map-pin': mapPin, 'moon': moon, 'sun': sun, 'crown': crown, 'ticket': ticket,
'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,
};
static const pencil = LucideIcons.pencil;
static const terminal = LucideIcons.terminal;
static const menu = LucideIcons.menu;
}
+80
View File
@@ -0,0 +1,80 @@
// seg_switch.dart — 分段开关(镜像 design/prototype atoms .segswitch)。
// 一排等宽选项,每项 leading 图标 + 文字;选中态 surface 底 + 柔和阴影 + fg1,
// 未选 fg2;容器 bg-subtle + border + full 圆角。渠道/币种/主题等二选一场景通用。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
/// 单个分段项:图标 + 文案。
typedef SegOption = ({IconData icon, String label});
class SegSwitch extends StatelessWidget {
const SegSwitch({
super.key,
required this.options,
required this.selectedIndex,
required this.onChanged,
});
final List<SegOption> options;
final int selectedIndex;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
// .segswitch:bg-subtle + border + full 圆角 + space-1 内边距/间隙
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: c.bgSubtle,
border: Border.all(color: c.border),
borderRadius: BorderRadius.circular(PangolinRadius.full),
),
child: Row(
children: [
for (var i = 0; i < options.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(child: _opt(c, i, options[i])),
],
],
),
);
}
Widget _opt(PangolinScheme c, int i, SegOption o) {
final active = i == selectedIndex;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: active ? null : () => onChanged(i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
// .segswitch-opt.is-active:surface 底 + shadow-sm;未选透明
color: active ? c.surface : Colors.transparent,
borderRadius: BorderRadius.circular(PangolinRadius.full),
boxShadow: active ? PangolinShadow.sm : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(o.icon, size: 16, color: active ? c.fg1 : c.fg2),
const SizedBox(width: 8),
Flexible(
child: Text(
o.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: PangolinText.sm.copyWith(
color: active ? c.fg1 : c.fg2,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
}
+1 -8
View File
@@ -12,7 +12,7 @@ dependencies:
sdk: flutter
flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo
country_flags: ^3.1.0 # 节点国旗(SVG,全平台含 Windows;按 region 码渲染)
lucide_icons: ^0.257.0 # Lucide 细线条图标
lucide_icons_flutter: ^3.1.14 # Lucide 细线条图标(自带字体 assets/lucide.ttf
google_fonts: ^6.2.1 # 运行时加载 Sora / Manrope / Noto Sans SC / JetBrains Mono
flutter_riverpod: ^2.5.1 # 状态层(连接状态机 / 免费额度 / 节点选择 / 语言 / 主题)
http: ^1.2.1 # 控制面 HTTP 客户端(connect API
@@ -27,7 +27,6 @@ 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:
@@ -44,12 +43,6 @@ flutter_launcher_icons:
remove_alpha_ios: true
image_path: "assets/app-icon-ios-1024.png"
# lucide_icons-0.257.0 defines LucideIconData extends IconData, which breaks
# when Flutter SDK marks IconData as final. Override with a local patched copy
# that replaces the subclass with direct IconData(...) const constructors.
dependency_overrides:
lucide_icons:
path: packages/lucide_icons_patched
flutter:
uses-material-design: true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

@@ -330,8 +330,9 @@ void main() {
await tester.pump();
});
// 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。
testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async {
// 免费版 10 分钟卡控(enforcement):连接期锁定额度,墙上时钟越过额度即自动切断 +
// 本地置耗尽(#21;额度倒计时 UI 已移除,只保留到点即切的强制行为)。
testWidgets('免费额度到点 → 自动切断 + 置耗尽', (tester) async {
final bridge = _FakeBridge();
var fake = DateTime(2026, 7, 1, 12);
final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟
@@ -341,7 +342,7 @@ void main() {
c.read(nodesProvider);
await tester.pump();
// 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。
// 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 额度(fake api 抛错不影响锁定)。
expect(c.read(quotaProvider).remainingMinutes, 10);
c.read(connectionProvider.notifier).toggle();
await tester.pump();
@@ -349,7 +350,6 @@ void main() {
await tester.pump();
await tester.pump();
expect(c.read(connectionProvider).phase, VpnPhase.on);
expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时');
// 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。
fake = fake.add(const Duration(seconds: 601));
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pangolin_vpn/services/api_client.dart';
import 'package:pangolin_vpn/services/auth_api.dart';
import 'package:pangolin_vpn/models/payment.dart';
import 'package:pangolin_vpn/services/payment_api.dart';
ApiClient _client(MockClient mock) => ApiClient(
@@ -25,7 +26,7 @@ void main() {
final items = await api.catalog();
expect(items.length, 3);
expect(items.first.sku, 'pro_month');
expect(items.first.priceLabel(), '¥29.99');
expect(items.first.priceLabel(PayChannel.cny), '¥29.99');
});
test('createOrder 只传 sku+method+metadata,解析 session', () async {
+3
View File
@@ -15,6 +15,9 @@ class _FakePaymentApi implements PaymentApi {
@override
Future<List<PayCatalogItem>> catalog() async => const [];
@override
Future<List<PayOrderSummary>> listOrders() async => const [];
@override
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async {
createCalls++;
+1 -1
View File
@@ -30,7 +30,7 @@ void main() {
testWidgets('connecting 态:loader 图标', (tester) async {
await tester.pumpWidget(button(VpnPhase.connecting));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byIcon(PangolinIcons.loader), findsOneWidget);
expect(find.byIcon(PangolinIcons.loaderCircle), findsOneWidget);
});
testWidgets('on 态:盾勾 + 计时 + 已加密', (tester) async {
+85
View File
@@ -0,0 +1,85 @@
// orders_page_test.dart — 订单列表/详情页行为测试(不打网络,provider 注入)。
import 'package:flutter/material.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/screens/orders_page.dart';
import 'package:pangolin_vpn/state/orders_provider.dart';
import 'package:pangolin_vpn/widgets/status_pill.dart';
import '../helpers/harness.dart';
final t = StringsZh();
final _orders = <PayOrderSummary>[
PayOrderSummary(
orderNo: 'o-year', sku: 'pro_year', plan: 'pro', days: 366, method: 'crypto',
status: 'paid', amountMinor: 34990000, currency: 'USDT',
createdAt: DateTime.utc(2026, 7, 10), paidAt: DateTime.utc(2026, 7, 10)),
PayOrderSummary(
orderNo: 'o-month', sku: 'pro_month', plan: 'pro', days: 31, method: 'alipay',
status: 'created', amountMinor: 2999, currency: 'CNY',
createdAt: DateTime.utc(2026, 7, 11)),
PayOrderSummary(
orderNo: 'o-q', sku: 'pro_quarter', plan: 'pro', days: 92, method: 'nezha',
status: 'canceled', amountMinor: 6888, currency: 'CNY',
createdAt: DateTime.utc(2026, 7, 9)),
];
void main() {
setUpAll(disableGoogleFontsFetching);
testWidgets('订单列表:三单渲染 + 结算币种金额 + 状态 pill', (tester) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(wrapThemed(
OrdersScreen(t: t, embedded: true),
overrides: [ordersProvider.overrideWith((ref) async => _orders)],
));
await tester.pumpAndSettle();
// 套餐名(逐档)
expect(find.text(t.proYearly), findsOneWidget);
expect(find.text(t.proMonthly), findsOneWidget);
expect(find.text(t.proQuarterly), findsOneWidget);
// 金额按结算币种(USDT $ / CNY ¥),在「金额 · 日期」子行里
expect(find.textContaining(r'$34.99'), findsOneWidget);
expect(find.textContaining('¥29.99'), findsOneWidget);
// 状态 pill(created→等待付款 / paid→已开通 / canceled→已取消)
expect(find.byType(StatusPill), findsNWidgets(3));
expect(find.text(t.awaitingPayment), findsOneWidget);
expect(find.text(t.orderStatusPaid), findsOneWidget);
expect(find.text(t.orderStatusCanceled), findsOneWidget);
});
testWidgets('订单空态', (tester) async {
await tester.pumpWidget(wrapThemed(
OrdersScreen(t: t, embedded: true),
overrides: [ordersProvider.overrideWith((ref) async => const <PayOrderSummary>[])],
));
await tester.pumpAndSettle();
expect(find.text(t.ordersEmpty), findsOneWidget);
});
testWidgets('点 created 单进详情:显订单号 + 继续支付', (tester) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(wrapThemed(
OrdersScreen(t: t, embedded: true),
overrides: [
ordersProvider.overrideWith((ref) async => _orders),
orderDetailProvider('o-month').overrideWith((ref) async => PayOrderStatus(
orderNo: 'o-month', payStatus: 'pending', activated: false, summary: _orders[1])),
],
));
await tester.pumpAndSettle();
// 点 created 那单(月付)进详情
await tester.tap(find.text(t.proMonthly));
await tester.pumpAndSettle();
// 详情字段行 + created 态动作「继续支付」
expect(find.text(t.orderNoLabel), findsOneWidget);
expect(find.text(t.orderContinuePay), findsWidgets);
});
}
+13 -4
View File
@@ -13,9 +13,9 @@ 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'),
PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY', priceUsdtMicro: 4990000),
PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY', priceUsdtMicro: 12990000),
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000),
];
/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
@@ -26,6 +26,9 @@ class _PollingFakePaymentApi implements PaymentApi {
@override
Future<List<PayCatalogItem>> catalog() async => const [];
@override
Future<List<PayOrderSummary>> listOrders() async => const [];
@override
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async =>
const PayOrder(
@@ -66,9 +69,15 @@ void main() {
));
await tester.pumpAndSettle();
expect(find.byType(PlanCard), findsNWidgets(3));
expect(find.text(t.proQuarterly), findsOneWidget);
// 默认支付渠道 = USDT → 套餐价显 USDT
expect(find.text('\$4.99'), findsOneWidget);
expect(find.text('\$34.99'), findsOneWidget);
// 切到人民币渠道 → 套餐价改显 ¥
await tester.tap(find.text(t.payChannelCny));
await tester.pumpAndSettle();
expect(find.text('¥29.99'), findsOneWidget);
expect(find.text('¥199.99'), findsOneWidget);
expect(find.text(t.proQuarterly), findsOneWidget);
});
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {