Files
monisuo/flutter_monisuo/lib/ui/pages/trade/trade_page.dart
2026-04-21 08:09:45 +08:00

808 lines
32 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:provider/provider.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_theme_extension.dart';
import '../../../core/event/app_event_bus.dart';
import '../../../data/models/coin.dart';
import '../../../data/models/order_models.dart';
import '../../../providers/market_provider.dart';
import '../../../providers/asset_provider.dart';
import '../../../providers/trade_provider.dart';
import '../../../data/services/trade_service.dart';
import '../../components/neon_glow.dart';
import '../../components/coin_icon.dart';
import 'components/price_card.dart';
import 'components/placeholder_card.dart';
import 'components/split_trade_form.dart';
import 'components/confirm_dialog.dart';
import 'trade_history_page.dart';
/// 交易頁面 — 幣安級佈局
///
/// Header → 實時價格 → 左表單+右訂單簿 → 底部(當前委託/持有幣種)
class TradePage extends StatefulWidget {
final String? initialCoinCode;
const TradePage({super.key, this.initialCoinCode});
@override
State<TradePage> createState() => _TradePageState();
}
class _TradePageState extends State<TradePage>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
Coin? _selectedCoin;
bool _isBuySubmitting = false;
bool _isSellSubmitting = false;
int _orderType = 1;
double _realtimePrice = 0;
final _buyPriceController = TextEditingController();
final _buyQuantityController = TextEditingController();
final _sellPriceController = TextEditingController();
final _sellQuantityController = TextEditingController();
late TabController _bottomTabController;
List<OrderTrade> _pendingOrders = [];
bool _isLoadingOrders = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_bottomTabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
@override
void dispose() {
_buyPriceController.dispose();
_buyQuantityController.dispose();
_sellPriceController.dispose();
_sellQuantityController.dispose();
_bottomTabController.dispose();
super.dispose();
}
void _loadData() {
final marketProvider = context.read<MarketProvider>();
marketProvider.loadCoins().then((_) {
if (_selectedCoin != null) return;
final coins = marketProvider.allCoins;
if (coins.isEmpty) return;
Coin? coin;
if (widget.initialCoinCode != null) {
coin = coins.firstWhere(
(c) => c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
orElse: () => coins.first,
);
} else {
// 默认选中第一个平台代币
final platformCoins = marketProvider.platformCoins;
coin = platformCoins.isNotEmpty ? platformCoins.first : coins.first;
}
if (mounted) _selectCoin(coin);
});
final assetProvider = context.read<AssetProvider>();
if (assetProvider.holdings.isEmpty) {
assetProvider.refreshAll(force: true);
}
_loadPendingOrders();
}
void _selectCoin(Coin coin) {
setState(() {
_selectedCoin = coin;
_buyQuantityController.clear();
_sellQuantityController.clear();
});
context.read<TradeProvider>().selectCoin(coin);
}
void _showCoinPicker(BuildContext context, List<Coin> coins) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => Container(
height: MediaQuery.of(ctx).size.height * 0.5,
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: AppSpacing.sm),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Text('选择币种', style: AppTextStyles.headlineLarge(context)),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: coins.length,
itemBuilder: (_, index) {
final c = coins[index];
final selected = c.code == _selectedCoin?.code;
return ListTile(
leading: CoinIcon(symbol: c.code, size: 28),
title: Text('${c.code}/USDT', style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(c.name, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant)),
trailing: selected ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) : null,
selectedTileColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
selected: selected,
onTap: () { Navigator.of(ctx).pop(); _selectCoin(c); },
);
},
),
),
],
),
),
);
}
Future<void> _loadPendingOrders() async {
if (_isLoadingOrders) return;
setState(() => _isLoadingOrders = true);
try {
final response = await context.read<TradeService>().getOrders(
coinCode: _selectedCoin?.code,
pageNum: 1,
pageSize: 50,
);
if (response.success && response.data != null) {
final list = response.data!['list'] as List? ?? [];
setState(() {
_pendingOrders = list
.map((e) => OrderTrade.fromJson(e as Map<String, dynamic>))
.where((o) => o.status == 0)
.toList();
});
}
} catch (_) {
} finally {
if (mounted) setState(() => _isLoadingOrders = false);
}
}
String get _availableUsdt {
final holdings = context.read<AssetProvider>().holdings;
final usdt = holdings.where((h) => h.coinCode == 'USDT').firstOrNull;
return usdt?.quantity ?? '0';
}
String get _availableCoinQty {
if (_selectedCoin == null) return '0';
final holdings = context.read<AssetProvider>().holdings;
final pos = holdings.where((h) => h.coinCode == _selectedCoin!.code).firstOrNull;
return pos?.quantity ?? '0';
}
@override
Widget build(BuildContext context) {
super.build(context);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: Consumer3<MarketProvider, AssetProvider, TradeProvider>(
builder: (context, market, asset, tradeProvider, _) {
final filteredCoins = market.allCoins
.where((c) => c.code != 'USDT' && c.code != 'BTC' && c.code != 'ETH')
.toList();
// 从 TradeProvider 获取实时价格,更新到 controller
_realtimePrice = tradeProvider.currentPrice;
final realtimePrice = _realtimePrice;
if (realtimePrice > 0 && _selectedCoin != null) {
// 市价单:同步实时价到价格框
if (_orderType == 1) {
_buyPriceController.text = realtimePrice.toStringAsFixed(2);
_sellPriceController.text = realtimePrice.toStringAsFixed(2);
}
// 只在价格变化时才更新 Coin 对象,减少 GC 压力
if (_selectedCoin!.price != realtimePrice ||
_selectedCoin!.change24h != tradeProvider.change24h) {
_selectedCoin = Coin(
id: _selectedCoin!.id,
code: _selectedCoin!.code,
name: _selectedCoin!.name,
price: realtimePrice,
priceType: _selectedCoin!.priceType,
change24h: tradeProvider.change24h,
high24h: tradeProvider.high24h,
low24h: tradeProvider.low24h,
volume24h: tradeProvider.volume24h,
status: _selectedCoin!.status,
);
}
}
return Column(
children: [
// 顶部标题栏
Container(
height: 48,
alignment: Alignment.center,
child: Text(
'交易',
style: AppTextStyles.headlineLarge(context).copyWith(
fontWeight: FontWeight.w700,
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(
left: AppSpacing.md,
right: AppSpacing.md,
top: AppSpacing.sm,
bottom: AppSpacing.xl,
),
child: Column(children: [
if (_selectedCoin != null)
PriceCard(
coin: _selectedCoin!,
tradingStatus: tradeProvider.tradingStatus,
onTapCoin: () => _showCoinPicker(context, market.platformCoins),
)
else
const Padding(
padding: EdgeInsets.all(AppSpacing.md),
child: PlaceholderCard(message: '請先選擇交易幣種'),
),
const SizedBox(height: AppSpacing.sm),
if (_selectedCoin != null)
SplitTradeForm(
coin: _selectedCoin,
depth: tradeProvider.depth,
realtimePrice: realtimePrice,
buyPriceController: _buyPriceController,
buyQuantityController: _buyQuantityController,
sellPriceController: _sellPriceController,
sellQuantityController: _sellQuantityController,
availableUsdt: _availableUsdt,
availableCoinQty: _availableCoinQty,
onBuyQuantityChanged: () => setState(() {}),
onSellQuantityChanged: () => setState(() {}),
onBuyFillPercent: _buyFillPercent,
onSellFillPercent: _sellFillPercent,
onBuySubmit: tradeProvider.isTradable && _canBuy() && !_isBuySubmitting
? () => _executeTrade(isBuy: true) : null,
onSellSubmit: tradeProvider.isTradable && _canSell() && !_isSellSubmitting
? () => _executeTrade(isBuy: false) : null,
orderType: _orderType,
onOrderTypeChanged: (type) {
setState(() {
_orderType = type;
if (type == 1 && realtimePrice > 0) {
_buyPriceController.text = realtimePrice.toStringAsFixed(2);
_sellPriceController.text = realtimePrice.toStringAsFixed(2);
}
});
},
)
else
const SizedBox.shrink(),
if (_selectedCoin != null) ...[
const SizedBox(height: AppSpacing.sm),
_buildBottomSection(context, asset, tradeProvider),
],
]),
),
),
],
);
},
),
);
}
// ==========================================
// 底部:当前委托 / 持有币种
// ==========================================
Widget _buildBottomSection(BuildContext context, AssetProvider asset, TradeProvider tradeProvider) {
final holdingsCount = asset.holdings
.where((h) => h.coinCode != 'USDT' && double.tryParse(h.quantity)?.compareTo(0) == 1)
.length;
return Container(
decoration: BoxDecoration(
color: context.appColors.surfaceCard,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: context.appColors.ghostBorder),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Row(children: [
_buildTabLabel(context, '当前委托', 0, _pendingOrders.length),
const SizedBox(width: 16),
_buildTabLabel(context, '持有币种', 1, holdingsCount),
const Spacer(),
GestureDetector(
onTap: () => _navigateToHistory(context),
child: Icon(Icons.history, size: 18, color: context.colors.onSurfaceVariant),
),
]),
),
const Divider(height: 1),
SizedBox(
height: 180,
child: TabBarView(
controller: _bottomTabController,
children: [
_buildPendingOrdersTab(context),
_buildHoldingsTab(context, asset, tradeProvider),
],
),
),
]),
);
}
Widget _buildTabLabel(BuildContext context, String label, int index, int count) {
final isSelected = _bottomTabController.index == index;
final colorScheme = context.colors;
return GestureDetector(
onTap: () { _bottomTabController.animateTo(index); setState(() {}); },
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isSelected ? context.colors.primary : Colors.transparent,
width: 2,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? context.colors.primary : colorScheme.onSurfaceVariant,
),
),
if (count > 0) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: isSelected
? context.colors.primary.withValues(alpha: 0.15)
: colorScheme.onSurfaceVariant.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text('$count',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isSelected ? context.colors.primary : colorScheme.onSurfaceVariant,
)),
),
],
],
),
),
);
}
Widget _buildPendingOrdersTab(BuildContext context) {
if (_isLoadingOrders) {
return const Center(child: SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2),
));
}
if (_pendingOrders.isEmpty) {
return Center(child: Text('暂无委托订单',
style: AppTextStyles.bodySmall(context).copyWith(color: context.colors.onSurfaceVariant)));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
itemCount: _pendingOrders.length,
itemBuilder: (context, index) {
final order = _pendingOrders[index];
final isBuy = order.isBuy;
final dirColor = isBuy ? context.appColors.up : context.appColors.down;
final dirText = isBuy ? '买入' : '卖出';
final amount = (double.tryParse(order.price) ?? 0) * (double.tryParse(order.quantity) ?? 0);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(children: [
// 第一行:方向+币种+类型 | 委托金额 | 撤销
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: dirColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
child: Text(dirText,
style: AppTextStyles.bodySmall(context).copyWith(color: dirColor, fontWeight: FontWeight.w600)),
),
const SizedBox(width: 6),
Text(order.coinCode,
style: AppTextStyles.bodySmall(context).copyWith(fontWeight: FontWeight.w600)),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
border: Border.all(color: context.colors.onSurfaceVariant.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(3),
),
child: Text(order.orderTypeText,
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 9, color: context.colors.onSurfaceVariant)),
),
const Spacer(),
Text('${amount.toStringAsFixed(2)} USDT',
style: AppTextStyles.numberSmall(context).copyWith(fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
GestureDetector(
onTap: () => _cancelOrder(order.orderNo),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: context.colors.onSurfaceVariant.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(4),
),
child: Text('撤销',
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 11)),
),
),
]),
const SizedBox(height: 8),
// 第二行:委托价 | 数量 | 时间
Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('委托价', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(order.price, style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
])),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
Text('数量', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(order.quantity, style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
])),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('时间', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(_formatTime(order.createTime),
style: AppTextStyles.bodySmall(context).copyWith(fontSize: 11)),
])),
]),
]),
);
},
);
}
String _formatTime(DateTime? time) {
if (time == null) return '--';
return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Widget _buildHoldingsTab(BuildContext context, AssetProvider asset, TradeProvider tradeProvider) {
final holdings = asset.holdings
.where((h) => h.coinCode != 'USDT' && double.tryParse(h.quantity)?.compareTo(0) == 1)
.toList();
if (holdings.isEmpty) {
return Center(child: Text('暂无持仓',
style: AppTextStyles.bodySmall(context).copyWith(color: context.colors.onSurfaceVariant)));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
itemCount: holdings.length,
itemBuilder: (context, index) {
final h = holdings[index];
final qty = double.tryParse(h.quantity) ?? 0;
final avgPrice = double.tryParse(h.avgPrice) ?? 0;
final cost = avgPrice * qty;
// 判断是否平台代币,获取今日盈利比率
final coinInfo = context.read<MarketProvider>().getCoinByCode(h.coinCode);
final isPlatform = coinInfo?.isPlatform == 1;
final todayRate = coinInfo?.todayProfitRate ?? 0.005;
double value;
double profit;
double displayPrice;
if (isPlatform) {
// 平台代币:未实现盈亏 = 成本 × 今日盈利比率
profit = cost * todayRate;
value = cost + profit;
displayPrice = avgPrice * (1 + todayRate);
} else {
displayPrice = tradeProvider.currentPrice > 0 && h.coinCode == _selectedCoin?.code
? tradeProvider.currentPrice : avgPrice;
value = displayPrice * qty;
profit = value - cost;
}
final profitRate = cost > 0 ? (profit / cost) * 100 : 0.0;
final isProfit = profit >= 0;
final rateColor = isProfit ? context.appColors.up : context.appColors.down;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(children: [
// 第一行:币种 | 卖出 | 盈亏%
Row(children: [
Text(h.coinCode,
style: AppTextStyles.bodySmall(context).copyWith(fontWeight: FontWeight.w700, fontSize: 14)),
const Spacer(),
GestureDetector(
onTap: () => _quickSell(h.coinCode, h.quantity, displayPrice),
child: Text('卖出',
style: AppTextStyles.bodySmall(context).copyWith(
color: context.appColors.down, fontSize: 12)),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: rateColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
child: Text('${isProfit ? '+' : ''}${profitRate.toStringAsFixed(2)}%',
style: AppTextStyles.bodySmall(context).copyWith(
color: rateColor, fontWeight: FontWeight.w700, fontSize: 13)),
),
]),
const SizedBox(height: 8),
// 第二行:持有 | 买入均价 | 持仓成本
Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('持有', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(_truncate4(qty), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
])),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
Text('均价', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(_truncate4(avgPrice), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
])),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('持仓成本', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text('${cost.toStringAsFixed(2)} U', style: AppTextStyles.numberSmall(context).copyWith(
fontSize: 12, fontWeight: FontWeight.w600)),
])),
]),
const SizedBox(height: 6),
// 第三行:现价/预估价 | 未实现盈亏
Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(isPlatform ? '预估价' : '现价', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text(_truncate4(displayPrice), style: AppTextStyles.numberSmall(context).copyWith(fontSize: 12)),
])),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('未实现盈亏', style: AppTextStyles.bodySmall(context).copyWith(
color: context.colors.onSurfaceVariant, fontSize: 10)),
const SizedBox(height: 2),
Text('${isProfit ? '+' : ''}${profit.toStringAsFixed(2)} U',
style: AppTextStyles.numberSmall(context).copyWith(
fontSize: 12, color: rateColor, fontWeight: FontWeight.w600)),
])),
]),
]),
);
},
);
}
String _truncate4(double v) => (v * 10000).truncate() / 10000 < 0.0001
? '0.0000' : ((v * 10000).truncate() / 10000).toStringAsFixed(4);
Future<void> _quickSell(String coinCode, String quantity, double price) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => ConfirmDialog(
isBuy: false,
coinCode: coinCode,
price: price.toStringAsFixed(2),
quantity: quantity,
amount: (price * (double.tryParse(quantity) ?? 0)).toStringAsFixed(2),
),
);
if (confirmed != true) return;
setState(() => _isSellSubmitting = true);
try {
final response = await context.read<TradeService>().sell(
coinCode: coinCode,
price: price.toStringAsFixed(2),
quantity: quantity,
orderType: 1,
);
if (!mounted) return;
if (response.success) {
context.read<AssetProvider>().refreshAll(force: true);
context.read<AppEventBus>().fire(AppEventType.assetChanged);
_loadPendingOrders();
_showResultDialog(true, '賣出成功', '市價賣出 $quantity $coinCode');
} else {
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
}
} catch (e) {
if (mounted) _showResultDialog(false, '交易失敗', e.toString());
} finally {
if (mounted) setState(() => _isSellSubmitting = false);
}
}
void _navigateToHistory(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => TradeHistoryPage(coinCode: _selectedCoin?.code)),
).then((_) {
// 返回时刷新委托列表
_loadPendingOrders();
context.read<AssetProvider>().refreshAll(force: true);
});
}
Future<void> _cancelOrder(String orderNo) async {
try {
final response = await context.read<TradeService>().cancelOrder(orderNo);
if (mounted) {
if (response.success) {
_loadPendingOrders();
context.read<AssetProvider>().refreshAll(force: true);
}
}
} catch (_) {}
}
// ============================================
// 交易逻辑
// ============================================
bool _canBuy() {
if (_selectedCoin == null) return false;
final qty = double.tryParse(_buyQuantityController.text) ?? 0;
if (qty <= 0) return false;
final price = double.tryParse(_buyPriceController.text) ?? 0;
final amount = price * qty;
final available = double.tryParse(_availableUsdt) ?? 0;
return amount <= available;
}
bool _canSell() {
if (_selectedCoin == null) return false;
final qty = double.tryParse(_sellQuantityController.text) ?? 0;
if (qty <= 0) return false;
final available = double.tryParse(_availableCoinQty) ?? 0;
return qty <= available;
}
void _buyFillPercent(double pct) {
final price = _orderType == 1 ? _realtimePrice : (double.tryParse(_buyPriceController.text) ?? 0);
final available = double.tryParse(_availableUsdt) ?? 0;
if (price <= 0) return;
// 100%时留极小余量防精度误差
final safePct = pct >= 1.0 ? 0.9999 : pct;
final qty = (available / price) * safePct;
_buyQuantityController.text = qty < 0.0001 ? '' : ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4);
setState(() {});
}
void _sellFillPercent(double pct) {
final available = double.tryParse(_availableCoinQty) ?? 0;
final qty = available * pct;
_sellQuantityController.text = (qty * 10000).truncateToDouble() / 10000 < 0.0001
? '' : ((qty * 10000).truncateToDouble() / 10000).toStringAsFixed(4);
setState(() {});
}
void _executeTrade({required bool isBuy}) async {
final priceController = isBuy ? _buyPriceController : _sellPriceController;
final qtyController = isBuy ? _buyQuantityController : _sellQuantityController;
final price = priceController.text;
final quantity = qtyController.text;
final coinCode = _selectedCoin!.code;
final typeLabel = _orderType == 1 ? '市价单' : '限价单';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => ConfirmDialog(
isBuy: isBuy,
coinCode: coinCode,
price: price,
quantity: quantity,
amount: ((double.tryParse(price) ?? 0) * (double.tryParse(quantity) ?? 0)).toStringAsFixed(2),
),
);
if (confirmed != true) return;
setState(() {
if (isBuy) _isBuySubmitting = true;
else _isSellSubmitting = true;
});
try {
final tradeService = context.read<TradeService>();
final response = isBuy
? await tradeService.buy(coinCode: coinCode, price: price, quantity: quantity, orderType: _orderType)
: await tradeService.sell(coinCode: coinCode, price: price, quantity: quantity, orderType: _orderType);
if (!mounted) return;
if (response.success) {
qtyController.clear();
context.read<AssetProvider>().refreshAll(force: true);
context.read<AppEventBus>().fire(AppEventType.assetChanged);
_loadPendingOrders();
final msg = _orderType == 2
? '$typeLabel委托成功: $quantity $coinCode @ $price USDT'
: '$typeLabel: $quantity $coinCode @ $price USDT';
_showResultDialog(true, isBuy ? '買入成功' : '賣出成功', msg);
} else {
_showResultDialog(false, '交易失敗', response.message ?? '請稍後重試');
}
} catch (e) {
if (mounted) _showResultDialog(false, '交易失敗', e.toString());
} finally {
if (mounted) {
setState(() {
if (isBuy) _isBuySubmitting = false;
else _isSellSubmitting = false;
});
}
}
}
void _showResultDialog(bool success, String title, String message) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Row(children: [
NeonIcon(icon: success ? Icons.check_circle : Icons.error,
color: success ? ctx.appColors.up : Theme.of(ctx).colorScheme.error, size: 24),
const SizedBox(width: AppSpacing.sm),
Text(title),
]),
content: Text(message),
actions: [
TextButton(child: const Text('確定'), onPressed: () => Navigator.of(ctx).pop()),
],
),
);
}
}