add
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/payment_provider.dart';
|
||||
import '../../services/pay_launcher.dart';
|
||||
import '../../services/payment_service.dart';
|
||||
import '../../services/user_service.dart';
|
||||
import '../feed/widgets/feed_overlays.dart';
|
||||
|
||||
/// 充值页:余额展示 / 套餐网格 / 自定义金额 / 支付方式 / 立即支付
|
||||
class RechargeScreen extends ConsumerWidget {
|
||||
const RechargeScreen({super.key});
|
||||
|
||||
static const Map<int, IconData> _methodIcons = {
|
||||
1: Icons.account_balance,
|
||||
2: Icons.chat_bubble,
|
||||
3: Icons.paid,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(paymentProvider);
|
||||
final user = ref.watch(authProvider).user;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('充值')),
|
||||
body: state.isLoading && state.result == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state.loadFailed && state.result == null
|
||||
? _LoadFailed(
|
||||
onRetry: () => ref.read(paymentProvider.notifier).load(),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => ref.read(paymentProvider.notifier).load(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 当前积分
|
||||
_PointsHeader(points: user?.points ?? 0),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 套餐网格
|
||||
if (state.result?.packages.isNotEmpty == true)
|
||||
_PackageGrid(
|
||||
packages: state.result!.packages,
|
||||
selectedId: state.selectedPackageId,
|
||||
onSelect: (id) =>
|
||||
ref.read(paymentProvider.notifier).selectPackage(id),
|
||||
),
|
||||
|
||||
// 自定义金额
|
||||
if (state.result?.custom.enabled == true) ...[
|
||||
const SizedBox(height: 12),
|
||||
_CustomAmountInput(
|
||||
custom: state.result!.custom,
|
||||
value: state.customYuan,
|
||||
onChanged: (v) => ref
|
||||
.read(paymentProvider.notifier)
|
||||
.setCustomYuan(v),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// 支付方式
|
||||
if (state.result?.payMethods.isNotEmpty == true)
|
||||
_PayMethodSelector(
|
||||
methods: state.result!.payMethods,
|
||||
selected: state.selectedMethod,
|
||||
onSelect: (m) =>
|
||||
ref.read(paymentProvider.notifier).selectMethod(m),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
// 立即支付
|
||||
ElevatedButton(
|
||||
onPressed: state.creating ? null : () => _pay(context, ref),
|
||||
child: state.creating
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'立即支付${state.amountYuan != null ? ' ¥${state.amountYuan}' : ''}',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: Text(
|
||||
'1 元 = 10 积分,充值后积分实时到账',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWeak, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pay(BuildContext context, WidgetRef ref) async {
|
||||
final notifier = ref.read(paymentProvider.notifier);
|
||||
final current = ref.read(paymentProvider);
|
||||
if (current.amountYuan == null || current.selectedMethod == null) {
|
||||
showTopToast(context, '请选择充值金额与支付方式');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final order = await notifier.createOrder();
|
||||
if (!context.mounted) return;
|
||||
if (order.isUsdt) {
|
||||
context.push('/recharge/usdt', extra: order);
|
||||
return;
|
||||
}
|
||||
await _launchAndPoll(context, order);
|
||||
} on ApiException catch (e) {
|
||||
if (!context.mounted) return;
|
||||
if (e.code == 1001) {
|
||||
showTopToast(context, '充值金额不在可选档位,请重新选择');
|
||||
} else {
|
||||
showTopToast(context, e.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) showTopToast(context, '下单失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/// 调起原生 SDK;无有效调起参数时降级为「外部完成支付」轮询弹窗
|
||||
Future<void> _launchAndPoll(
|
||||
BuildContext context, RechargeOrderInfo order) async {
|
||||
// 服务端未配置商户密钥(dev):返回 dev_mock,直接走轮询兜底
|
||||
if (order.payParams['dev_mock'] == true) {
|
||||
_showAliWechatSheet(context, order);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await PayLauncher.launch(order);
|
||||
if (!context.mounted) return;
|
||||
switch (result) {
|
||||
case PayLaunchResult.success:
|
||||
case PayLaunchResult.unsupported:
|
||||
// 成功仍需服务端查单确认;参数不可用时降级到手动轮询
|
||||
_showAliWechatSheet(context, order);
|
||||
case PayLaunchResult.canceled:
|
||||
showTopToast(context, '已取消支付');
|
||||
case PayLaunchResult.failed:
|
||||
showTopToast(context, '调起支付失败,请确认已安装对应 App');
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付宝/微信:底部轮询弹窗(无 SDK 集成期以「我已完成支付」兜底)
|
||||
void _showAliWechatSheet(BuildContext context, RechargeOrderInfo order) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => _AliWechatPollSheet(order: order),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部积分余额
|
||||
class _PointsHeader extends StatelessWidget {
|
||||
const _PointsHeader({required this.points});
|
||||
|
||||
final int points;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.account_balance_wallet_outlined,
|
||||
color: AppColors.gold),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('当前积分',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 12)),
|
||||
Text('$points',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.gold)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 套餐网格
|
||||
class _PackageGrid extends StatelessWidget {
|
||||
const _PackageGrid({
|
||||
required this.packages,
|
||||
required this.selectedId,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<RechargePackage> packages;
|
||||
final int? selectedId;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: packages.map((pkg) {
|
||||
final selected = selectedId == pkg.id;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(pkg.id),
|
||||
child: Container(
|
||||
width: (MediaQuery.of(context).size.width - 42) / 2,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: selected
|
||||
? Border.all(color: AppColors.primary, width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('¥${pkg.amountYuan}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
if (pkg.bonus > 0)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.goldGradient,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('赠 ${pkg.bonus}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('到账 ${pkg.totalPoints} 积分',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义金额输入
|
||||
class _CustomAmountInput extends StatefulWidget {
|
||||
const _CustomAmountInput({
|
||||
required this.custom,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final CustomRechargeConfig custom;
|
||||
final int? value;
|
||||
final ValueChanged<int?> onChanged;
|
||||
|
||||
@override
|
||||
State<_CustomAmountInput> createState() => _CustomAmountInputState();
|
||||
}
|
||||
|
||||
class _CustomAmountInputState extends State<_CustomAmountInput> {
|
||||
late final TextEditingController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = TextEditingController(
|
||||
text: widget.value == null ? '' : widget.value.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _CustomAmountInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != oldWidget.value) {
|
||||
final newText = widget.value == null ? '' : widget.value.toString();
|
||||
if (_ctrl.text != newText) {
|
||||
_ctrl.text = newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: _ctrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
hintText: '自定义金额(${widget.custom.minYuan}-${widget.custom.maxYuan} 元)',
|
||||
prefixIcon: const Icon(Icons.edit_outlined),
|
||||
suffixText: '元',
|
||||
),
|
||||
onChanged: (v) {
|
||||
final n = int.tryParse(v);
|
||||
widget.onChanged(n);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付方式单选
|
||||
class _PayMethodSelector extends StatelessWidget {
|
||||
const _PayMethodSelector({
|
||||
required this.methods,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<PayMethod> methods;
|
||||
final int? selected;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('支付方式',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 10),
|
||||
...methods.map((m) {
|
||||
final active = selected == m.method;
|
||||
final icon = RechargeScreen._methodIcons[m.method] ?? Icons.payment;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(icon, color: AppColors.gold),
|
||||
title: Text(m.name),
|
||||
trailing: active
|
||||
? const Icon(Icons.check_circle, color: AppColors.primary)
|
||||
: const Icon(Icons.radio_button_off,
|
||||
color: AppColors.textWeak),
|
||||
onTap: () => onSelect(m.method),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付宝/微信轮询弹窗(底部 Sheet)
|
||||
class _AliWechatPollSheet extends ConsumerStatefulWidget {
|
||||
const _AliWechatPollSheet({required this.order});
|
||||
|
||||
final RechargeOrderInfo order;
|
||||
|
||||
@override
|
||||
ConsumerState<_AliWechatPollSheet> createState() =>
|
||||
_AliWechatPollSheetState();
|
||||
}
|
||||
|
||||
class _AliWechatPollSheetState
|
||||
extends ConsumerState<_AliWechatPollSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final poll = ref.watch(orderPollProvider(widget.order));
|
||||
|
||||
// 状态变化时自动关闭(成功/超时/关闭)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (poll.status == OrderPollStatus.paid) {
|
||||
_refreshUser();
|
||||
_showResult('支付成功', '已到账 ${widget.order.points} 积分');
|
||||
} else if (poll.status == OrderPollStatus.closed) {
|
||||
_showResult('订单已关闭', '该订单已被取消或超时');
|
||||
} else if (poll.status == OrderPollStatus.timeout) {
|
||||
_showResult('查询超时', '请前往「钱包-充值记录」稍后查看结果');
|
||||
}
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textWeak,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'支付中…',
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'订单号:${widget.order.orderNo}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.order.isUsdt
|
||||
? '请完成 USDT 转账'
|
||||
: '请在外部完成支付,完成后返回本页',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () => _cancel(context),
|
||||
child: const Text('取消订单',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancel(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('取消订单', style: TextStyle(fontSize: 16)),
|
||||
content: const Text('取消后该笔订单将关闭,是否继续?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('继续支付'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child:
|
||||
const Text('取消订单', style: TextStyle(color: AppColors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(orderPollProvider(widget.order).notifier).cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshUser() async {
|
||||
// 支付成功后刷新全局用户信息(积分余额)
|
||||
try {
|
||||
final user = await UserService.fetchMe();
|
||||
await ref.read(authProvider.notifier).onLoginSuccess(user);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _showResult(String title, String body) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: Text(title, style: const TextStyle(fontSize: 16)),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 停止轮询避免后台泄漏
|
||||
ref.read(orderPollProvider(widget.order).notifier).stop();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadFailed extends StatelessWidget {
|
||||
const _LoadFailed({required this.onRetry});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('点击重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user