feat(client): pay v2 支付渠道 switch + 订单列表/详情端到端
购买/支付(Phase B): - 新 SegSwitch 段控 widget(镜像 .segswitch 原子) - 购买页重构:顶部支付渠道 switch(默认 USDT→加密货币 / 人民币→支付宝),套餐价随渠道分币种显示,删底部弹窗选方式;点购买直达支付页 - 支付页加订单信息卡(套餐/渠道/金额按币种) - models:PayChannel + PayCatalogItem.priceUsdtMicro/priceLabel(ch)/perMonthLabel(ch) 订单(P0,全新): - payment_api.listOrders() + orders_provider(列表 + 详情 family) - OrdersScreen 列表→详情(状态 pill / 可复制订单号 / 继续支付·重新购买) - 导航:NavView.orders + desktop_shell 分支 + account 入口 配套: - l10n +19 getter(支付渠道 4 + 订单 15)×6 语言,l10n 闸绿 - 图标单源迁移 lucide_icons_flutter + pangolin_icons.dart(codegen) - 联系渠道订正(Telegram/邮件 support@yanmeiai.com/官网) + 前端去广告&时长 - 测试:购买 switch 分币种 + 订单列表/空态/详情 + 假 API listOrders;golden 重录 - flutter analyze 0 error,flutter test 226 全绿 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user