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

1055 lines
34 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 '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<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: 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<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';
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<PriceInfo?> 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<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线图绘制器Y轴自适应显示真实数据
class _CandlestickPainter extends CustomPainter {
final List<Kline> 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;
}
}