import 'package:flutter/material.dart'; import 'dart:math'; class ScoreProgressChart extends StatefulWidget { final List> 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 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)); } 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 achievements; final double minVal; final double maxVal; final Color lineColor; final Color fillColor; final List> 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 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 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; }