diff --git a/lib/pages/music/score_single.dart b/lib/pages/music/score_single.dart index e9c0a62..c063d4d 100644 --- a/lib/pages/music/score_single.dart +++ b/lib/pages/music/score_single.dart @@ -443,6 +443,7 @@ class _SongDetailPageState extends State { lineColor: color, fillColor: color, ), + const SizedBox(height: 5), ], ) else diff --git a/lib/widgets/score_progress_chart.dart b/lib/widgets/score_progress_chart.dart index 42fd47b..56be687 100644 --- a/lib/widgets/score_progress_chart.dart +++ b/lib/widgets/score_progress_chart.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'dart:math'; -class ScoreProgressChart extends StatelessWidget { - // 历史成绩列表,按时间排序 +class ScoreProgressChart extends StatefulWidget { final List> 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 createState() => _ScoreProgressChartState(); +} + +class _ScoreProgressChartState extends State { + int? _hoveredIndex; + Size? _chartSize; + + List> get sortedScores { + if (widget.historyScores.isEmpty) return []; + return List.from(widget.historyScores) + ..sort((a, b) => (a['time'] ?? 0).compareTo(b['time'] ?? 0)); + } + + List get achievements => sortedScores.map((s) { + int ach = s['achievement'] ?? 0; + return ach / 10000.0; + }).toList(); + + Map 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 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 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> 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 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 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; } \ No newline at end of file