262773a9de
- PaymentFlowController 新增 stopPolling():只停轮询 Timer,不发远程 cancel、 不改 state;PaymentScreen.dispose() 改调它,不再复用 cancel()——离开支付页 (切左导航 tab)不再等同于放弃订单,cancel() 只留给「取消订单」按钮的显式路径。 - redirect 分支 launchUrl 前加 canLaunchUrl 判定 + try-catch,失败时用 showPangolinToast 给可见提示(新增 l10n openAlipayFailed,zh/en 均补)。 - purchase_page.dart::_choose 下单后严格判 phase == awaitingPayment 才跳转 支付页,建单失败(failed)不再误跳。 - 回归测试:payment_pages_test.dart 补两条用例区分 dispose(不触发 cancel、 state 不归 idle)与「取消订单」按钮(远程 cancel + state 归 idle); payment_flow_test.dart 补 stopPolling() 单元测试。
136 lines
4.9 KiB
Dart
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.awaitingPayment) {
|
|
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),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|