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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '哪吒支付';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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 校准。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||