808 lines
32 KiB
Dart
808 lines
32 KiB
Dart
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()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|