546 lines
18 KiB
Dart
546 lines
18 KiB
Dart
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('点击重试')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|