rwadurian/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart

1647 lines
57 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/trade_order_model.dart';
import '../../../domain/entities/price_info.dart';
import '../../../domain/entities/market_overview.dart';
import '../../../domain/entities/trade_order.dart';
import '../../../domain/entities/kline.dart';
import '../../providers/user_providers.dart';
import '../../providers/trading_providers.dart';
import '../../providers/asset_providers.dart';
import '../../widgets/shimmer_loading.dart';
import '../../widgets/kline_chart/kline_chart_widget.dart';
class TradingPage extends ConsumerStatefulWidget {
const TradingPage({super.key});
@override
ConsumerState<TradingPage> createState() => _TradingPageState();
}
class _TradingPageState extends ConsumerState<TradingPage> {
// 品牌色彩(不随主题变化)
static const Color _orange = AppColors.orange;
static const Color _green = AppColors.up;
static const Color _red = AppColors.down;
// 状态
int _selectedTab = 1; // 0: 买入, 1: 卖出
int _selectedTimeRange = 4; // 时间周期选择默认1时
final _quantityController = TextEditingController();
final _priceController = TextEditingController();
bool _isFullScreen = false; // K线图全屏状态
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', ''];
@override
void dispose() {
_quantityController.dispose();
_priceController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
// 初始化时加载K线数据
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[_selectedTimeRange]);
});
}
@override
Widget build(BuildContext context) {
final priceAsync = ref.watch(currentPriceProvider);
final marketAsync = ref.watch(marketOverviewProvider);
final ordersAsync = ref.watch(ordersProvider);
final klinesState = ref.watch(klinesNotifierProvider);
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
// 全屏K线图模式
if (_isFullScreen) {
return KlineChartWidget(
klines: klinesState.klines,
currentPrice: priceAsync.valueOrNull?.price ?? '0',
isFullScreen: true,
onFullScreenToggle: () => setState(() => _isFullScreen = false),
timeRanges: _timeRanges,
selectedTimeIndex: _selectedTimeRange,
onTimeRangeChanged: (index) {
setState(() => _selectedTimeRange = index);
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[index]);
},
isLoadingMore: klinesState.isLoadingMore,
hasMoreHistory: klinesState.hasMoreHistory,
onLoadMoreHistory: () => ref.read(klinesNotifierProvider.notifier).loadMoreHistory(),
);
}
return Scaffold(
backgroundColor: AppColors.backgroundOf(context),
body: SafeArea(
bottom: false,
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
ref.invalidate(ordersProvider);
// 重新加载K线数据
await ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[_selectedTimeRange]);
},
child: Column(
children: [
_buildAppBar(),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildPriceCard(priceAsync),
_buildChartSection(priceAsync, klinesState),
_buildMarketDataCard(marketAsync),
_buildTradingPanel(priceAsync),
_buildMyOrdersCard(ordersAsync),
const SizedBox(height: 24),
],
),
),
),
],
),
),
),
);
}
Widget _buildAppBar() {
return Container(
color: AppColors.surfaceOf(context),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Center(
child: Text(
'积分股兑换',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimaryOf(context),
),
),
),
);
}
Widget _buildPriceCard(AsyncValue<PriceInfo?> priceAsync) {
final isLoading = priceAsync.isLoading;
final priceInfo = priceAsync.valueOrNull;
final hasError = priceAsync.hasError;
if (hasError && priceInfo == null) {
return _buildErrorCard('价格加载失败');
}
final price = priceInfo?.price ?? '0';
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'当前积分股价值',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondaryOf(context),
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
AmountText(
amount: priceInfo != null ? formatPrice(price) : null,
isLoading: isLoading,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: _orange,
letterSpacing: -0.75,
),
),
const SizedBox(width: 4),
Text(
'积分值',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondaryOf(context),
),
),
],
),
const SizedBox(height: 8),
Builder(
builder: (context) {
final changePercent = double.tryParse(priceInfo?.priceChangePercent ?? '0') ?? 0;
final isPositive = changePercent >= 0;
final color = isPositive ? _green : _red;
final icon = isPositive ? Icons.trending_up : Icons.trending_down;
final sign = isPositive ? '+' : '';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(
'较上线首日',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondaryOf(context),
),
),
const SizedBox(width: 4),
DataText(
data: isLoading ? null : '$sign${changePercent.toStringAsFixed(2)}%',
isLoading: isLoading,
placeholder: '+--.--%',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
},
),
],
),
);
}
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync, KlinesState klinesState) {
final priceInfo = priceAsync.valueOrNull;
final currentPrice = priceInfo?.price ?? '0.000000';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: klinesState.isLoading && klinesState.klines.isEmpty
? const SizedBox(
height: 280,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
: KlineChartWidget(
klines: klinesState.klines,
currentPrice: currentPrice,
isFullScreen: false,
onFullScreenToggle: () => setState(() => _isFullScreen = true),
timeRanges: _timeRanges,
selectedTimeIndex: _selectedTimeRange,
onTimeRangeChanged: (index) {
setState(() => _selectedTimeRange = index);
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
ref.read(klinesNotifierProvider.notifier).loadKlines(_timeRanges[index]);
},
isLoadingMore: klinesState.isLoadingMore,
hasMoreHistory: klinesState.hasMoreHistory,
onLoadMoreHistory: () => ref.read(klinesNotifierProvider.notifier).loadMoreHistory(),
),
);
}
Widget _buildMarketDataCard(AsyncValue<MarketOverview?> marketAsync) {
final isLoading = marketAsync.isLoading;
final market = marketAsync.valueOrNull;
final hasError = marketAsync.hasError;
if (hasError && market == null) {
return _buildErrorCard('市场数据加载失败');
}
final bgGray = AppColors.backgroundOf(context);
final darkText = AppColors.textPrimaryOf(context);
final grayText = AppColors.textSecondaryOf(context);
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
'市场数据',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: darkText,
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
_buildMarketDataItem(
'总积分股',
market != null ? formatCompact(market.totalShares) : null,
_orange,
isLoading,
),
Container(width: 1, height: 24, color: bgGray),
const SizedBox(width: 16),
_buildMarketDataItem(
'流通池',
market != null ? formatCompact(market.circulationPool) : null,
_orange,
isLoading,
),
],
),
const SizedBox(height: 24),
Container(height: 1, color: bgGray),
const SizedBox(height: 24),
Row(
children: [
_buildMarketDataItem(
'积分股池',
market != null ? formatCompact(market.greenPoints) : null,
_orange,
isLoading,
),
Container(width: 1, height: 24, color: bgGray),
const SizedBox(width: 16),
_buildMarketDataItem(
'黑洞销毁量',
market != null ? formatCompact(market.blackHoleAmount) : null,
_red,
isLoading,
),
],
),
],
),
);
}
Widget _buildMarketDataItem(String label, String? value, Color valueColor, bool isLoading) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context))),
const SizedBox(height: 4),
DataText(
data: value,
isLoading: isLoading,
placeholder: '--,---,---',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: valueColor,
),
),
],
),
);
}
Widget _buildTradingPanel(AsyncValue<PriceInfo?> priceAsync) {
final priceInfo = priceAsync.valueOrNull;
final currentPrice = priceInfo?.price ?? '0';
// 获取用户资产信息
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
final asset = assetAsync.valueOrNull;
// 获取买入功能开关状态
final buyEnabledAsync = ref.watch(buyEnabledProvider);
final buyEnabled = buyEnabledAsync.valueOrNull ?? false;
// 挖矿账户积分股(可划转卖出)
final miningShareBalance = asset?.miningShareBalance ?? '0';
// 交易账户积分股(可直接卖出)
final tradingShareBalance = asset?.tradingShareBalance ?? '0';
// 可用积分股(总计:挖矿 + 交易)
final availableShares = asset?.availableShares ?? '0';
// 可用积分值(现金)
final availableCash = asset?.availableCash ?? '0';
// 始终使用实时价格(价格不可修改)
if (priceInfo != null) {
_priceController.text = currentPrice;
}
// 如果选中买入但买入功能未开启,强制切换到卖出
if (_selectedTab == 0 && !buyEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _selectedTab == 0) {
setState(() => _selectedTab = 1);
}
});
}
final grayText = AppColors.textSecondaryOf(context);
final bgGray = AppColors.backgroundOf(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: bgGray)),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: buyEnabled ? () => setState(() => _selectedTab = 0) : null,
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: _selectedTab == 0 && buyEnabled ? _orange : Colors.transparent,
width: 2,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'买入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: buyEnabled
? (_selectedTab == 0 ? _orange : grayText)
: grayText.withValues(alpha: 0.5),
),
),
if (!buyEnabled) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: grayText.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'待开启',
style: TextStyle(
fontSize: 10,
color: grayText.withValues(alpha: 0.7),
),
),
),
],
],
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedTab = 1),
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: _selectedTab == 1 ? _orange : Colors.transparent,
width: 2,
),
),
),
child: Text(
'卖出',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _selectedTab == 1 ? _orange : grayText,
),
),
),
),
),
],
),
),
const SizedBox(height: 24),
// 买入功能未开启时显示提示
if (_selectedTab == 0 && !buyEnabled) ...[
Container(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.lock_outline,
size: 48,
color: grayText.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'买入功能待开启',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: grayText,
),
),
const SizedBox(height: 8),
Text(
'买入功能暂未开放,请耐心等待',
style: TextStyle(
fontSize: 14,
color: grayText.withValues(alpha: 0.7),
),
),
],
),
),
] else ...[
// 可用余额提示
if (_selectedTab == 0) ...[
// 买入时显示可用积分值
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'可用积分值',
style: TextStyle(fontSize: 12, color: grayText),
),
Text(
formatAmount(availableCash),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
] else ...[
// 卖出时显示可用积分股
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'可用积分股',
style: TextStyle(fontSize: 12, color: grayText),
),
Text(
formatAmount(tradingShareBalance),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
const SizedBox(height: 4),
// 划转入口
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => _showTransferDialog(miningShareBalance, tradingShareBalance),
child: const Text(
'划转',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
),
],
const SizedBox(height: 16),
// 价格显示(只读,使用实时价格)
_buildInputField('价格', _priceController, '实时价格', '积分值', readOnly: true),
const SizedBox(height: 16),
// 数量输入 - 带"全部"按钮
// 卖出时使用交易账户积分股余额(只能卖出交易账户的,挖矿账户需要先划转)
_buildQuantityInputField(
'数量',
_quantityController,
'请输入数量',
'积分股',
_selectedTab == 1 ? tradingShareBalance : null,
_selectedTab == 0 ? availableCash : null,
currentPrice,
),
const SizedBox(height: 16),
// 预计获得/支出
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgGray,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedTab == 0 ? '预计支出' : '预计获得',
style: TextStyle(fontSize: 12, color: grayText),
),
Text(
_calculateEstimate(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
const SizedBox(height: 16),
// 交易手续费说明 (卖出时显示)
if (_selectedTab == 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'交易手续费',
style: TextStyle(fontSize: 12, color: grayText),
),
const Text(
'10% 进入积分股池',
style: TextStyle(
fontSize: 12,
color: _green,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 24),
// 提交按钮
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _handleTrade,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'确认交易',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
],
),
);
}
Widget _buildQuantityInputField(
String label,
TextEditingController controller,
String hint,
String suffix,
String? availableSharesForSell,
String? availableCashForBuy,
String currentPrice,
) {
final grayText = AppColors.textSecondaryOf(context);
final bgGray = AppColors.backgroundOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: grayText,
),
),
const SizedBox(height: 8),
Container(
height: 44,
decoration: BoxDecoration(
color: bgGray,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontSize: 14,
color: grayText,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (_) => setState(() {}),
),
),
// 全部按钮
GestureDetector(
onTap: () {
if (availableSharesForSell != null) {
// 卖出时填入全部可用积分股
controller.text = availableSharesForSell;
} else if (availableCashForBuy != null) {
// 买入时根据可用积分值计算可买数量
final price = double.tryParse(currentPrice) ?? 0;
final cash = double.tryParse(availableCashForBuy) ?? 0;
if (price > 0) {
final maxQuantity = cash / price;
controller.text = maxQuantity.toStringAsFixed(4);
}
}
setState(() {});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _orange,
),
),
),
),
Text(suffix, style: TextStyle(fontSize: 12, color: grayText)),
const SizedBox(width: 12),
],
),
),
],
);
}
Widget _buildInputField(
String label,
TextEditingController controller,
String hint,
String suffix, {
bool readOnly = false,
}) {
final grayText = AppColors.textSecondaryOf(context);
final darkText = AppColors.textPrimaryOf(context);
final bgGray = AppColors.backgroundOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: grayText,
),
),
const SizedBox(height: 8),
Container(
height: 44,
decoration: BoxDecoration(
color: readOnly ? bgGray.withOpacity(0.7) : bgGray,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
readOnly: readOnly,
enabled: !readOnly,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: TextStyle(
fontSize: 14,
color: readOnly ? grayText : darkText,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontSize: 14,
color: grayText,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
Text(suffix, style: TextStyle(fontSize: 12, color: grayText)),
const SizedBox(width: 12),
],
),
),
],
);
}
/// 计算预估获得/支出
/// 卖出公式:卖出交易额 = (卖出量 + 卖出销毁量) × 价格 × 0.9
/// = 卖出量 × (1 + burnMultiplier) × 价格 × 0.9
String _calculateEstimate() {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
if (price == 0 || quantity == 0) {
return '0.00 积分值';
}
if (_selectedTab == 1) {
// 卖出时:有效积分股 = 卖出量 × (1 + burnMultiplier)
// 卖出交易额 = 有效积分股 × 价格 × 0.9扣除10%手续费)
final marketAsync = ref.read(marketOverviewProvider);
final burnMultiplier = double.tryParse(
marketAsync.valueOrNull?.burnMultiplier ?? '0',
) ?? 0;
final effectiveQuantity = quantity * (1 + burnMultiplier);
final grossAmount = effectiveQuantity * price;
final netAmount = grossAmount * 0.9; // 扣除10%手续费
return '${formatAmount(netAmount.toString())} 积分值';
}
// 买入时:支出 = 价格 × 数量
final total = price * quantity;
return '${formatAmount(total.toString())} 积分值';
}
Widget _buildMyOrdersCard(AsyncValue<OrdersPageModel?> ordersAsync) {
final isLoading = ordersAsync.isLoading;
final ordersPage = ordersAsync.valueOrNull;
final orders = ordersPage?.data ?? [];
final darkText = AppColors.textPrimaryOf(context);
final grayText = AppColors.textSecondaryOf(context);
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'我的挂单',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: darkText,
),
),
GestureDetector(
onTap: () => context.push(Routes.tradingRecords),
child: const Text(
'全部 >',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _orange,
),
),
),
],
),
const SizedBox(height: 16),
if (isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(color: _orange),
),
)
else if (orders.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(
'暂无挂单',
style: TextStyle(fontSize: 14, color: grayText),
),
)
else
Column(
children: orders.take(3).map((order) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildOrderItemFromEntity(order),
)).toList(),
),
],
),
);
}
Widget _buildOrderItemFromEntity(TradeOrder order) {
final isSell = order.isSell;
final dateFormat = DateFormat('MM/dd HH:mm');
final formattedDate = dateFormat.format(order.createdAt);
final darkText = AppColors.textPrimaryOf(context);
final grayText = AppColors.textSecondaryOf(context);
final bgGray = AppColors.backgroundOf(context);
final cardBg = AppColors.cardOf(context);
String statusText;
switch (order.status) {
case OrderStatus.pending:
statusText = '待成交';
break;
case OrderStatus.partial:
statusText = '部分成交';
break;
case OrderStatus.filled:
statusText = '已成交';
break;
case OrderStatus.cancelled:
statusText = '已取消';
break;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: bgGray),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isSell ? _red : _green).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isSell ? '卖出' : '买入',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSell ? _red : _green,
),
),
),
const SizedBox(width: 8),
Text(
formatPrice(order.price),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: darkText,
),
),
],
),
const SizedBox(height: 4),
Text(
formattedDate,
style: TextStyle(fontSize: 12, color: grayText),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${formatCompact(order.quantity)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: darkText,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9999),
),
child: Text(
statusText,
style: const TextStyle(fontSize: 12, color: _orange),
),
),
],
),
],
),
);
}
Widget _buildErrorCard(String message) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColors.cardOf(context),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Column(
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 8),
Text(message),
],
),
),
);
}
void _handleTrade() async {
if (_priceController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入价格')),
);
return;
}
if (_quantityController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入数量')),
);
return;
}
final isBuy = _selectedTab == 0;
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
// 卖出时显示确认弹窗
if (!isBuy) {
// 获取销毁倍数,使用与后端一致的公式计算
final marketAsync = ref.read(marketOverviewProvider);
final burnMultiplier = double.tryParse(
marketAsync.valueOrNull?.burnMultiplier ?? '0',
) ?? 0;
// 有效积分股 = 卖出量 × (1 + burnMultiplier)
final effectiveQuantity = quantity * (1 + burnMultiplier);
// 交易总额 = 有效积分股 × 价格
final grossAmount = effectiveQuantity * price;
// 手续费 = 交易总额 × 10%
final tradeFee = grossAmount * 0.1;
// 实际获得 = 交易总额 × 90%
final received = grossAmount * 0.9;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认卖出'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('卖出数量: ${formatAmount(quantity.toString())} 积分股'),
const SizedBox(height: 8),
Text('卖出价格: ${formatPrice(price.toString())} 积分值'),
const SizedBox(height: 8),
Text('销毁倍数: ${burnMultiplier.toStringAsFixed(4)}'),
const SizedBox(height: 8),
Text('有效积分股: ${formatAmount(effectiveQuantity.toString())}'),
const SizedBox(height: 8),
Text('交易总额: ${formatAmount(grossAmount.toString())} 积分值'),
const SizedBox(height: 8),
Text(
'进入积分股池: ${formatAmount(tradeFee.toString())} 积分值 (10%)',
style: const TextStyle(color: _green),
),
const SizedBox(height: 8),
Text(
'实际获得: ${formatAmount(received.toString())} 积分值',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: _green,
),
),
const SizedBox(height: 16),
Text(
'注意: 卖出积分股将扣除10%进入积分股池,此操作不可撤销。',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondaryOf(context),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'确认卖出',
style: TextStyle(color: _orange),
),
),
],
),
);
if (confirmed != true) return;
}
bool success;
if (isBuy) {
success = await ref
.read(tradingNotifierProvider.notifier)
.buyShares(_priceController.text, _quantityController.text);
} else {
success = await ref
.read(tradingNotifierProvider.notifier)
.sellShares(_priceController.text, _quantityController.text);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? (isBuy ? '买入订单已提交' : '卖出订单已提交')
: (isBuy ? '买入失败' : '卖出失败')),
backgroundColor: success ? _green : AppColors.error,
),
);
if (success) {
_quantityController.clear();
// 交易成功后立即刷新
_refreshAfterTrade();
}
}
}
/// 交易成功后刷新数据
/// 立即刷新一次然后在2秒和5秒后再各刷新一次
/// 确保能看到做市商处理后的最新状态
void _refreshAfterTrade() {
final user = ref.read(userNotifierProvider);
final accountSeq = user.accountSequence ?? '';
// 立即刷新
_doRefresh(accountSeq);
// 2秒后再刷新做市商可能在1-4秒内吃单
Future.delayed(const Duration(seconds: 2), () {
if (mounted) _doRefresh(accountSeq);
});
// 5秒后最终刷新确保看到最终状态
Future.delayed(const Duration(seconds: 5), () {
if (mounted) _doRefresh(accountSeq);
});
}
void _doRefresh(String accountSeq) {
ref.invalidate(ordersProvider);
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
ref.invalidate(accountAssetProvider(accountSeq));
}
/// 显示划转弹窗
void _showTransferDialog(String miningBalance, String tradingBalance) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _TransferBottomSheet(
miningBalance: miningBalance,
tradingBalance: tradingBalance,
onTransferComplete: () {
final user = ref.read(userNotifierProvider);
final accountSeq = user.accountSequence ?? '';
_doRefresh(accountSeq);
},
),
);
}
}
/// 划转底部弹窗
class _TransferBottomSheet extends ConsumerStatefulWidget {
final String miningBalance;
final String tradingBalance;
final VoidCallback onTransferComplete;
const _TransferBottomSheet({
required this.miningBalance,
required this.tradingBalance,
required this.onTransferComplete,
});
@override
ConsumerState<_TransferBottomSheet> createState() => _TransferBottomSheetState();
}
class _TransferBottomSheetState extends ConsumerState<_TransferBottomSheet> {
static const Color _orange = AppColors.orange;
static const Color _green = AppColors.up;
// 0: 从挖矿划入交易, 1: 从交易划出到挖矿
int _direction = 0;
final _amountController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
String get _availableBalance {
return _direction == 0 ? widget.miningBalance : widget.tradingBalance;
}
@override
Widget build(BuildContext context) {
final cardBg = AppColors.cardOf(context);
final darkText = AppColors.textPrimaryOf(context);
final grayText = AppColors.textSecondaryOf(context);
final bgGray = AppColors.backgroundOf(context);
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: cardBg,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'积分股划转',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: darkText,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(Icons.close, color: grayText),
),
],
),
const SizedBox(height: 20),
// 划转方向切换
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: bgGray,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_direction = 0;
_amountController.clear();
}),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _direction == 0 ? AppColors.cardOf(context) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: _direction == 0
? [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 4)]
: null,
),
child: Text(
'划入交易账户',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: _direction == 0 ? FontWeight.bold : FontWeight.normal,
color: _direction == 0 ? _orange : grayText,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() {
_direction = 1;
_amountController.clear();
}),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _direction == 1 ? AppColors.cardOf(context) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: _direction == 1
? [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 4)]
: null,
),
child: Text(
'划出到分配账户',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: _direction == 1 ? FontWeight.bold : FontWeight.normal,
color: _direction == 1 ? _orange : grayText,
),
),
),
),
),
],
),
),
const SizedBox(height: 20),
// 划转说明
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgGray,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_direction == 0 ? '分配账户' : '交易账户',
style: TextStyle(fontSize: 12, color: grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(_direction == 0 ? widget.miningBalance : widget.tradingBalance),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: darkText,
),
),
],
),
),
const Icon(Icons.arrow_forward, color: _orange),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_direction == 0 ? '交易账户' : '分配账户',
style: TextStyle(fontSize: 12, color: grayText),
),
const SizedBox(height: 4),
Text(
formatAmount(_direction == 0 ? widget.tradingBalance : widget.miningBalance),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: darkText,
),
),
],
),
),
],
),
),
const SizedBox(height: 20),
// 数量输入
Text(
'划转数量',
style: TextStyle(fontSize: 14, color: darkText),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: bgGray),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: '请输入划转数量',
hintStyle: TextStyle(color: grayText, fontSize: 14),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
),
),
GestureDetector(
onTap: () {
_amountController.text = _availableBalance;
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'全部',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
'可用: ${formatAmount(_availableBalance)} 积分股',
style: TextStyle(fontSize: 12, color: grayText),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'提示: 最低划转数量为 0.01 积分股',
style: TextStyle(fontSize: 12, color: grayText),
),
GestureDetector(
onTap: () {
Navigator.pop(context);
context.push(Routes.transferRecords);
},
child: const Text(
'划转记录',
style: TextStyle(
fontSize: 12,
color: _orange,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 24),
// 提交按钮
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleTransfer,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
_direction == 0 ? '划入交易账户' : '划出到分配账户',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
),
);
}
Future<void> _handleTransfer() async {
final amount = _amountController.text.trim();
if (amount.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入划转数量')),
);
return;
}
final amountValue = double.tryParse(amount) ?? 0;
if (amountValue < 0.01) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('最低划转数量为 0.01 积分股')),
);
return;
}
final available = double.tryParse(_availableBalance) ?? 0;
if (amountValue > available) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('划转数量超过可用余额')),
);
return;
}
setState(() => _isLoading = true);
try {
bool success;
if (_direction == 0) {
// 从挖矿划入交易
success = await ref.read(tradingNotifierProvider.notifier).transferIn(amount);
} else {
// 从交易划出到挖矿
success = await ref.read(tradingNotifierProvider.notifier).transferOut(amount);
}
if (mounted) {
if (success) {
Navigator.pop(context);
widget.onTransferComplete();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_direction == 0 ? '划入成功' : '划出成功'),
backgroundColor: _green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('划转失败,请稍后重试'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('划转失败: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}