0418 0229
更新3
This commit is contained in:
@@ -443,6 +443,7 @@ class _SongDetailPageState extends State<SongDetailPage> {
|
||||
lineColor: color,
|
||||
fillColor: color,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
],
|
||||
)
|
||||
else
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class ScoreProgressChart extends StatelessWidget {
|
||||
// 历史成绩列表,按时间排序
|
||||
class ScoreProgressChart extends StatefulWidget {
|
||||
final List<Map<String, dynamic>> historyScores;
|
||||
final double height;
|
||||
final Color lineColor;
|
||||
@@ -17,44 +16,95 @@ class ScoreProgressChart extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (historyScores.isEmpty) {
|
||||
return SizedBox(height: height);
|
||||
State<ScoreProgressChart> createState() => _ScoreProgressChartState();
|
||||
}
|
||||
|
||||
class _ScoreProgressChartState extends State<ScoreProgressChart> {
|
||||
int? _hoveredIndex;
|
||||
Size? _chartSize;
|
||||
|
||||
List<Map<String, dynamic>> get sortedScores {
|
||||
if (widget.historyScores.isEmpty) return [];
|
||||
return List.from(widget.historyScores)
|
||||
..sort((a, b) => (a['time'] ?? 0).compareTo(b['time'] ?? 0));
|
||||
}
|
||||
|
||||
List<double> get achievements => sortedScores.map((s) {
|
||||
int ach = s['achievement'] ?? 0;
|
||||
return ach / 10000.0;
|
||||
}).toList();
|
||||
|
||||
Map<String, double> get range {
|
||||
final list = achievements;
|
||||
if (list.isEmpty) return {'min': 0, 'max': 1};
|
||||
double minV = list.reduce(min);
|
||||
double maxV = list.reduce(max);
|
||||
if (minV == maxV) {
|
||||
minV = max(0, minV - 1);
|
||||
maxV = min(101, maxV + 1);
|
||||
} else {
|
||||
double r = maxV - minV;
|
||||
minV = max(0, minV - r * 0.1);
|
||||
maxV = min(101, maxV + r * 0.1);
|
||||
}
|
||||
return {'min': minV, 'max': maxV};
|
||||
}
|
||||
|
||||
void _handleTap(TapDownDetails details) {
|
||||
if (_chartSize == null || achievements.isEmpty) return;
|
||||
final tap = details.localPosition;
|
||||
final minV = range['min']!;
|
||||
final maxV = range['max']!;
|
||||
final r = maxV - minV;
|
||||
|
||||
List<Offset> points = [];
|
||||
for (int i = 0; i < achievements.length; i++) {
|
||||
double x = achievements.length == 1
|
||||
? _chartSize!.width / 2
|
||||
: i * _chartSize!.width / (achievements.length - 1);
|
||||
double normY = (achievements[i] - minV) / r;
|
||||
double y = _chartSize!.height - normY * _chartSize!.height;
|
||||
points.add(Offset(x, y));
|
||||
}
|
||||
|
||||
// 提取达成率数据 (achievement / 10000)
|
||||
// 假设 historyScores 已经按时间正序排列
|
||||
final List<double> achievements = historyScores.map((s) {
|
||||
int ach = s['achievement'] ?? 0;
|
||||
return ach / 10000.0;
|
||||
}).toList();
|
||||
int? index;
|
||||
for (int i = 0; i < points.length; i++) {
|
||||
if ((tap - points[i]).distance < 15) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setState(() => _hoveredIndex = index);
|
||||
}
|
||||
|
||||
// 找出最小值和最大值以确定 Y 轴范围,为了美观,稍微扩大一点范围
|
||||
double minVal = achievements.reduce(min);
|
||||
double maxVal = achievements.reduce(max);
|
||||
|
||||
// 如果所有分数一样,给一个默认范围
|
||||
if (minVal == maxVal) {
|
||||
minVal = max(0, minVal - 1.0);
|
||||
maxVal = min(101.0, maxVal + 1.0);
|
||||
} else {
|
||||
// 增加一点上下边距
|
||||
double range = maxVal - minVal;
|
||||
minVal = max(0, minVal - range * 0.1);
|
||||
maxVal = min(101.0, maxVal + range * 0.1);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.historyScores.isEmpty) {
|
||||
return SizedBox(height: widget.height);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: _ChartPainter(
|
||||
achievements: achievements,
|
||||
minVal: minVal,
|
||||
maxVal: maxVal,
|
||||
lineColor: lineColor,
|
||||
fillColor: fillColor.withOpacity(0.2),
|
||||
),
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, cons) {
|
||||
_chartSize = Size(cons.maxWidth, widget.height);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: _handleTap,
|
||||
child: CustomPaint(
|
||||
painter: _ChartPainter(
|
||||
achievements: achievements,
|
||||
minVal: range['min']!,
|
||||
maxVal: range['max']!,
|
||||
lineColor: widget.lineColor,
|
||||
fillColor: widget.fillColor.withOpacity(0.2),
|
||||
rawData: sortedScores,
|
||||
hoveredIndex: _hoveredIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -66,6 +116,8 @@ class _ChartPainter extends CustomPainter {
|
||||
final double maxVal;
|
||||
final Color lineColor;
|
||||
final Color fillColor;
|
||||
final List<Map<String, dynamic>> rawData;
|
||||
final int? hoveredIndex;
|
||||
|
||||
_ChartPainter({
|
||||
required this.achievements,
|
||||
@@ -73,84 +125,102 @@ class _ChartPainter extends CustomPainter {
|
||||
required this.maxVal,
|
||||
required this.lineColor,
|
||||
required this.fillColor,
|
||||
required this.rawData,
|
||||
required this.hoveredIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// ✅ 修复1:数据不足直接返回
|
||||
if (achievements.length < 1) return;
|
||||
if (achievements.isEmpty) return;
|
||||
|
||||
final paint = Paint()
|
||||
final linePaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = 2.0
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = fillColor
|
||||
..style = PaintingStyle.fill;
|
||||
final fillPaint = Paint()..color = fillColor;
|
||||
final dotPaint = Paint()..color = lineColor;
|
||||
final highlightPaint = Paint()..color = Colors.white;
|
||||
|
||||
final path = Path();
|
||||
final fillPath = Path();
|
||||
|
||||
double width = size.width;
|
||||
double height = size.height;
|
||||
double range = maxVal - minVal;
|
||||
if (range == 0) range = 1; // 防止除以0
|
||||
|
||||
// 计算每个点的坐标
|
||||
final r = maxVal - minVal;
|
||||
List<Offset> points = [];
|
||||
|
||||
for (int i = 0; i < achievements.length; i++) {
|
||||
double x;
|
||||
if (achievements.length == 1) {
|
||||
x = width / 2; // ✅ 修复2:只有1个点时,放中间
|
||||
} else {
|
||||
x = (i / (achievements.length - 1)) * width;
|
||||
}
|
||||
|
||||
// Y轴翻转,因为Canvas原点在左上角
|
||||
double normalizedY = (achievements[i] - minVal) / range;
|
||||
double y = height - (normalizedY * height);
|
||||
|
||||
// ✅ 修复3:防止 NaN / 无限大
|
||||
if (x.isNaN || x.isInfinite) x = 0;
|
||||
if (y.isNaN || y.isInfinite) y = height / 2;
|
||||
|
||||
double x = achievements.length == 1
|
||||
? size.width / 2
|
||||
: i * size.width / (achievements.length - 1);
|
||||
double normY = (achievements[i] - minVal) / r;
|
||||
double y = size.height - normY * size.height;
|
||||
points.add(Offset(x, y));
|
||||
}
|
||||
|
||||
// 构建折线路径
|
||||
path.moveTo(points.first.dx, points.first.dy);
|
||||
fillPath.moveTo(points.first.dx, height); // 填充从底部开始
|
||||
fillPath.lineTo(points.first.dx, points.first.dy);
|
||||
|
||||
for (int i = 1; i < points.length; i++) {
|
||||
path.lineTo(points[i].dx, points[i].dy);
|
||||
fillPath.lineTo(points[i].dx, points[i].dy);
|
||||
}
|
||||
|
||||
// 闭合填充路径
|
||||
fillPath.lineTo(points.last.dx, height);
|
||||
fillPath.close();
|
||||
|
||||
// 绘制填充
|
||||
// 填充
|
||||
final fillPath = Path()
|
||||
..moveTo(points.first.dx, size.height)
|
||||
..lineTo(points.first.dx, points.first.dy);
|
||||
for (var p in points) fillPath.lineTo(p.dx, p.dy);
|
||||
fillPath
|
||||
..lineTo(points.last.dx, size.height)
|
||||
..close();
|
||||
canvas.drawPath(fillPath, fillPaint);
|
||||
// 绘制折线
|
||||
canvas.drawPath(path, paint);
|
||||
|
||||
// 可选:绘制最后一个点的高亮圆点
|
||||
final dotPaint = Paint()..color = lineColor;
|
||||
final lastPoint = points.last;
|
||||
// ✅ 修复4:绘制前校验,彻底杜绝NaN崩溃
|
||||
if (!lastPoint.dx.isNaN && !lastPoint.dy.isNaN) {
|
||||
canvas.drawCircle(lastPoint, 3.0, dotPaint);
|
||||
// 线条
|
||||
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
|
||||
for (var p in points) linePath.lineTo(p.dx, p.dy);
|
||||
canvas.drawPath(linePath, linePaint);
|
||||
|
||||
// 点
|
||||
for (var p in points) canvas.drawCircle(p, 2.5, dotPaint);
|
||||
|
||||
// 高亮
|
||||
if (hoveredIndex != null && hoveredIndex! < rawData.length) {
|
||||
final p = points[hoveredIndex!];
|
||||
canvas.drawCircle(p, 5, highlightPaint);
|
||||
canvas.drawCircle(p, 3, dotPaint);
|
||||
_drawTooltip(canvas, size, p, rawData[hoveredIndex!]);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawTooltip(Canvas canvas, Size size, Offset p, Map<String, dynamic> data) {
|
||||
final ach = (data['achievement'] ?? 0) / 10000.0;
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(data['time'] ?? 0);
|
||||
final level = data['level'] ?? 0;
|
||||
final sdx = data['deluxscoreMax'] ?? 0;
|
||||
final combo = data["comboStatus"];
|
||||
final sync = data["syncStatus"];
|
||||
|
||||
final lines = [
|
||||
'达成率: ${ach.toStringAsFixed(4)}%',
|
||||
'Lv.$level DX:$sdx',
|
||||
'Combo:$combo Sync:$sync',
|
||||
'${time.month}/${time.day} ${time.hour}:${time.minute.toString().padLeft(2,'0')}',
|
||||
];
|
||||
|
||||
final painter = TextPainter(
|
||||
textDirection: TextDirection.ltr,
|
||||
text: TextSpan(
|
||||
style: TextStyle(color: Colors.white, fontSize: 11),
|
||||
children: lines.map((l) => TextSpan(text: '$l\n')).toList(),
|
||||
),
|
||||
)..layout();
|
||||
|
||||
final w = painter.width + 10;
|
||||
final h = painter.height + 6;
|
||||
double dx = p.dx;
|
||||
double dy = p.dy - h - 8;
|
||||
|
||||
if (dx + w > size.width) dx = size.width - w;
|
||||
if (dx < 0) dx = 0;
|
||||
if (dy < 0) dy = p.dy + 8;
|
||||
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(Rect.fromLTWH(dx, dy, w, h), Radius.circular(6)),
|
||||
Paint()..color = Colors.black54,
|
||||
);
|
||||
painter.paint(canvas, Offset(dx + 5, dy + 3));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ChartPainter oldDelegate) {
|
||||
return oldDelegate.achievements != achievements ||
|
||||
oldDelegate.minVal != minVal ||
|
||||
oldDelegate.maxVal != maxVal;
|
||||
}
|
||||
bool shouldRepaint(covariant _ChartPainter old) =>
|
||||
old.achievements != achievements || old.hoveredIndex != hoveredIndex;
|
||||
}
|
||||
Reference in New Issue
Block a user