This commit is contained in:
sion
2026-04-21 08:12:17 +08:00
parent 5264043c21
commit 685202dd55
247 changed files with 18661 additions and 0 deletions

View 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('確認修改'),
),
],
),
),
),
);
}
}

View 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,
),
),
],
),
),
],
);
}
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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)),
]),
),
]);
}
}

View File

@@ -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,
),
),
),
),
),
);
}
}

View File

@@ -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,
)),
],
),
),
],
),
),
);
}
}

View 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 (_) {}
}
}