111
This commit is contained in:
129
flutter_monisuo/lib/ui/pages/mine/change_password_page.dart
Normal file
129
flutter_monisuo/lib/ui/pages/mine/change_password_page.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../components/material_input.dart';
|
||||
|
||||
class ChangePasswordPage extends StatefulWidget {
|
||||
const ChangePasswordPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChangePasswordPage> createState() => _ChangePasswordPageState();
|
||||
}
|
||||
|
||||
class _ChangePasswordPageState extends State<ChangePasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _oldPasswordController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldPasswordController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final dio = context.read<DioClient>();
|
||||
final response = await dio.post('/api/user/change-password', data: {
|
||||
'oldPassword': _oldPasswordController.text,
|
||||
'newPassword': _newPasswordController.text,
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('密碼修改成功')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(response.message ?? '修改失敗')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('網絡錯誤:$e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('修改密碼'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
MaterialInput(
|
||||
controller: _oldPasswordController,
|
||||
labelText: '當前密碼',
|
||||
obscureText: true,
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '請輸入當前密碼' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MaterialInput(
|
||||
controller: _newPasswordController,
|
||||
labelText: '新密碼',
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '請輸入新密碼';
|
||||
if (v.length < 6) return '密碼長度不能少於6位';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MaterialInput(
|
||||
controller: _confirmPasswordController,
|
||||
labelText: '確認新密碼',
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '請確認新密碼';
|
||||
if (v != _newPasswordController.text) return '兩次密碼不一致';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _handleSubmit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('確認修改'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
281
flutter_monisuo/lib/ui/pages/trade/trade_history_page.dart
Normal file
281
flutter_monisuo/lib/ui/pages/trade/trade_history_page.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
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 '../../../data/models/order_models.dart';
|
||||
import '../../../providers/asset_provider.dart';
|
||||
import '../../../data/services/trade_service.dart';
|
||||
|
||||
/// 交易历史页 — 当前委托 / 订单历史 / 历史成交
|
||||
class TradeHistoryPage extends StatefulWidget {
|
||||
final String? coinCode;
|
||||
|
||||
const TradeHistoryPage({super.key, this.coinCode});
|
||||
|
||||
@override
|
||||
State<TradeHistoryPage> createState() => _TradeHistoryPageState();
|
||||
}
|
||||
|
||||
class _TradeHistoryPageState extends State<TradeHistoryPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<OrderTrade> _pendingOrders = [];
|
||||
List<OrderTrade> _allOrders = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_loadOrders();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadOrders() async {
|
||||
if (_isLoading) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final response = await context.read<TradeService>().getOrders(
|
||||
coinCode: widget.coinCode,
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
);
|
||||
if (response.success && response.data != null) {
|
||||
final list = (response.data!['list'] as List? ?? [])
|
||||
.map((e) => OrderTrade.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
setState(() {
|
||||
_allOrders = list;
|
||||
_pendingOrders = list.where((o) => o.status == 0).toList();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('交易历史'),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
),
|
||||
body: Column(children: [
|
||||
// tab 文字切换
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Row(children: [
|
||||
_tabText('当前委托', 0),
|
||||
const SizedBox(width: 20),
|
||||
_tabText('订单历史', 1),
|
||||
const SizedBox(width: 20),
|
||||
_tabText('历史成交', 2),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 内容
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)))
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildPendingList(context),
|
||||
_buildAllOrdersList(context),
|
||||
_buildFilledList(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabText(String label, int index) {
|
||||
final selected = _tabController.index == index;
|
||||
return GestureDetector(
|
||||
onTap: () { _tabController.animateTo(index); setState(() {}); },
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected
|
||||
? context.colors.onSurface
|
||||
: context.colors.onSurfaceVariant,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 当前委托
|
||||
Widget _buildPendingList(BuildContext context) {
|
||||
if (_pendingOrders.isEmpty) {
|
||||
return _emptyView('暂无委托订单');
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadOrders,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.xs),
|
||||
itemCount: _pendingOrders.length,
|
||||
itemBuilder: (context, index) => _orderCard(_pendingOrders[index], showCancel: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 订单历史(全部)
|
||||
Widget _buildAllOrdersList(BuildContext context) {
|
||||
if (_allOrders.isEmpty) {
|
||||
return _emptyView('暂无订单记录');
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadOrders,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.xs),
|
||||
itemCount: _allOrders.length,
|
||||
itemBuilder: (context, index) => _orderCard(_allOrders[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 历史成交(已成交的)
|
||||
Widget _buildFilledList(BuildContext context) {
|
||||
final filled = _allOrders.where((o) => o.status == 1).toList();
|
||||
if (filled.isEmpty) {
|
||||
return _emptyView('暂无成交记录');
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadOrders,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.xs),
|
||||
itemCount: filled.length,
|
||||
itemBuilder: (context, index) => _orderCard(filled[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyView(String msg) {
|
||||
return Center(child: Text(msg,
|
||||
style: AppTextStyles.bodySmall(context)
|
||||
.copyWith(color: context.colors.onSurfaceVariant)));
|
||||
}
|
||||
|
||||
Widget _orderCard(OrderTrade order, {bool showCancel = false}) {
|
||||
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);
|
||||
final statusColor = order.status == 0
|
||||
? context.appColors.up
|
||||
: order.status == 1
|
||||
? context.colors.onSurfaceVariant
|
||||
: context.colors.error;
|
||||
|
||||
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)),
|
||||
if (showCancel && order.isPending) ...[
|
||||
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)),
|
||||
),
|
||||
),
|
||||
] else if (!showCancel || !order.isPending) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(order.statusText,
|
||||
style: AppTextStyles.bodySmall(context).copyWith(
|
||||
color: statusColor, fontWeight: FontWeight.w500, fontSize: 12)),
|
||||
],
|
||||
]),
|
||||
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')}';
|
||||
}
|
||||
|
||||
Future<void> _cancelOrder(String orderNo) async {
|
||||
try {
|
||||
final response = await context.read<TradeService>().cancelOrder(orderNo);
|
||||
if (response.success) {
|
||||
_loadOrders();
|
||||
context.read<AssetProvider>().refreshAll(force: true);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user