- 重构主题字号体系 (h1-h4, body, amount等) - 修复16个页面文件中的硬编码字号 - 新字号层级参考币安/OKX标准 - Display: 22/20/18px (总资产、价格) - Headline: 15/14/13px (标题、副标题) - Body: 13/12/11px (正文、辅助文字) - Label: 11/10/9px (标签) - Number: 22/16/13px (数字)
1198 lines
39 KiB
Dart
1198 lines
39 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import '../../../core/theme/app_color_scheme.dart';
|
|
import '../../../core/theme/app_spacing.dart';
|
|
import '../../../data/models/coin.dart';
|
|
import '../../../providers/market_provider.dart';
|
|
import '../../../providers/asset_provider.dart';
|
|
import '../../../data/services/trade_service.dart';
|
|
import '../../components/glass_panel.dart';
|
|
import '../../components/neon_glow.dart';
|
|
|
|
/// 交易页面
|
|
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);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
backgroundColor: colorScheme.background,
|
|
body: Consumer2<MarketProvider, AssetProvider>(
|
|
builder: (context, market, asset, _) {
|
|
return SingleChildScrollView(
|
|
padding: AppSpacing.pagePadding,
|
|
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();
|
|
});
|
|
},
|
|
),
|
|
SizedBox(height: AppSpacing.md),
|
|
|
|
// 价格卡片
|
|
if (_selectedCoin != null)
|
|
_PriceCard(coin: _selectedCoin!)
|
|
else
|
|
const _PlaceholderCard(message: '请先选择交易币种'),
|
|
|
|
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),
|
|
),
|
|
|
|
SizedBox(height: AppSpacing.lg),
|
|
|
|
// 买入 + 卖出双按钮
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TradeButton(
|
|
isBuy: true,
|
|
coinCode: _selectedCoin?.code,
|
|
enabled: _canTrade() && !_isSubmitting,
|
|
isLoading: _isSubmitting && _tradeType == 0,
|
|
onPressed: () {
|
|
_tradeType = 0;
|
|
_executeTrade();
|
|
},
|
|
),
|
|
),
|
|
SizedBox(width: AppSpacing.md),
|
|
Expanded(
|
|
child: _TradeButton(
|
|
isBuy: false,
|
|
coinCode: _selectedCoin?.code,
|
|
enabled: _canTrade() && !_isSubmitting,
|
|
isLoading: _isSubmitting && _tradeType == 1,
|
|
onPressed: () {
|
|
_tradeType = 1;
|
|
_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) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
showShadDialog(
|
|
context: context,
|
|
builder: (ctx) => ShadDialog.alert(
|
|
title: Row(
|
|
children: [
|
|
NeonIcon(
|
|
icon: success ? Icons.check_circle : Icons.error,
|
|
color: success
|
|
? AppColorScheme.getUpColor(isDark)
|
|
: colorScheme.error,
|
|
size: 24,
|
|
),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Text(title),
|
|
],
|
|
),
|
|
description: Text(message),
|
|
actions: [
|
|
ShadButton(
|
|
child: const Text('确定'),
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 确认对话框
|
|
class _ConfirmDialog extends StatelessWidget {
|
|
final bool isBuy;
|
|
final String coinCode;
|
|
final String price;
|
|
final String quantity;
|
|
final String amount;
|
|
|
|
const _ConfirmDialog({
|
|
required this.isBuy,
|
|
required this.coinCode,
|
|
required this.price,
|
|
required this.quantity,
|
|
required this.amount,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final actionColor = isBuy
|
|
? AppColorScheme.getUpColor(isDark)
|
|
: AppColorScheme.getDownColor(isDark);
|
|
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
child: GlassPanel(
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Text(
|
|
'确认${isBuy ? '买入' : '卖出'}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: AppSpacing.lg),
|
|
_dialogRow('交易对', '$coinCode/USDT', colorScheme),
|
|
SizedBox(height: AppSpacing.sm),
|
|
_dialogRow('委托价格', '$price USDT', colorScheme),
|
|
SizedBox(height: AppSpacing.sm),
|
|
_dialogRow('交易金额', '$amount USDT', colorScheme,
|
|
valueColor: actionColor),
|
|
SizedBox(height: AppSpacing.sm),
|
|
_dialogRow('交易数量', '$quantity $coinCode', colorScheme),
|
|
SizedBox(height: AppSpacing.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: NeonButton(
|
|
text: '取消',
|
|
type: NeonButtonType.outline,
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
height: 44,
|
|
showGlow: false,
|
|
),
|
|
),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: NeonButton(
|
|
text: '确认${isBuy ? '买入' : '卖出'}',
|
|
type: isBuy ? NeonButtonType.tertiary : NeonButtonType.error,
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
height: 44,
|
|
showGlow: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _dialogRow(String label, String value, ColorScheme colorScheme,
|
|
{Color? valueColor}) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 14, color: colorScheme.onSurfaceVariant)),
|
|
Text(value,
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: valueColor ?? colorScheme.onSurface,
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 币种选择器
|
|
class _CoinSelector extends StatelessWidget {
|
|
final Coin? selectedCoin;
|
|
final List<Coin> coins;
|
|
final ValueChanged<Coin> onCoinSelected;
|
|
|
|
const _CoinSelector({
|
|
required this.selectedCoin,
|
|
required this.coins,
|
|
required this.onCoinSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return GestureDetector(
|
|
onTap: () => _showCoinPicker(context),
|
|
child: GlassPanel(
|
|
padding: EdgeInsets.all(AppSpacing.md),
|
|
child: Row(
|
|
children: [
|
|
_CoinAvatar(icon: selectedCoin?.displayIcon),
|
|
SizedBox(width: AppSpacing.sm + AppSpacing.xs),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
selectedCoin != null
|
|
? '${selectedCoin!.code}/USDT'
|
|
: '选择币种',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
selectedCoin?.name ?? '点击选择交易对',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(LucideIcons.chevronDown, color: colorScheme.onSurfaceVariant),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCoinPicker(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (ctx) => Container(
|
|
height: MediaQuery.of(ctx).size.height * 0.65,
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? colorScheme.surface
|
|
: colorScheme.surfaceContainerLowest,
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
margin: EdgeInsets.only(top: AppSpacing.sm),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('选择币种',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
)),
|
|
GestureDetector(
|
|
onTap: () => Navigator.of(ctx).pop(),
|
|
child: Icon(LucideIcons.x,
|
|
color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1, color: colorScheme.outlineVariant.withOpacity(0.2)),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
|
itemCount: coins.length,
|
|
itemBuilder: (listCtx, index) =>
|
|
_buildCoinItem(coins[index], context, listCtx),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCoinItem(
|
|
Coin coin, BuildContext context, BuildContext sheetContext) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isSelected = selectedCoin?.code == coin.code;
|
|
final changeColor = coin.isUp
|
|
? AppColorScheme.getUpColor(isDark)
|
|
: AppColorScheme.getDownColor(isDark);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(sheetContext).pop();
|
|
onCoinSelected(coin);
|
|
},
|
|
child: Container(
|
|
padding:
|
|
EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: AppSpacing.md),
|
|
color:
|
|
isSelected ? colorScheme.primary.withOpacity(0.1) : Colors.transparent,
|
|
child: Row(
|
|
children: [
|
|
_CoinAvatar(icon: coin.displayIcon),
|
|
SizedBox(width: AppSpacing.md),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 第一行:币种代码 + 价格 + 涨跌幅
|
|
Row(
|
|
children: [
|
|
Text(coin.code,
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
)),
|
|
SizedBox(width: AppSpacing.xs),
|
|
Text('/USDT',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSurfaceVariant,
|
|
)),
|
|
const Spacer(),
|
|
Text('\$${coin.formattedPrice}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
)),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: changeColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Text(coin.formattedChange,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: changeColor,
|
|
fontWeight: FontWeight.w600,
|
|
)),
|
|
),
|
|
if (isSelected) ...[
|
|
SizedBox(width: AppSpacing.sm),
|
|
Icon(LucideIcons.check,
|
|
size: 16, color: colorScheme.primary),
|
|
],
|
|
],
|
|
),
|
|
SizedBox(height: 3),
|
|
// 第二行:币种名称
|
|
Text(coin.name,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 币种头像
|
|
class _CoinAvatar extends StatelessWidget {
|
|
final String? icon;
|
|
const _CoinAvatar({this.icon});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
border: Border.all(color: colorScheme.primary.withOpacity(0.2)),
|
|
),
|
|
child: Center(
|
|
child: Text(icon ?? '?',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
color: colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 价格卡片 - 重新设计
|
|
class _PriceCard extends StatelessWidget {
|
|
final Coin coin;
|
|
const _PriceCard({required this.coin});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isUp = coin.isUp;
|
|
final changeColor =
|
|
isUp ? AppColorScheme.getUpColor(isDark) : AppColorScheme.down;
|
|
final changeBgColor = isUp
|
|
? AppColorScheme.getUpBackgroundColor(isDark)
|
|
: colorScheme.error.withOpacity(0.1);
|
|
|
|
return GlassPanel(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.lg, vertical: AppSpacing.md + AppSpacing.sm),
|
|
child: Row(
|
|
children: [
|
|
// 左侧:币种标签 + 价格
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Text(
|
|
'${coin.code}/USDT',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
'\$${coin.formattedPrice}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 右侧:涨跌幅
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2),
|
|
decoration: BoxDecoration(
|
|
color: changeBgColor,
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
border: Border.all(color: changeColor.withOpacity(0.15)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'24h',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: changeColor.withOpacity(0.7),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
SizedBox(height: 2),
|
|
Text(
|
|
coin.formattedChange,
|
|
style: TextStyle(
|
|
fontSize: 16, color: changeColor, fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 占位卡片
|
|
class _PlaceholderCard extends StatelessWidget {
|
|
final String message;
|
|
const _PlaceholderCard({required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return GlassPanel(
|
|
padding: EdgeInsets.all(AppSpacing.xl),
|
|
child: Center(
|
|
child: Text(message,
|
|
style: TextStyle(
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontSize: 14,
|
|
)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 交易表单卡片 - 重新设计
|
|
class _TradeFormCard extends StatelessWidget {
|
|
final int tradeType;
|
|
final Coin? selectedCoin;
|
|
final TextEditingController amountController;
|
|
final String availableUsdt;
|
|
final String availableCoinQty;
|
|
final String calculatedQuantity;
|
|
final String maxAmount;
|
|
final ValueChanged<int> onTradeTypeChanged;
|
|
final VoidCallback onAmountChanged;
|
|
final ValueChanged<double> onFillPercent;
|
|
|
|
const _TradeFormCard({
|
|
required this.tradeType,
|
|
required this.selectedCoin,
|
|
required this.amountController,
|
|
required this.availableUsdt,
|
|
required this.availableCoinQty,
|
|
required this.calculatedQuantity,
|
|
required this.maxAmount,
|
|
required this.onTradeTypeChanged,
|
|
required this.onAmountChanged,
|
|
required this.onFillPercent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isBuy = tradeType == 0;
|
|
final actionColor = isBuy
|
|
? AppColorScheme.getUpColor(isDark)
|
|
: AppColorScheme.getDownColor(isDark);
|
|
|
|
return GlassPanel(
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 买入/卖出切换 - 重新设计
|
|
Container(
|
|
padding: EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildTypeButton(
|
|
context: context,
|
|
label: '买入',
|
|
isActive: isBuy,
|
|
color: AppColorScheme.buyButtonFill,
|
|
icon: LucideIcons.trendingUp,
|
|
onTap: () => onTradeTypeChanged(0),
|
|
),
|
|
),
|
|
SizedBox(width: 4),
|
|
Expanded(
|
|
child: _buildTypeButton(
|
|
context: context,
|
|
label: '卖出',
|
|
isActive: !isBuy,
|
|
color: AppColorScheme.sellButtonFill,
|
|
icon: LucideIcons.trendingDown,
|
|
onTap: () => onTradeTypeChanged(1),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: AppSpacing.lg),
|
|
|
|
// 交易金额输入
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('交易金额',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
)),
|
|
Text('USDT',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: actionColor.withOpacity(0.7),
|
|
letterSpacing: 0.5,
|
|
)),
|
|
],
|
|
),
|
|
SizedBox(height: AppSpacing.sm),
|
|
_AmountInput(
|
|
amountController: amountController,
|
|
maxAmount: maxAmount,
|
|
isBuy: isBuy,
|
|
actionColor: actionColor,
|
|
onChanged: onAmountChanged,
|
|
),
|
|
SizedBox(height: AppSpacing.sm),
|
|
|
|
// 快捷比例按钮 - 药丸样式
|
|
Row(
|
|
children: [
|
|
_buildPctButton('25%', 0.25, colorScheme, actionColor),
|
|
SizedBox(width: 6),
|
|
_buildPctButton('50%', 0.5, colorScheme, actionColor),
|
|
SizedBox(width: 6),
|
|
_buildPctButton('75%', 0.75, colorScheme, actionColor),
|
|
SizedBox(width: 6),
|
|
_buildPctButton('全部', 1.0, colorScheme, actionColor),
|
|
],
|
|
),
|
|
SizedBox(height: AppSpacing.lg),
|
|
|
|
// 预计数量 + 可用余额 - 卡片样式
|
|
Container(
|
|
padding: EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
border: Border.all(
|
|
color: colorScheme.outlineVariant.withOpacity(0.1)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// 预计数量
|
|
Row(
|
|
children: [
|
|
Icon(LucideIcons.calculator,
|
|
size: 14, color: colorScheme.onSurfaceVariant),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Text('预计数量',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
)),
|
|
const Spacer(),
|
|
Text(
|
|
'$calculatedQuantity ${selectedCoin?.code ?? ''}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
|
child: Divider(
|
|
height: 1,
|
|
color: colorScheme.outlineVariant.withOpacity(0.08)),
|
|
),
|
|
// 可用余额
|
|
Row(
|
|
children: [
|
|
Icon(LucideIcons.wallet,
|
|
size: 14, color: colorScheme.onSurfaceVariant),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Text(isBuy ? '可用 USDT' : '可用 ${selectedCoin?.code ?? ""}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
)),
|
|
const Spacer(),
|
|
Text(
|
|
isBuy
|
|
? '$availableUsdt USDT'
|
|
: '$availableCoinQty ${selectedCoin?.code ?? ""}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 买入/卖出切换按钮 - 实心填充 + 图标
|
|
Widget _buildTypeButton({
|
|
required BuildContext context,
|
|
required String label,
|
|
required bool isActive,
|
|
required Color color,
|
|
required IconData icon,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeInOut,
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm + 4),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? color : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon,
|
|
size: 16,
|
|
color: isActive ? Colors.white : color.withOpacity(0.5)),
|
|
SizedBox(width: AppSpacing.xs),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isActive ? Colors.white : color.withOpacity(0.5),
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 15,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 百分比按钮 - 药丸样式
|
|
Widget _buildPctButton(
|
|
String label, double pct, ColorScheme colorScheme, Color actionColor) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => onFillPercent(pct),
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.sm - 2),
|
|
decoration: BoxDecoration(
|
|
color: actionColor.withOpacity(0.06),
|
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
|
border: Border.all(color: actionColor.withOpacity(0.12)),
|
|
),
|
|
child: Center(
|
|
child: Text(label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: actionColor.withOpacity(0.8),
|
|
)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 交易按钮 - 使用 NeonButton 风格
|
|
class _TradeButton extends StatelessWidget {
|
|
final bool isBuy;
|
|
final String? coinCode;
|
|
final bool enabled;
|
|
final bool isLoading;
|
|
final VoidCallback onPressed;
|
|
|
|
const _TradeButton({
|
|
required this.isBuy,
|
|
required this.coinCode,
|
|
required this.enabled,
|
|
required this.isLoading,
|
|
required this.onPressed,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final fillColor =
|
|
isBuy ? AppColorScheme.buyButtonFill : AppColorScheme.sellButtonFill;
|
|
|
|
return GestureDetector(
|
|
onTap: enabled ? onPressed : null,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: enabled ? fillColor : colorScheme.onSurface.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
|
),
|
|
child: Center(
|
|
child: isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
isBuy ? LucideIcons.trendingUp : LucideIcons.trendingDown,
|
|
size: 16,
|
|
color: enabled
|
|
? Colors.white
|
|
: colorScheme.onSurface.withOpacity(0.3),
|
|
),
|
|
SizedBox(width: AppSpacing.xs),
|
|
Text(
|
|
'${isBuy ? '买入' : '卖出'} ${coinCode ?? ""}',
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: enabled
|
|
? Colors.white
|
|
: colorScheme.onSurface.withOpacity(0.3),
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 金额输入框(含超额提示)
|
|
class _AmountInput extends StatefulWidget {
|
|
final TextEditingController amountController;
|
|
final String maxAmount;
|
|
final bool isBuy;
|
|
final Color actionColor;
|
|
final VoidCallback onChanged;
|
|
|
|
const _AmountInput({
|
|
required this.amountController,
|
|
required this.maxAmount,
|
|
required this.isBuy,
|
|
required this.actionColor,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
State<_AmountInput> createState() => _AmountInputState();
|
|
}
|
|
|
|
class _AmountInputState extends State<_AmountInput> {
|
|
bool _isExceeded = false;
|
|
|
|
void _checkLimit() {
|
|
final input = double.tryParse(widget.amountController.text) ?? 0;
|
|
final max = double.tryParse(widget.maxAmount) ?? 0;
|
|
final exceeded = widget.isBuy && input > max && max > 0 && input > 0;
|
|
if (exceeded != _isExceeded) {
|
|
setState(() => _isExceeded = exceeded);
|
|
}
|
|
widget.onChanged();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.amountController.addListener(_checkLimit);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.amountController.removeListener(_checkLimit);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final warningColor = AppColorScheme.warning;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
|
border: Border.all(
|
|
color: _isExceeded
|
|
? warningColor.withOpacity(0.5)
|
|
: widget.actionColor.withOpacity(0.15),
|
|
),
|
|
),
|
|
child: TextField(
|
|
controller: widget.amountController,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
onChanged: (_) => _checkLimit(),
|
|
style: GoogleFonts.spaceGrotesk(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: '0.00',
|
|
hintStyle: TextStyle(
|
|
color: colorScheme.outlineVariant.withOpacity(0.4)),
|
|
border: InputBorder.none,
|
|
contentPadding: EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.md,
|
|
),
|
|
suffixIcon: Padding(
|
|
padding: EdgeInsets.only(right: AppSpacing.sm),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm, vertical: AppSpacing.xs),
|
|
decoration: BoxDecoration(
|
|
color: widget.actionColor.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Text('USDT',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: widget.actionColor.withOpacity(0.7),
|
|
)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
suffixIconConstraints: const BoxConstraints(minWidth: 60),
|
|
),
|
|
),
|
|
),
|
|
if (_isExceeded)
|
|
Padding(
|
|
padding: EdgeInsets.only(top: AppSpacing.xs),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, size: 13, color: warningColor),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'超出可用USDT余额',
|
|
style: TextStyle(fontSize: 11, color: warningColor),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|