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
This commit is contained in:
@@ -12,6 +12,8 @@ import '../state/navigation_provider.dart';
|
||||
import '../widgets/account_screens.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import 'payment_page.dart';
|
||||
import 'purchase_page.dart';
|
||||
|
||||
/// 格式化到期日为 YYYY-MM-DD(本地时区)。
|
||||
String _fmtDate(DateTime d) {
|
||||
@@ -49,7 +51,29 @@ class AccountPage extends ConsumerWidget {
|
||||
final body = ListView(
|
||||
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
|
||||
children: [
|
||||
_PlanBanner(t: t, isFree: isFree, email: email, expiresLabel: expiresLabel, onUpgrade: () => open(NavView.plans, PlansScreen(t: t))),
|
||||
_PlanBanner(
|
||||
t: t,
|
||||
isFree: isFree,
|
||||
email: email,
|
||||
expiresLabel: expiresLabel,
|
||||
// mobile/tablet 全屏 push(desktop 走 open() 的 navView 分支,mobilePage 参数被忽略,
|
||||
// 由 desktop_shell 自己那份 PlansScreen(onChoose: ...) 接线,见 desktop_shell.dart)。
|
||||
onUpgrade: () => open(
|
||||
NavView.plans,
|
||||
PlansScreen(
|
||||
t: t,
|
||||
onChoose: (code) => code == 'pro'
|
||||
? Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => PurchaseScreen(
|
||||
t: t,
|
||||
onOrderCreated: () => Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))),
|
||||
),
|
||||
))
|
||||
: Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(t: t))),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// 账户信息
|
||||
_SectionLabel(text: t.accInfoTitle),
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// payment_page.dart — 支付页:按 session.render_type 多态渲染,轮询到
|
||||
// activated 切成功态。crypto_address=地址+精确金额+复制;redirect=外链拉起;
|
||||
// qr=预留(复制内容兜底)。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/payment_provider.dart';
|
||||
import '../widgets/pangolin_button.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
|
||||
class PaymentScreen extends ConsumerStatefulWidget {
|
||||
const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false});
|
||||
|
||||
final AppText t;
|
||||
final VoidCallback? onBack;
|
||||
/// 成功态「完成」/取消订单后的退出导航(壳层决定去向)。
|
||||
final VoidCallback? onDone;
|
||||
final bool embedded;
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
|
||||
}
|
||||
|
||||
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
AppText get t => widget.t;
|
||||
VoidCallback? get onBack => widget.onBack;
|
||||
VoidCallback? get onDone => widget.onDone;
|
||||
bool get embedded => widget.embedded;
|
||||
|
||||
// dispose() 时 Riverpod 的 ref 已提前失效(ConsumerStatefulElement 在
|
||||
// unmount 阶段收口 ref,dispose() 里再 ref.read 会抛
|
||||
// "Cannot use ref after the widget was disposed")——必须用 build() 里
|
||||
// 缓存下来的 controller/phase,不能在 dispose() 现取。
|
||||
PaymentFlowController? _ctl;
|
||||
PaymentPhase _phase = PaymentPhase.idle;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// paymentFlowProvider 非 autoDispose、轮询 Timer 无 expiry 自停:离开支付页时,
|
||||
// 若订单仍在等待付款(唯一存在活跃 Timer 的相位),cancel() 停轮询 + 视同放弃本单
|
||||
// (与「取消订单」按钮同一路径),避免永久轮询。
|
||||
//
|
||||
// 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发
|
||||
// cancel() 的 state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的
|
||||
// Element,炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道),
|
||||
// 光 catchError 接不住。用 scheduleMicrotask 挪到本次 unmount 收尾之后再执行——
|
||||
// 3s 轮询周期下,微任务级别的延迟不影响“停轮询”这个目的。执行时点若容器已把
|
||||
// controller 一并 dispose(如整页 ProviderScope 随之销毁),用公开的 `mounted` 短路,
|
||||
// 不调用已销毁实例。
|
||||
if (_phase == PaymentPhase.awaitingPayment) {
|
||||
final ctl = _ctl;
|
||||
if (ctl != null) {
|
||||
scheduleMicrotask(() {
|
||||
if (ctl.mounted) ctl.cancel();
|
||||
});
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _zh => t.lang == AppLang.zh;
|
||||
|
||||
Future<void> _copy(BuildContext context, String text) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) showPangolinToast(context, t.copied);
|
||||
}
|
||||
|
||||
// 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。
|
||||
Widget _card(PangolinScheme c, {required Widget child}) => Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
border: Border.all(color: c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
Widget _kvRow(BuildContext context, String label, String value, {bool mono = true}) {
|
||||
final c = context.pangolin;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(
|
||||
width: 76,
|
||||
child: Text(label, style: PangolinText.sm.copyWith(color: c.fg3)),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(value,
|
||||
style: (mono ? PangolinText.mono : PangolinText.body)
|
||||
.copyWith(color: c.fg1, fontSize: 14)),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(PangolinIcons.copy, size: 16, color: c.fg3),
|
||||
onPressed: () => _copy(context, value),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) {
|
||||
final c = context.pangolin;
|
||||
final session = s.order!.session;
|
||||
final payload = session.payload;
|
||||
|
||||
Widget renderBody;
|
||||
switch (session.renderType) {
|
||||
case 'crypto_address':
|
||||
renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_kvRow(context, t.payNetworkLabel, '${payload['currency'] ?? 'USDT'} · ${payload['network'] ?? 'TRC20'}'),
|
||||
_kvRow(context, t.payAddressLabel, '${payload['address'] ?? ''}'),
|
||||
_kvRow(context, t.payAmountLabel, '${payload['amount'] ?? ''}'),
|
||||
const SizedBox(height: 6),
|
||||
Text(t.payExactAmountHint, style: PangolinText.caption.copyWith(color: c.warning)),
|
||||
]));
|
||||
case 'redirect':
|
||||
renderBody = _card(c, child: Column(children: [
|
||||
PangolinButton(
|
||||
label: t.openAlipay,
|
||||
icon: PangolinIcons.externalLink,
|
||||
expand: true,
|
||||
onPressed: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'),
|
||||
mode: LaunchMode.externalApplication),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]));
|
||||
case 'qr': // 预留:复制内容兜底,不引入二维码渲染依赖
|
||||
renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_kvRow(context, t.payAmountLabel, '¥${payload['display_amount'] ?? ''}', mono: true),
|
||||
_kvRow(context, t.payAddressLabel, '${payload['qr_content'] ?? ''}'),
|
||||
const SizedBox(height: 6),
|
||||
Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]));
|
||||
default:
|
||||
renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}',
|
||||
style: PangolinText.body.copyWith(color: c.fg2)));
|
||||
}
|
||||
|
||||
return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [
|
||||
renderBody,
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent)),
|
||||
const SizedBox(width: 10),
|
||||
Text(t.awaitingPayment, style: PangolinText.sm.copyWith(color: c.fg2)),
|
||||
]),
|
||||
const SizedBox(height: 22),
|
||||
PangolinButton(
|
||||
label: t.switchPayMethod,
|
||||
variant: PangolinButtonVariant.secondary,
|
||||
expand: true,
|
||||
onPressed: () => _pickAndSwitch(context, ref, s),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PangolinButton(
|
||||
label: t.cancelOrder,
|
||||
variant: PangolinButtonVariant.ghost,
|
||||
expand: true,
|
||||
onPressed: () async {
|
||||
await ref.read(paymentFlowProvider.notifier).cancel();
|
||||
onDone?.call();
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async {
|
||||
final other = s.method == 'crypto' ? 'alipay' : 'crypto';
|
||||
await ref.read(paymentFlowProvider.notifier).switchMethod(other);
|
||||
}
|
||||
|
||||
Widget _succeeded(BuildContext context, PaymentFlowState s) {
|
||||
final c = context.pangolin;
|
||||
final exp = s.status?.expiresAt;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(PangolinIcons.checkCircle, size: 56, color: c.success),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.paySucceeded, style: PangolinText.h2.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
if (exp != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('${t.payExpiresAt} ${exp.toLocal().toString().split(' ').first}',
|
||||
style: PangolinText.sm.copyWith(color: c.fg2)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
PangolinButton(label: t.payDone, expand: true, onPressed: () => onDone?.call()),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _failed(BuildContext context, WidgetRef ref, PaymentFlowState s) {
|
||||
final c = context.pangolin;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(t.payFailed, style: PangolinText.h3.copyWith(color: c.danger)),
|
||||
const SizedBox(height: 8),
|
||||
Text((_zh ? s.errorZh : s.errorEn) ?? '', style: PangolinText.sm.copyWith(color: c.fg3)),
|
||||
const SizedBox(height: 20),
|
||||
PangolinButton(
|
||||
label: t.payRetry,
|
||||
expand: true,
|
||||
onPressed: () {
|
||||
final item = s.item;
|
||||
if (item != null) ref.read(paymentFlowProvider.notifier).start(item, s.method);
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final s = ref.watch(paymentFlowProvider);
|
||||
_ctl = ref.read(paymentFlowProvider.notifier);
|
||||
_phase = s.phase;
|
||||
|
||||
final body = switch (s.phase) {
|
||||
PaymentPhase.creating => const Center(child: CircularProgressIndicator()),
|
||||
PaymentPhase.awaitingPayment => _awaiting(context, ref, s),
|
||||
PaymentPhase.succeeded => _succeeded(context, s),
|
||||
PaymentPhase.failed => _failed(context, ref, s),
|
||||
PaymentPhase.idle => Center(
|
||||
child: Text(_zh ? '暂无进行中的订单' : 'No active order',
|
||||
style: PangolinText.body.copyWith(color: c.fg3))),
|
||||
};
|
||||
|
||||
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.paymentTitle,
|
||||
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
Expanded(child: body),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user