Files
pangolin/client/lib/screens/purchase_page.dart
T

246 lines
9.1 KiB
Dart

// 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<T>(List<T> 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<PurchaseScreen> createState() => _PurchaseScreenState();
}
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
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<void> _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)),
),
),
),
),
),
],
),
);
}
}