rwadurian/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart

970 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/format_utils.dart';
import '../../../data/models/trade_order_model.dart';
import '../../../domain/entities/price_info.dart';
import '../../../domain/entities/market_overview.dart';
import '../../../domain/entities/trade_order.dart';
import '../../providers/user_providers.dart';
import '../../providers/trading_providers.dart';
import '../../widgets/shimmer_loading.dart';
class TradingPage extends ConsumerStatefulWidget {
const TradingPage({super.key});
@override
ConsumerState<TradingPage> createState() => _TradingPageState();
}
class _TradingPageState extends ConsumerState<TradingPage> {
// 设计色彩
static const Color _orange = Color(0xFFFF6B00);
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _grayText = Color(0xFF6B7280);
static const Color _darkText = Color(0xFF1F2937);
static const Color _bgGray = Color(0xFFF3F4F6);
static const Color _lightGray = Color(0xFFF9FAFB);
static const Color _borderGray = Color(0xFFE5E7EB);
// 状态
int _selectedTab = 1; // 0: 买入, 1: 卖出
int _selectedTimeRange = 1; // 时间周期选择
final _quantityController = TextEditingController();
final _priceController = TextEditingController();
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', ''];
@override
void dispose() {
_quantityController.dispose();
_priceController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final priceAsync = ref.watch(currentPriceProvider);
final marketAsync = ref.watch(marketOverviewProvider);
final ordersAsync = ref.watch(ordersProvider);
final user = ref.watch(userNotifierProvider);
final accountSequence = user.accountSequence ?? '';
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
bottom: false,
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
ref.invalidate(ordersProvider);
},
child: Column(
children: [
_buildAppBar(),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildPriceCard(priceAsync),
_buildChartSection(priceAsync),
_buildMarketDataCard(marketAsync),
_buildTradingPanel(priceAsync),
_buildMyOrdersCard(ordersAsync),
const SizedBox(height: 100),
],
),
),
),
],
),
),
),
);
}
Widget _buildAppBar() {
return Container(
color: _bgGray.withValues(alpha: 0.9),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(9999),
),
child: const Text(
'积分股交易',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: [
const Center(
child: Icon(Icons.notifications_outlined, color: _grayText),
),
Positioned(
right: 10,
top: 10,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
),
),
],
),
);
}
Widget _buildPriceCard(AsyncValue<PriceInfo?> priceAsync) {
final isLoading = priceAsync.isLoading;
final priceInfo = priceAsync.valueOrNull;
final hasError = priceAsync.hasError;
if (hasError && priceInfo == null) {
return _buildErrorCard('价格加载失败');
}
final price = priceInfo?.price ?? '0';
final greenPoints = priceInfo?.greenPoints ?? '0';
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'当前积分股价格',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _grayText,
),
),
DataText(
data: priceInfo != null ? '= ${formatCompact(greenPoints)} 绿积分' : null,
isLoading: isLoading,
placeholder: '= -- 绿积分',
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AmountText(
amount: priceInfo != null ? formatPrice(price) : null,
isLoading: isLoading,
prefix: '\u00A5 ',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: _orange,
letterSpacing: -0.75,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.trending_up, size: 16, color: _green),
DataText(
data: isLoading ? null : '+0.00%',
isLoading: isLoading,
placeholder: '+--.--%',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _green,
),
),
],
),
),
],
),
],
),
);
}
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync) {
final priceInfo = priceAsync.valueOrNull;
final currentPrice = priceInfo?.price ?? '0.000000';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Container(
height: 200,
decoration: BoxDecoration(
color: _lightGray,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
CustomPaint(
size: const Size(double.infinity, 200),
painter: _CandlestickPainter(),
),
Positioned(
right: 0,
top: 60,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Text(
formatPrice(currentPrice),
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
],
),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_timeRanges.length, (index) {
final isSelected = _selectedTimeRange == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => setState(() => _selectedTimeRange = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? _orange : Colors.white,
borderRadius: BorderRadius.circular(9999),
border: isSelected ? null : Border.all(color: _borderGray),
),
child: Text(
_timeRanges[index],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : _grayText,
),
),
),
),
);
}),
),
),
],
),
);
}
Widget _buildMarketDataCard(AsyncValue<MarketOverview?> marketAsync) {
final isLoading = marketAsync.isLoading;
final market = marketAsync.valueOrNull;
final hasError = marketAsync.hasError;
if (hasError && market == null) {
return _buildErrorCard('市场数据加载失败');
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'市场数据',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
_buildMarketDataItem(
'总积分股',
market != null ? formatCompact(market.totalShares) : null,
_orange,
isLoading,
),
Container(width: 1, height: 24, color: _bgGray),
const SizedBox(width: 16),
_buildMarketDataItem(
'流通池',
market != null ? formatCompact(market.circulationPool) : null,
_orange,
isLoading,
),
],
),
const SizedBox(height: 24),
Container(height: 1, color: _bgGray),
const SizedBox(height: 24),
Row(
children: [
_buildMarketDataItem(
'绿积分池',
market != null ? formatCompact(market.greenPoints) : null,
_orange,
isLoading,
),
Container(width: 1, height: 24, color: _bgGray),
const SizedBox(width: 16),
_buildMarketDataItem(
'黑洞销毁量',
market != null ? formatCompact(market.blackHoleAmount) : null,
_red,
isLoading,
),
],
),
],
),
);
}
Widget _buildMarketDataItem(String label, String? value, Color valueColor, bool isLoading) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(height: 4),
DataText(
data: value,
isLoading: isLoading,
placeholder: '--,---,---',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: valueColor,
),
),
],
),
);
}
Widget _buildTradingPanel(AsyncValue<PriceInfo?> priceAsync) {
final priceInfo = priceAsync.valueOrNull;
final currentPrice = priceInfo?.price ?? '0';
// 设置默认价格
if (_priceController.text.isEmpty && priceInfo != null) {
_priceController.text = currentPrice;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Container(
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: _bgGray)),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedTab = 0),
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: _selectedTab == 0 ? _orange : Colors.transparent,
width: 2,
),
),
),
child: Text(
'买入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _selectedTab == 0 ? _orange : _grayText,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedTab = 1),
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: _selectedTab == 1 ? _orange : Colors.transparent,
width: 2,
),
),
),
child: Text(
'卖出',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _selectedTab == 1 ? _orange : _grayText,
),
),
),
),
),
],
),
),
const SizedBox(height: 24),
// 价格输入
_buildInputField('价格', _priceController, '请输入价格', '绿积分'),
const SizedBox(height: 16),
// 数量输入
_buildInputField('数量', _quantityController, '请输入数量', '积分股'),
const SizedBox(height: 16),
// 预计获得/支出
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedTab == 0 ? '预计支出' : '预计获得',
style: const TextStyle(fontSize: 12, color: _grayText),
),
Text(
_calculateEstimate(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _orange,
),
),
],
),
),
const SizedBox(height: 16),
// 销毁说明 (卖出时显示)
if (_selectedTab == 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text(
'销毁比例',
style: TextStyle(fontSize: 12, color: _grayText),
),
Text(
'10% 进入黑洞',
style: TextStyle(
fontSize: 12,
color: _red,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 24),
// 提交按钮
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _handleTrade,
style: ElevatedButton.styleFrom(
backgroundColor: _orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
_selectedTab == 0 ? '买入积分股' : '卖出积分股',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
);
}
Widget _buildInputField(
String label,
TextEditingController controller,
String hint,
String suffix,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _grayText,
),
),
const SizedBox(height: 8),
Container(
height: 44,
decoration: BoxDecoration(
color: _bgGray,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
fontSize: 14,
color: Color(0xFF9CA3AF),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
Text(suffix, style: const TextStyle(fontSize: 12, color: _grayText)),
const SizedBox(width: 12),
],
),
),
],
);
}
String _calculateEstimate() {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
final total = price * quantity;
if (total == 0) {
return '0.00 绿积分';
}
if (_selectedTab == 1) {
// 卖出时扣除10%销毁
final afterBurn = total * 0.9;
return '${formatAmount(afterBurn.toString())} 绿积分';
}
return '${formatAmount(total.toString())} 绿积分';
}
Widget _buildMyOrdersCard(AsyncValue<OrdersPageModel?> ordersAsync) {
final isLoading = ordersAsync.isLoading;
final ordersPage = ordersAsync.valueOrNull;
final orders = ordersPage?.data ?? [];
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'我的挂单',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
GestureDetector(
onTap: () {
// TODO: 查看全部挂单
},
child: const Text(
'全部 >',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _orange,
),
),
),
],
),
const SizedBox(height: 16),
if (isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(color: _orange),
),
)
else if (orders.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text(
'暂无挂单',
style: TextStyle(fontSize: 14, color: _grayText),
),
)
else
Column(
children: orders.take(3).map((order) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildOrderItemFromEntity(order),
)).toList(),
),
],
),
);
}
Widget _buildOrderItemFromEntity(TradeOrder order) {
final isSell = order.isSell;
final dateFormat = DateFormat('MM/dd HH:mm');
final formattedDate = dateFormat.format(order.createdAt);
String statusText;
switch (order.status) {
case OrderStatus.pending:
statusText = '待成交';
break;
case OrderStatus.partial:
statusText = '部分成交';
break;
case OrderStatus.filled:
statusText = '已成交';
break;
case OrderStatus.cancelled:
statusText = '已取消';
break;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _lightGray,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _bgGray),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isSell ? _red : _green).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isSell ? '卖出' : '买入',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSell ? _red : _green,
),
),
),
const SizedBox(width: 8),
Text(
formatPrice(order.price),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
const SizedBox(height: 4),
Text(
formattedDate,
style: const TextStyle(fontSize: 12, color: _grayText),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${formatCompact(order.quantity)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _darkText,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9999),
),
child: Text(
statusText,
style: const TextStyle(fontSize: 12, color: _orange),
),
),
],
),
],
),
);
}
Widget _buildErrorCard(String message) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Column(
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 8),
Text(message),
],
),
),
);
}
void _handleTrade() async {
if (_priceController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入价格')),
);
return;
}
if (_quantityController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入数量')),
);
return;
}
final isBuy = _selectedTab == 0;
bool success;
if (isBuy) {
success = await ref
.read(tradingNotifierProvider.notifier)
.buyShares(_priceController.text, _quantityController.text);
} else {
success = await ref
.read(tradingNotifierProvider.notifier)
.sellShares(_priceController.text, _quantityController.text);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? (isBuy ? '买入订单已提交' : '卖出订单已提交')
: (isBuy ? '买入失败' : '卖出失败')),
backgroundColor: success ? _green : AppColors.error,
),
);
if (success) {
_quantityController.clear();
// 交易成功后刷新订单列表
ref.invalidate(ordersProvider);
ref.invalidate(currentPriceProvider);
ref.invalidate(marketOverviewProvider);
}
}
}
}
// K线图绘制器简化版本显示模拟数据
class _CandlestickPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final greenPaint = Paint()..color = const Color(0xFF10B981);
final redPaint = Paint()..color = const Color(0xFFEF4444);
final dashPaint = Paint()
..color = const Color(0xFFFF6B00)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// 模拟K线数据
final candleData = [
{'open': 0.6, 'close': 0.5, 'high': 0.7, 'low': 0.45},
{'open': 0.5, 'close': 0.55, 'high': 0.6, 'low': 0.48},
{'open': 0.55, 'close': 0.52, 'high': 0.58, 'low': 0.5},
{'open': 0.52, 'close': 0.6, 'high': 0.65, 'low': 0.5},
{'open': 0.6, 'close': 0.58, 'high': 0.65, 'low': 0.55},
{'open': 0.58, 'close': 0.62, 'high': 0.68, 'low': 0.55},
{'open': 0.62, 'close': 0.55, 'high': 0.65, 'low': 0.52},
{'open': 0.55, 'close': 0.58, 'high': 0.62, 'low': 0.52},
{'open': 0.58, 'close': 0.52, 'high': 0.6, 'low': 0.5},
{'open': 0.52, 'close': 0.65, 'high': 0.7, 'low': 0.5},
{'open': 0.65, 'close': 0.7, 'high': 0.75, 'low': 0.62},
{'open': 0.7, 'close': 0.75, 'high': 0.8, 'low': 0.68},
];
final candleWidth = (size.width - 40) / candleData.length;
const padding = 20.0;
for (int i = 0; i < candleData.length; i++) {
final data = candleData[i];
final open = data['open']!;
final close = data['close']!;
final high = data['high']!;
final low = data['low']!;
final isGreen = close >= open;
final paint = isGreen ? greenPaint : redPaint;
final x = padding + i * candleWidth + candleWidth / 2;
final yOpen = size.height - (open * size.height * 0.8 + size.height * 0.1);
final yClose = size.height - (close * size.height * 0.8 + size.height * 0.1);
final yHigh = size.height - (high * size.height * 0.8 + size.height * 0.1);
final yLow = size.height - (low * size.height * 0.8 + size.height * 0.1);
canvas.drawLine(
Offset(x, yHigh),
Offset(x, yLow),
paint..strokeWidth = 1,
);
final bodyTop = isGreen ? yClose : yOpen;
final bodyBottom = isGreen ? yOpen : yClose;
canvas.drawRect(
Rect.fromLTRB(x - candleWidth * 0.3, bodyTop, x + candleWidth * 0.3, bodyBottom),
paint..style = PaintingStyle.fill,
);
}
final dashY = size.height * 0.35;
const dashWidth = 5.0;
const dashSpace = 3.0;
double startX = 0;
while (startX < size.width - 60) {
canvas.drawLine(
Offset(startX, dashY),
Offset(startX + dashWidth, dashY),
dashPaint,
);
startX += dashWidth + dashSpace;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}