111
This commit is contained in:
181
flutter_monisuo/lib/ui/pages/trade/components/order_book.dart
Normal file
181
flutter_monisuo/lib/ui/pages/trade/components/order_book.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../../data/models/order_book.dart';
|
||||
|
||||
/// 订单簿组件
|
||||
///
|
||||
/// 显示3档卖单(红) + 价差 + 3档买单(绿),带深度条。
|
||||
class OrderBook extends StatelessWidget {
|
||||
final OrderBookDepth depth;
|
||||
|
||||
const OrderBook({super.key, required this.depth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asks = depth.asks.length > 3 ? depth.asks.sublist(0, 3) : depth.asks;
|
||||
final bids = depth.bids.length > 3 ? depth.bids.sublist(0, 3) : depth.bids;
|
||||
|
||||
// 计算最大数量(用于深度条百分比)
|
||||
double maxQty = 0;
|
||||
for (var a in asks) {
|
||||
if (a.quantity > maxQty) maxQty = a.quantity;
|
||||
}
|
||||
for (var b in bids) {
|
||||
if (b.quantity > maxQty) maxQty = b.quantity;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm + AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: context.appColors.ghostBorder),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 表头
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
// 卖单(从低到高,最接近当前价在底部)
|
||||
...asks.reversed.map((a) => _OrderBookRow(
|
||||
level: a,
|
||||
isAsk: true,
|
||||
maxQty: maxQty,
|
||||
)),
|
||||
// 价差指示器
|
||||
_buildSpreadIndicator(context),
|
||||
// 买单(从高到低,最接近当前价在顶部)
|
||||
...bids.map((b) => _OrderBookRow(
|
||||
level: b,
|
||||
isAsk: false,
|
||||
maxQty: maxQty,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('价格',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text('数量',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpreadIndicator(BuildContext context) {
|
||||
final spreadStr = depth.spread >= 1
|
||||
? depth.spread.toStringAsFixed(2)
|
||||
: depth.spread.toStringAsFixed(4);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.appColors.ghostBorder, width: 0.5),
|
||||
bottom: BorderSide(
|
||||
color: context.appColors.ghostBorder, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('价差 ',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.appColors.onSurfaceMuted,
|
||||
)),
|
||||
Text('$spreadStr (${depth.spreadPercent.toStringAsFixed(2)}%)',
|
||||
style: AppTextStyles.numberSmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrderBookRow extends StatelessWidget {
|
||||
final OrderBookLevel level;
|
||||
final bool isAsk;
|
||||
final double maxQty;
|
||||
|
||||
const _OrderBookRow({
|
||||
required this.level,
|
||||
required this.isAsk,
|
||||
required this.maxQty,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color =
|
||||
isAsk ? context.appColors.down : context.appColors.up;
|
||||
final bgColor =
|
||||
isAsk ? context.appColors.downBackground : context.appColors.upBackground;
|
||||
final percent = maxQty > 0 ? (level.quantity / maxQty).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 深度条背景
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: percent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 内容
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
level.formattedPrice,
|
||||
style: AppTextStyles.numberSmall(context).copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
level.formattedQuantity,
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
|
||||
/// 单条价格跳动记录
|
||||
class PriceTick {
|
||||
final double price;
|
||||
final DateTime timestamp;
|
||||
final bool isUp;
|
||||
|
||||
const PriceTick({
|
||||
required this.price,
|
||||
required this.timestamp,
|
||||
required this.isUp,
|
||||
});
|
||||
|
||||
String get formattedPrice {
|
||||
if (price >= 1000) return price.toStringAsFixed(2);
|
||||
if (price >= 1) return price.toStringAsFixed(4);
|
||||
return price.toStringAsFixed(6);
|
||||
}
|
||||
}
|
||||
|
||||
/// 价格跳动流 Widget
|
||||
///
|
||||
/// 显示模拟实时价格跳动,涨绿跌红,最新一条有背景高亮。
|
||||
class PriceTickStream extends StatelessWidget {
|
||||
final List<PriceTick> ticks;
|
||||
|
||||
const PriceTickStream({super.key, required this.ticks});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayTicks = ticks.length > 10
|
||||
? ticks.sublist(ticks.length - 10)
|
||||
: ticks;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm + AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: context.appColors.ghostBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'实时报价',
|
||||
style: AppTextStyles.labelMedium(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'模拟数据',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.appColors.onSurfaceMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: displayTicks.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'选择币种后开始报价',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.appColors.onSurfaceMuted,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
itemCount: displayTicks.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final tick = displayTicks[displayTicks.length - 1 - index];
|
||||
final isLatest = index == 0;
|
||||
return _TickItem(tick: tick, isLatest: isLatest);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TickItem extends StatelessWidget {
|
||||
final PriceTick tick;
|
||||
final bool isLatest;
|
||||
|
||||
const _TickItem({required this.tick, required this.isLatest});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = tick.isUp ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isLatest
|
||||
? (tick.isUp
|
||||
? context.appColors.upBackground
|
||||
: context.appColors.downBackground)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
tick.formattedPrice,
|
||||
style: AppTextStyles.numberSmall(context).copyWith(
|
||||
color: color,
|
||||
fontWeight: isLatest ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../../data/models/coin.dart';
|
||||
import '../../../../data/models/order_book.dart';
|
||||
|
||||
/// 交易面板 — 左侧交易表单 + 右侧订单簿
|
||||
///
|
||||
/// 布局:左50%表单(买入/卖出tab + 市价/限价下拉) | 右50%订单簿(5档买卖盘)
|
||||
class SplitTradeForm extends StatefulWidget {
|
||||
final Coin? coin;
|
||||
final OrderBookDepth depth;
|
||||
final double realtimePrice;
|
||||
final TextEditingController buyPriceController;
|
||||
final TextEditingController buyQuantityController;
|
||||
final TextEditingController sellPriceController;
|
||||
final TextEditingController sellQuantityController;
|
||||
final String availableUsdt;
|
||||
final String availableCoinQty;
|
||||
final VoidCallback onBuyQuantityChanged;
|
||||
final VoidCallback onSellQuantityChanged;
|
||||
final ValueChanged<double> onBuyFillPercent;
|
||||
final ValueChanged<double> onSellFillPercent;
|
||||
final VoidCallback? onBuySubmit;
|
||||
final VoidCallback? onSellSubmit;
|
||||
final int orderType;
|
||||
final ValueChanged<int> onOrderTypeChanged;
|
||||
|
||||
const SplitTradeForm({
|
||||
super.key,
|
||||
required this.coin,
|
||||
required this.depth,
|
||||
required this.realtimePrice,
|
||||
required this.buyPriceController,
|
||||
required this.buyQuantityController,
|
||||
required this.sellPriceController,
|
||||
required this.sellQuantityController,
|
||||
required this.availableUsdt,
|
||||
required this.availableCoinQty,
|
||||
required this.onBuyQuantityChanged,
|
||||
required this.onSellQuantityChanged,
|
||||
required this.onBuyFillPercent,
|
||||
required this.onSellFillPercent,
|
||||
this.onBuySubmit,
|
||||
this.onSellSubmit,
|
||||
this.orderType = 1,
|
||||
required this.onOrderTypeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SplitTradeForm> createState() => _SplitTradeFormState();
|
||||
}
|
||||
|
||||
class _SplitTradeFormState extends State<SplitTradeForm>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isBuy => _tabController.index == 0;
|
||||
bool get _isLimitOrder => widget.orderType == 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accentColor =
|
||||
_isBuy ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.surfaceCard,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: context.appColors.ghostBorder),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 6, child: _buildFormSide(context, accentColor)),
|
||||
Container(
|
||||
width: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
color: context.appColors.ghostBorder,
|
||||
),
|
||||
Expanded(flex: 4, child: _buildOrderBookSide(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormSide(BuildContext context, Color accentColor) {
|
||||
final priceController =
|
||||
_isBuy ? widget.buyPriceController : widget.sellPriceController;
|
||||
final quantityController =
|
||||
_isBuy ? widget.buyQuantityController : widget.sellQuantityController;
|
||||
final available =
|
||||
_isBuy ? widget.availableUsdt : widget.availableCoinQty;
|
||||
final onQuantityChanged =
|
||||
_isBuy ? widget.onBuyQuantityChanged : widget.onSellQuantityChanged;
|
||||
final onFillPercent =
|
||||
_isBuy ? widget.onBuyFillPercent : widget.onSellFillPercent;
|
||||
final onSubmit = _isBuy ? widget.onBuySubmit : widget.onSellSubmit;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 买入/卖出 Tab
|
||||
Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: const EdgeInsets.all(3),
|
||||
indicator: BoxDecoration(
|
||||
color: accentColor,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: context.colors.onSurfaceVariant,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13),
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: const [Tab(text: '买入'), Tab(text: '卖出')],
|
||||
onTap: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// 价格行:左侧标签 + 右侧市价/限价切换
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_isLimitOrder ? '委托价格' : '市价',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_orderTypeLink(context, '市价', 1, accentColor),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text('|', style: TextStyle(fontSize: 10, color: context.colors.onSurfaceVariant.withValues(alpha: 0.4))),
|
||||
),
|
||||
_orderTypeLink(context, '限价', 2, accentColor),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 价格输入框或市价提示
|
||||
if (_isLimitOrder) ...[
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: TextField(
|
||||
controller: priceController,
|
||||
enabled: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
filled: true,
|
||||
fillColor: context.colors.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixText: 'USDT',
|
||||
suffixStyle: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
] else ...[
|
||||
Container(
|
||||
height: 36,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'以当前最优价格成交',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
|
||||
// 数量
|
||||
Text('数量', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant)),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
enabled: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
filled: true,
|
||||
fillColor: context.colors.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixText: widget.coin?.code ?? '',
|
||||
suffixStyle: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// 金额
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('金额', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant)),
|
||||
Builder(builder: (context) {
|
||||
final price = double.tryParse(priceController.text) ?? 0;
|
||||
final qty = double.tryParse(quantityController.text) ?? 0;
|
||||
final amount = price * qty;
|
||||
return Text(
|
||||
amount > 0 ? '${amount.toStringAsFixed(2)} USDT' : '0.00 USDT',
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// 可用余额
|
||||
Text(
|
||||
'可用: $available ${_isBuy ? 'USDT' : (widget.coin?.code ?? '')}',
|
||||
style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 快捷百分比按钮
|
||||
Row(
|
||||
children: [
|
||||
_pctBtn(context, '25%', 0.25, onFillPercent),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '50%', 0.50, onFillPercent),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '75%', 0.75, onFillPercent),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '100%', 1.0, onFillPercent),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 提交按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${_isBuy ? '买入' : '卖出'} ${widget.coin?.code ?? ''}',
|
||||
style: AppTextStyles.labelLarge(context)
|
||||
.copyWith(color: Colors.white, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pctBtn(BuildContext context, String label, double pct, ValueChanged<double> onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(pct),
|
||||
child: Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(label,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _orderTypeLink(BuildContext context, String label, int type, Color accentColor) {
|
||||
final selected = widget.orderType == type;
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOrderTypeChanged(type),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: selected ? accentColor : context.colors.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 右侧订单簿
|
||||
// ==========================================
|
||||
|
||||
Widget _buildOrderBookSide(BuildContext context) {
|
||||
final asks = widget.depth.asks.length > 5
|
||||
? widget.depth.asks.sublist(0, 5)
|
||||
: widget.depth.asks;
|
||||
final bids = widget.depth.bids.length > 5
|
||||
? widget.depth.bids.sublist(0, 5)
|
||||
: widget.depth.bids;
|
||||
final coinCode = widget.coin?.code ?? '';
|
||||
|
||||
double maxQty = 0;
|
||||
for (var a in asks) { if (a.quantity > maxQty) maxQty = a.quantity; }
|
||||
for (var b in bids) { if (b.quantity > maxQty) maxQty = b.quantity; }
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: Row(children: [
|
||||
Expanded(child: Text('委托价格', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant, fontWeight: FontWeight.w600))),
|
||||
Expanded(child: Text('数量', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.right)),
|
||||
]),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: Row(children: [
|
||||
Expanded(child: Text('(USDT)', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.appColors.onSurfaceMuted, fontSize: 10))),
|
||||
Expanded(child: Text('($coinCode)', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.appColors.onSurfaceMuted, fontSize: 10),
|
||||
textAlign: TextAlign.right)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
...asks.reversed.map((a) => _OrderBookRow(level: a, isAsk: true, maxQty: maxQty)),
|
||||
_buildSpreadIndicator(context),
|
||||
...bids.map((b) => _OrderBookRow(level: b, isAsk: false, maxQty: maxQty)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpreadIndicator(BuildContext context) {
|
||||
final spreadStr = widget.depth.spread >= 1
|
||||
? widget.depth.spread.toStringAsFixed(2)
|
||||
: widget.depth.spread.toStringAsFixed(4);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: context.appColors.ghostBorder, width: 0.5),
|
||||
bottom: BorderSide(color: context.appColors.ghostBorder, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('价差 ', style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.appColors.onSurfaceMuted)),
|
||||
Text('$spreadStr (${widget.depth.spreadPercent.toStringAsFixed(2)}%)',
|
||||
style: AppTextStyles.numberSmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrderBookRow extends StatelessWidget {
|
||||
final OrderBookLevel level;
|
||||
final bool isAsk;
|
||||
final double maxQty;
|
||||
|
||||
const _OrderBookRow({required this.level, required this.isAsk, required this.maxQty});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = isAsk ? context.appColors.down : context.appColors.up;
|
||||
final bgColor = isAsk ? context.appColors.downBackground : context.appColors.upBackground;
|
||||
final percent = maxQty > 0 ? (level.quantity / maxQty).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
return Stack(children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: percent,
|
||||
child: Container(decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(2))),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(level.formattedPrice,
|
||||
style: AppTextStyles.numberSmall(context)
|
||||
.copyWith(color: color, fontWeight: FontWeight.w500))),
|
||||
Expanded(child: Text(level.formattedQuantity,
|
||||
style: AppTextStyles.numberSmall(context), textAlign: TextAlign.right)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../../data/models/coin.dart';
|
||||
|
||||
/// 单侧交易表单面板(买入或卖出)
|
||||
class TradeFormPanel extends StatelessWidget {
|
||||
final bool isBuy;
|
||||
final Coin? coin;
|
||||
final TextEditingController priceController;
|
||||
final TextEditingController quantityController;
|
||||
final String availableBalance;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final ValueChanged<double> onFillPercent;
|
||||
final VoidCallback? onSubmit;
|
||||
|
||||
const TradeFormPanel({
|
||||
super.key,
|
||||
required this.isBuy,
|
||||
required this.coin,
|
||||
required this.priceController,
|
||||
required this.quantityController,
|
||||
required this.availableBalance,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFillPercent,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accentColor = isBuy ? context.appColors.up : context.appColors.down;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
isBuy ? '买入' : '卖出',
|
||||
style: AppTextStyles.headlineMedium(context).copyWith(
|
||||
color: accentColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 价格输入
|
||||
_buildInputField(
|
||||
context: context,
|
||||
label: '价格',
|
||||
controller: priceController,
|
||||
suffix: 'USDT',
|
||||
enabled: false,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 数量输入
|
||||
_buildInputField(
|
||||
context: context,
|
||||
label: '数量',
|
||||
controller: quantityController,
|
||||
suffix: coin?.code ?? '',
|
||||
enabled: true,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// 金额显示
|
||||
_buildAmountDisplay(context),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
// 可用余额
|
||||
Text(
|
||||
'可用: $availableBalance ${isBuy ? 'USDT' : (coin?.code ?? '')}',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 快捷百分比按钮
|
||||
Row(
|
||||
children: [
|
||||
_pctBtn(context, '25%', 0.25),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '50%', 0.50),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '75%', 0.75),
|
||||
const SizedBox(width: 4),
|
||||
_pctBtn(context, '100%', 1.0),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// 提交按钮
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${isBuy ? '买入' : '卖出'} ${coin?.code ?? ''}',
|
||||
style: AppTextStyles.labelLarge(context).copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputField({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String suffix,
|
||||
required bool enabled,
|
||||
ValueChanged<String>? onChanged,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: enabled ? (v) => onChanged?.call(v) : null,
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
filled: true,
|
||||
fillColor: context.colors.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixText: suffix,
|
||||
suffixStyle: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountDisplay(BuildContext context) {
|
||||
final price = double.tryParse(priceController.text) ?? 0;
|
||||
final qty = double.tryParse(quantityController.text) ?? 0;
|
||||
final amount = price * qty;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('金额', style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
Text(
|
||||
amount > 0 ? '${amount.toStringAsFixed(2)} USDT' : '0.00 USDT',
|
||||
style: AppTextStyles.numberSmall(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pctBtn(BuildContext context, String label, double pct) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onFillPercent(pct),
|
||||
child: Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/theme/app_theme_extension.dart';
|
||||
import '../../../../data/models/coin.dart';
|
||||
import '../../../components/coin_icon.dart';
|
||||
import 'coin_avatar.dart';
|
||||
|
||||
/// 紧凑型币对选择头部栏
|
||||
///
|
||||
/// 参考币安顶部交易对栏,显示当前币对 + 24h涨跌幅,点击弹出底部选择器。
|
||||
class TradeHeaderBar extends StatelessWidget {
|
||||
final Coin? selectedCoin;
|
||||
final List<Coin> coins;
|
||||
final ValueChanged<Coin> onCoinSelected;
|
||||
|
||||
const TradeHeaderBar({
|
||||
super.key,
|
||||
required this.selectedCoin,
|
||||
required this.coins,
|
||||
required this.onCoinSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showCoinPicker(context),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.appColors.ghostBorder,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (selectedCoin != null)
|
||||
CoinIcon(symbol: selectedCoin!.code, size: 28)
|
||||
else
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
selectedCoin != null
|
||||
? '${selectedCoin!.code}/USDT'
|
||||
: '选择币种',
|
||||
style: AppTextStyles.headlineLarge(context),
|
||||
),
|
||||
if (selectedCoin != null)
|
||||
Text(
|
||||
selectedCoin!.name,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCoinPicker(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => Container(
|
||||
height: MediaQuery.of(ctx).size.height * 0.65,
|
||||
decoration: BoxDecoration(
|
||||
color: ctx.isDark
|
||||
? ctx.colors.surface
|
||||
: ctx.colors.surfaceContainerLowest,
|
||||
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: ctx.colors.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('选择币种',
|
||||
style: AppTextStyles.headlineLarge(context)),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(ctx).pop(),
|
||||
child: Icon(LucideIcons.x,
|
||||
color: ctx.colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: ctx.colors.outlineVariant.withValues(alpha: 0.2)),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const 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 isSelected = selectedCoin?.code == coin.code;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onCoinSelected(coin);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg, vertical: AppSpacing.md),
|
||||
color: isSelected
|
||||
? context.colors.primary.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
CoinAvatar(icon: coin.displayIcon),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(coin.code,
|
||||
style: AppTextStyles.headlineLarge(context)),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text('/USDT',
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
const Spacer(),
|
||||
Text('\$${coin.formattedPrice}',
|
||||
style: AppTextStyles.numberMedium(context)),
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Icon(LucideIcons.check,
|
||||
size: 16, color: context.colors.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(coin.name,
|
||||
style: AppTextStyles.bodyMedium(context).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user