Files
UnionApp/lib/widgets/score_progress_chart.dart
spasolreisa d4bbc424c6 0418 0229
更新3
2026-04-18 02:29:27 +08:00

226 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import 'dart:math';
class ScoreProgressChart extends StatefulWidget {
final List<Map<String, dynamic>> historyScores;
final double height;
final Color lineColor;
final Color fillColor;
const ScoreProgressChart({
super.key,
required this.historyScores,
this.height = 60.0,
this.lineColor = Colors.blue,
this.fillColor = Colors.blueAccent,
});
@override
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));
}
int? index;
for (int i = 0; i < points.length; i++) {
if ((tap - points[i]).distance < 15) {
index = i;
break;
}
}
setState(() => _hoveredIndex = index);
}
@override
Widget build(BuildContext context) {
if (widget.historyScores.isEmpty) {
return SizedBox(height: widget.height);
}
return SizedBox(
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,
),
),
);
},
),
);
}
}
class _ChartPainter extends CustomPainter {
final List<double> achievements;
final double minVal;
final double maxVal;
final Color lineColor;
final Color fillColor;
final List<Map<String, dynamic>> rawData;
final int? hoveredIndex;
_ChartPainter({
required this.achievements,
required this.minVal,
required this.maxVal,
required this.lineColor,
required this.fillColor,
required this.rawData,
required this.hoveredIndex,
});
@override
void paint(Canvas canvas, Size size) {
if (achievements.isEmpty) return;
final linePaint = Paint()
..color = lineColor
..strokeWidth = 2
..style = PaintingStyle.stroke;
final fillPaint = Paint()..color = fillColor;
final dotPaint = Paint()..color = lineColor;
final highlightPaint = Paint()..color = Colors.white;
final r = maxVal - minVal;
List<Offset> points = [];
for (int i = 0; i < achievements.length; i++) {
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));
}
// 填充
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);
// 线条
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 old) =>
old.achievements != achievements || old.hoveredIndex != hoveredIndex;
}