This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
@@ -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('点击重试')),
],
),
);
}
}
@@ -0,0 +1,290 @@
import 'dart:async';
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/theme/app_theme.dart';
import '../../providers/payment_provider.dart';
import '../../providers/auth_provider.dart';
import '../../services/payment_service.dart';
import '../../services/user_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// USDT 支付页:金额(含尾差)/ 收款地址 / 网络 / 倒计时 / 轮询到账 / 取消订单
class UsdtPayScreen extends ConsumerStatefulWidget {
const UsdtPayScreen({super.key, required this.order});
final RechargeOrderInfo order;
@override
ConsumerState<UsdtPayScreen> createState() => _UsdtPayScreenState();
}
class _UsdtPayScreenState extends ConsumerState<UsdtPayScreen> {
Timer? _ticker;
Duration _remain = Duration.zero;
bool _resultHandled = false;
@override
void initState() {
super.initState();
_remain = _calcRemain();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
final r = _calcRemain();
if (!mounted) return;
setState(() => _remain = r);
});
}
Duration _calcRemain() {
final expire = DateTime.tryParse(widget.order.expireAt);
if (expire == null) return Duration.zero;
final diff = expire.difference(DateTime.now());
return diff.isNegative ? Duration.zero : diff;
}
@override
void dispose() {
_ticker?.cancel();
ref.read(orderPollProvider(widget.order).notifier).stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final poll = ref.watch(orderPollProvider(widget.order));
final order = widget.order;
// 轮询结果处理(仅一次)
if (!_resultHandled && poll.status != OrderPollStatus.polling) {
_resultHandled = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _onResult(poll));
}
final mm = _remain.inMinutes.remainder(60).toString().padLeft(2, '0');
final ss = _remain.inSeconds.remainder(60).toString().padLeft(2, '0');
return Scaffold(
appBar: AppBar(title: const Text('USDT 支付')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 金额卡
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const Text('请精确转账以下金额(含尾差用于订单匹配)',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 12)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
order.usdtAmount ?? '-',
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: AppColors.gold),
),
const SizedBox(width: 6),
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text('USDT',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 14)),
),
],
),
const SizedBox(height: 6),
Text(
'≈ ¥${order.amountYuan}(汇率 ${order.usdtRate ?? '-'})· 到账 ${order.points} 积分',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
],
),
),
const SizedBox(height: 12),
// 收款信息
_InfoRow(
label: '收款地址',
value: order.receiveAddress ?? '-',
onCopy: () => _copy(order.receiveAddress, '地址已复制'),
),
_InfoRow(label: '网络', value: order.network ?? 'TRC20'),
_InfoRow(label: '订单号', value: order.orderNo),
const SizedBox(height: 12),
// 倒计时 + 轮询状态
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_remain == Duration.zero
? '订单已过期,请重新下单'
: '等待链上确认(19 个区块)… 剩余 $mm:$ss',
style: const TextStyle(fontSize: 13),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'注意事项:\n'
'· 请务必按显示金额精确转账,金额不符将无法自动到账\n'
'· 仅支持 TRC20 网络,其他网络转账无法找回\n'
'· 满 19 个区块确认后积分自动到账',
style: TextStyle(color: AppColors.textWeak, fontSize: 12, height: 1.6),
),
const SizedBox(height: 20),
TextButton(
onPressed: () => _cancel(context),
child: const Text('取消订单',
style: TextStyle(color: AppColors.textSecondary)),
),
],
),
);
}
void _copy(String? text, String tip) {
if (text == null || text.isEmpty) return;
Clipboard.setData(ClipboardData(text: text));
showTopToast(context, tip);
}
void _refreshUser() async {
// 支付成功后刷新全局用户信息(积分余额)
try {
final user = await UserService.fetchMe();
await ref.read(authProvider.notifier).onLoginSuccess(user);
} catch (_) {}
}
Future<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 || !context.mounted) return;
try {
await ref.read(orderPollProvider(widget.order).notifier).cancel();
} catch (_) {
if (context.mounted) showTopToast(context, '取消失败,请稍后重试');
}
}
void _onResult(OrderPollState poll) {
if (!mounted) return;
if (poll.status == OrderPollStatus.paid) {
_refreshUser();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('充值成功', style: TextStyle(fontSize: 16)),
content: Text(
'已到账 ${widget.order.points} 积分,当前余额 ${poll.walletPoints ?? '-'}'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
context.pop(true); // 返回充值页并标记已支付
},
child: const Text('确定'),
),
],
),
);
} else {
showTopToast(
context, poll.status == OrderPollStatus.timeout ? '查询超时,请稍后在钱包查看' : '订单已关闭');
context.pop();
}
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value, this.onCopy});
final String label;
final String value;
final VoidCallback? onCopy;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 1),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
color: AppColors.surface,
child: Row(
children: [
SizedBox(
width: 64,
child: Text(label,
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13)),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
if (onCopy != null)
GestureDetector(
onTap: onCopy,
child: const Padding(
padding: EdgeInsets.only(left: 8),
child:
Icon(Icons.copy, size: 16, color: AppColors.textSecondary),
),
),
],
),
);
}
}