// 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=已取消 / expired=已过期。 ({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), 'expired' => (status: PangolinStatus.neutral, label: t.orderStatusExpired), _ => (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 createState() => _OrdersScreenState(); } class _OrdersScreenState extends ConsumerState { 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 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 _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 = [ _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 = []; switch (o.status) { case 'created': list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _goPurchase(context, ref))); // 过期单 pay 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。 case 'expired': 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)), ), ], ]), ); } }