// 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'; import '../l10n/app_text.dart'; import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/sub_scaffold.dart'; import '../widgets/pangolin_toast.dart'; import '../widgets/plan_card.dart'; import '../widgets/seg_switch.dart'; /// 手写 firstWhere-or-null(避免仅为一次查找引入 package:collection 依赖)。 T? _firstWhereOrNull(List items, bool Function(T) test) { for (final it in items) { if (test(it)) return it; } return null; } class PurchaseScreen extends ConsumerStatefulWidget { const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false}); final AppText t; final VoidCallback? onBack; /// 下单成功(进入 awaitingPayment)后由壳导航到支付页。 final VoidCallback? onOrderCreated; final bool embedded; @override ConsumerState createState() => _PurchaseScreenState(); } class _PurchaseScreenState extends ConsumerState { AppText get t => widget.t; bool get _zh => t.lang == AppLang.zh; // 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。 PayChannel _channel = PayChannel.usdt; String _name(String sku) => switch (sku) { 'pro_month' => t.proMonthly, 'pro_quarter' => t.proQuarterly, 'pro_year' => t.proYearly, _ => sku, }; Future _buy(PayCatalogItem item) async { await ref.read(paymentFlowProvider.notifier).start(item, _channel.method); if (!mounted) return; final st = ref.read(paymentFlowProvider); if (st.phase == PaymentPhase.awaitingPayment) { widget.onOrderCreated?.call(); } else if (st.phase == PaymentPhase.failed) { // 下单失败给可见 toast,别让用户以为「点了没反应」。 showPangolinToast( context, (_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed, ); } } @override Widget build(BuildContext context) { final c = context.pangolin; final catalog = ref.watch(payCatalogProvider); final body = PageBody(wide: true, child: catalog.when( loading: () => const Center( child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( child: Padding( padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)), ), ), data: (items) { final promoItem = _firstWhereOrNull(items, (i) => i.promo); final planItems = items.where((i) => !i.promo).toList(); final originalItem = _firstWhereOrNull(items, (i) => i.sku == 'pro_month'); return 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), if (promoItem != null) Padding( padding: const EdgeInsets.only(bottom: 14), child: _PromoCard( t: t, channel: _channel, item: promoItem, originalItem: originalItem, onPressed: () => _buy(promoItem), ), ), for (final it in planItems) Padding( padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0), child: PlanCard( name: _name(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: () => _buy(it), ), ), ], ); }, )); return SubScaffold( title: t.purchaseTitle, onBack: widget.onBack, embedded: widget.embedded, child: body, ); } } /// _PromoCard — 全员优惠月付特殊卡片(横幅式,视觉上与普通 PlanCard 拉开层次)。 /// 徽章 + 大号现价(随渠道 ¥/$ 联动)+ 可选原价划线(以 pro_month 为参照)。 class _PromoCard extends StatelessWidget { const _PromoCard({ required this.t, required this.channel, required this.item, required this.originalItem, required this.onPressed, }); final AppText t; final PayChannel channel; final PayCatalogItem item; final PayCatalogItem? originalItem; final VoidCallback onPressed; @override Widget build(BuildContext context) { final originalLabel = originalItem?.priceLabel(channel); return Container( padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay500, PangolinColors.clay700], ), borderRadius: BorderRadius.circular(PangolinRadius.xl), boxShadow: PangolinShadow.md, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( color: PangolinColors.white.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(PangolinRadius.full), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(PangolinIcons.zap, size: 13, color: PangolinColors.white), const SizedBox(width: 4), Text(t.promoBadge, style: PangolinText.caption.copyWith( color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 11)), ]), ), const SizedBox(height: 12), Text(t.proMonthly, style: PangolinText.sm.copyWith( color: PangolinColors.white.withValues(alpha: 0.85), fontWeight: FontWeight.w600)), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text(item.priceLabel(channel), style: PangolinText.display.copyWith(color: PangolinColors.white, fontSize: 30)), Text(t.perMonthSuffix, style: PangolinText.caption.copyWith( color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 13)), if (originalLabel != null) ...[ const SizedBox(width: 8), Text(originalLabel, style: PangolinText.sm.copyWith( color: PangolinColors.white.withValues(alpha: 0.62), fontSize: 14, decoration: TextDecoration.lineThrough, )), ], ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: Material( color: PangolinColors.white, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onPressed, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: Text(t.buyNow, style: PangolinText.sm.copyWith( fontWeight: FontWeight.w700, fontSize: 14, color: PangolinColors.clay700)), ), ), ), ), ), ], ), ); } }