Files
pangolin/client/lib/screens/purchase_page.dart
T
wangjia a5fff28134 feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线
购买页三档 pro(月/季/年,金额来自 GET /v1/pay/catalog)选档 → 选支付方式 → 下单;
支付页按 session.render_type 多态渲染(crypto_address 地址+精确金额+复制、redirect
外链拉起、qr 预留复制兜底),轮询用 Task 6 的 paymentFlowProvider,409
CURRENCY_MISMATCH 走「换方式开新单」。同时收口 paymentFlowProvider 非 autoDispose
带来的轮询生命周期缺口——支付页 dispose 时停轮询(cancel() 延后到微任务执行,避免
在自身 unmount 期间同步改 state 炸 Riverpod 断言)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 23:50:35 +08:00

136 lines
4.9 KiB
Dart

// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。
// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。
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/pangolin_icons.dart';
import '../widgets/plan_card.dart';
class PurchaseScreen extends ConsumerWidget {
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;
String _name(String sku) => switch (sku) {
'pro_month' => t.proMonthly,
'pro_quarter' => t.proQuarterly,
'pro_year' => t.proYearly,
_ => 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;
if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) {
onOrderCreated?.call();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final catalog = ref.watch(payCatalogProvider);
final body = 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 == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry',
style: PangolinText.body.copyWith(color: c.fg3)),
),
),
data: (items) => ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
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),
features: t.featsPro,
ctaLabel: t.buyNow,
featured: it.sku == 'pro_year',
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
onPressed: () => _choose(context, ref, it),
),
),
],
),
);
if (embedded) return body;
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(t.purchaseTitle,
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
);
}
}