- 首页:移除盈亏日历,福利中心卡片改为白色背景,查看按钮改为黄色 - 资产页面:余额卡片撑满宽度 - 底部导航栏:移除背景高亮,只保留图标和文字变化 - 行情页面:调整卡片文字大小和柱形图样式 - 交易页面:买入按钮白色文字,卖出按钮红色文字,优化输入框和百分比卡片样式,添加顶部间距 - 全局:移除渐变色,统一使用纯色背景
290 lines
9.2 KiB
Dart
290 lines
9.2 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../../../core/theme/app_spacing.dart';
|
||
import '../../../core/theme/app_theme_extension.dart';
|
||
import '../../../data/models/coin.dart';
|
||
import '../../../providers/market_provider.dart';
|
||
import '../../../providers/asset_provider.dart';
|
||
import '../../../data/services/trade_service.dart';
|
||
import '../../components/neon_glow.dart';
|
||
import 'components/coin_selector.dart';
|
||
import 'components/price_card.dart';
|
||
import 'components/placeholder_card.dart';
|
||
import 'components/trade_form_card.dart';
|
||
import 'components/trade_button.dart';
|
||
import 'components/confirm_dialog.dart';
|
||
|
||
/// 交易页面
|
||
///
|
||
/// 设计稿 Trade 页面,布局结构:
|
||
/// - 币种选择器卡片(Coin Selector Card)
|
||
/// - 价格卡片(Price Card):大号价格 + 涨跌幅徽章 + 副标题
|
||
/// - 买入/卖出切换(Buy/Sell Toggle)
|
||
/// - 交易表单卡片(Trade Form Card):金额输入 + 快捷比例 + 计算数量
|
||
/// - CTA 买入/卖出按钮(Buy/Sell Button)
|
||
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 {
|
||
int _tradeType = 0; // 0=买入, 1=卖出
|
||
Coin? _selectedCoin;
|
||
final _amountController = TextEditingController();
|
||
bool _isSubmitting = false;
|
||
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||
}
|
||
|
||
void _loadData() {
|
||
final marketProvider = context.read<MarketProvider>();
|
||
marketProvider.loadCoins().then((_) {
|
||
if (widget.initialCoinCode != null && _selectedCoin == null) {
|
||
final coins = marketProvider.allCoins;
|
||
final coin = coins.firstWhere(
|
||
(c) =>
|
||
c.code.toUpperCase() == widget.initialCoinCode!.toUpperCase(),
|
||
orElse: () =>
|
||
coins.isNotEmpty ? coins.first : throw Exception('No coins'),
|
||
);
|
||
if (mounted) setState(() => _selectedCoin = coin);
|
||
}
|
||
});
|
||
context.read<AssetProvider>().refreshAll(force: true);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_amountController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 获取交易账户中 USDT 可用余额
|
||
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';
|
||
}
|
||
|
||
/// 计算可买入/卖出的最大 USDT 金额
|
||
String get _maxAmount {
|
||
if (_selectedCoin == null) return '0';
|
||
final price = _selectedCoin!.price;
|
||
if (price <= 0) return '0';
|
||
|
||
if (_tradeType == 0) {
|
||
return _availableUsdt;
|
||
} else {
|
||
final qty = double.tryParse(_availableCoinQty) ?? 0;
|
||
return (qty * price).toStringAsFixed(2);
|
||
}
|
||
}
|
||
|
||
/// 计算数量
|
||
String get _calculatedQuantity {
|
||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||
final price = _selectedCoin?.price ?? 0;
|
||
if (price <= 0 || amount <= 0) return '0';
|
||
return (amount / price).toStringAsFixed(6);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
|
||
return Scaffold(
|
||
backgroundColor: context.colors.background,
|
||
body: Consumer2<MarketProvider, AssetProvider>(
|
||
builder: (context, market, asset, _) {
|
||
return SafeArea(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpacing.md, AppSpacing.md, AppSpacing.md, AppSpacing.xl + AppSpacing.sm, // 添加顶部间距
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 币种选择器卡片
|
||
CoinSelector(
|
||
selectedCoin: _selectedCoin,
|
||
coins: market.allCoins
|
||
.where((c) =>
|
||
c.code != 'USDT' &&
|
||
c.code != 'BTC' &&
|
||
c.code != 'ETH')
|
||
.toList(),
|
||
onCoinSelected: (coin) {
|
||
setState(() {
|
||
_selectedCoin = coin;
|
||
_amountController.clear();
|
||
});
|
||
},
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
|
||
// 价格卡片
|
||
if (_selectedCoin != null)
|
||
PriceCard(coin: _selectedCoin!)
|
||
else
|
||
PlaceholderCard(
|
||
message: '请先选择交易币种',
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
|
||
// 交易表单卡片(内含买入/卖出切换 + 表单)
|
||
TradeFormCard(
|
||
tradeType: _tradeType,
|
||
selectedCoin: _selectedCoin,
|
||
amountController: _amountController,
|
||
availableUsdt: _availableUsdt,
|
||
availableCoinQty: _availableCoinQty,
|
||
calculatedQuantity: _calculatedQuantity,
|
||
maxAmount: _maxAmount,
|
||
onTradeTypeChanged: (type) => setState(() {
|
||
_tradeType = type;
|
||
_amountController.clear();
|
||
}),
|
||
onAmountChanged: () => setState(() {}),
|
||
onFillPercent: (pct) => _fillPercent(pct),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
|
||
// CTA 买入/卖出按钮
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 48,
|
||
child: TradeButton(
|
||
isBuy: _tradeType == 0,
|
||
coinCode: _selectedCoin?.code,
|
||
enabled: _canTrade() && !_isSubmitting,
|
||
isLoading: _isSubmitting,
|
||
onPressed: _executeTrade,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _canTrade() {
|
||
if (_selectedCoin == null) return false;
|
||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||
if (amount <= 0) return false;
|
||
// 买入时校验不超过可用USDT
|
||
if (_tradeType == 0) {
|
||
final available = double.tryParse(_availableUsdt) ?? 0;
|
||
if (amount > available) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void _fillPercent(double pct) {
|
||
final max = double.tryParse(_maxAmount) ?? 0;
|
||
_amountController.text = (max * pct).toStringAsFixed(2);
|
||
setState(() {});
|
||
}
|
||
|
||
void _executeTrade() async {
|
||
final isBuy = _tradeType == 0;
|
||
final amount = _amountController.text;
|
||
final quantity = _calculatedQuantity;
|
||
final price = _selectedCoin!.price.toStringAsFixed(2);
|
||
final coinCode = _selectedCoin!.code;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => ConfirmDialog(
|
||
isBuy: isBuy,
|
||
coinCode: coinCode,
|
||
price: price,
|
||
quantity: quantity,
|
||
amount: amount,
|
||
),
|
||
);
|
||
|
||
if (confirmed != true) return;
|
||
|
||
setState(() => _isSubmitting = true);
|
||
|
||
try {
|
||
final tradeService = context.read<TradeService>();
|
||
final response = isBuy
|
||
? await tradeService.buy(
|
||
coinCode: coinCode, price: price, quantity: quantity)
|
||
: await tradeService.sell(
|
||
coinCode: coinCode, price: price, quantity: quantity);
|
||
|
||
if (!mounted) return;
|
||
|
||
if (response.success) {
|
||
_amountController.clear();
|
||
// 刷新资产数据
|
||
context.read<AssetProvider>().refreshAll(force: true);
|
||
_showResultDialog(true, '${isBuy ? '买入' : '卖出'}成功',
|
||
'$quantity $coinCode @ $price USDT');
|
||
} else {
|
||
_showResultDialog(false, '交易失败', response.message ?? '请稍后重试');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
_showResultDialog(false, '交易失败', e.toString());
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _isSubmitting = false);
|
||
}
|
||
}
|
||
|
||
void _showResultDialog(bool success, String title, String message) {
|
||
showShadDialog(
|
||
context: context,
|
||
builder: (ctx) => ShadDialog.alert(
|
||
title: Row(
|
||
children: [
|
||
NeonIcon(
|
||
icon: success ? Icons.check_circle : Icons.error,
|
||
color: success
|
||
? ctx.appColors.up
|
||
: ctx.colors.error,
|
||
size: 24,
|
||
),
|
||
SizedBox(width: AppSpacing.sm),
|
||
Text(title),
|
||
],
|
||
),
|
||
description: Text(message),
|
||
actions: [
|
||
ShadButton(
|
||
child: const Text('确定'),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|