import 'dart:math' as math; import 'dart:ui' as ui; 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 '../../../domain/entities/kline.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 createState() => _TradingPageState(); } class _TradingPageState extends ConsumerState { // 设计色彩 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 _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: 24), ], ), ), ), ], ), ), ), ); } 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 priceAsync) { final isLoading = priceAsync.isLoading; final priceInfo = priceAsync.valueOrNull; final hasError = priceAsync.hasError; if (hasError && priceInfo == null) { return _buildErrorCard('价格加载失败'); } final price = priceInfo?.price ?? '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: [ const Text( '当前积分股价格', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, 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 priceAsync) { final priceInfo = priceAsync.valueOrNull; final currentPrice = priceInfo?.price ?? '0.000000'; final klinesAsync = ref.watch(klinesProvider); final klines = klinesAsync.valueOrNull ?? []; 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: klinesAsync.isLoading ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) : Stack( children: [ CustomPaint( size: const Size(double.infinity, 200), painter: _CandlestickPainter(klines: klines), ), if (klines.isNotEmpty) 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); // 更新选中的周期,触发K线数据刷新 ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[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 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 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 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线图绘制器(Y轴自适应,显示真实数据) class _CandlestickPainter extends CustomPainter { final List klines; _CandlestickPainter({required this.klines}); @override void paint(Canvas canvas, Size size) { final greenPaint = Paint()..color = const Color(0xFF10B981); final redPaint = Paint()..color = const Color(0xFFEF4444); final gridPaint = Paint() ..color = const Color(0xFFE5E7EB) ..strokeWidth = 0.5; final textPaint = TextPainter(textDirection: ui.TextDirection.ltr); // 如果没有数据,显示提示 if (klines.isEmpty) { textPaint.text = const TextSpan( text: '暂无K线数据', style: TextStyle(color: Color(0xFF6B7280), fontSize: 14), ); textPaint.layout(); textPaint.paint( canvas, Offset((size.width - textPaint.width) / 2, (size.height - textPaint.height) / 2), ); return; } // 计算Y轴范围(自适应) double minPrice = double.infinity; double maxPrice = double.negativeInfinity; for (final kline in klines) { final low = double.tryParse(kline.low) ?? 0; final high = double.tryParse(kline.high) ?? 0; if (low < minPrice) minPrice = low; if (high > maxPrice) maxPrice = high; } // 添加一点余量,使K线不贴边 final priceRange = maxPrice - minPrice; final padding = priceRange * 0.1; // 上下各留10%空间 minPrice -= padding; maxPrice += padding; final adjustedRange = maxPrice - minPrice; // 绘图区域 const leftPadding = 10.0; const rightPadding = 50.0; // 右侧留出价格标签空间 const topPadding = 10.0; const bottomPadding = 10.0; final chartWidth = size.width - leftPadding - rightPadding; final chartHeight = size.height - topPadding - bottomPadding; // 绘制水平网格线和价格标签 const gridLines = 4; for (int i = 0; i <= gridLines; i++) { final y = topPadding + (chartHeight / gridLines) * i; canvas.drawLine( Offset(leftPadding, y), Offset(size.width - rightPadding, y), gridPaint, ); // 价格标签 final price = maxPrice - (adjustedRange / gridLines) * i; final priceText = _formatPriceLabel(price); textPaint.text = TextSpan( text: priceText, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 9), ); textPaint.layout(); textPaint.paint(canvas, Offset(size.width - rightPadding + 4, y - textPaint.height / 2)); } // 计算K线宽度 final candleWidth = chartWidth / klines.length; final bodyWidth = math.max(candleWidth * 0.6, 2.0); // 实体宽度,最小2px // 绘制K线 for (int i = 0; i < klines.length; i++) { final kline = klines[i]; final open = double.tryParse(kline.open) ?? 0; final close = double.tryParse(kline.close) ?? 0; final high = double.tryParse(kline.high) ?? 0; final low = double.tryParse(kline.low) ?? 0; final isGreen = close >= open; final paint = isGreen ? greenPaint : redPaint; final x = leftPadding + i * candleWidth + candleWidth / 2; // Y坐标转换(价格 -> 屏幕坐标) double priceToY(double price) { return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight; } final yOpen = priceToY(open); final yClose = priceToY(close); final yHigh = priceToY(high); final yLow = priceToY(low); // 绘制影线 canvas.drawLine( Offset(x, yHigh), Offset(x, yLow), paint..strokeWidth = 1, ); // 绘制实体 final bodyTop = math.min(yOpen, yClose); final bodyBottom = math.max(yOpen, yClose); // 确保实体至少有1px高度 final minBodyHeight = 1.0; final actualBodyBottom = bodyBottom - bodyTop < minBodyHeight ? bodyTop + minBodyHeight : bodyBottom; canvas.drawRect( Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom), paint..style = PaintingStyle.fill, ); } // 绘制最新价格虚线 if (klines.isNotEmpty) { final lastClose = double.tryParse(klines.last.close) ?? 0; final lastY = topPadding + ((maxPrice - lastClose) / adjustedRange) * chartHeight; final dashPaint = Paint() ..color = const Color(0xFFFF6B00) ..strokeWidth = 1 ..style = PaintingStyle.stroke; const dashWidth = 5.0; const dashSpace = 3.0; double startX = leftPadding; while (startX < size.width - rightPadding) { canvas.drawLine( Offset(startX, lastY), Offset(math.min(startX + dashWidth, size.width - rightPadding), lastY), dashPaint, ); startX += dashWidth + dashSpace; } } } // 格式化价格标签 String _formatPriceLabel(double price) { if (price >= 1) { return price.toStringAsFixed(4); } else if (price >= 0.0001) { return price.toStringAsFixed(6); } else { return price.toStringAsExponential(2); } } @override bool shouldRepaint(covariant _CandlestickPainter oldDelegate) { return oldDelegate.klines != klines; } }