// usage_line_chart.dart — 最近两周(14 天)按天流量折线图。 // CustomPainter 手绘:Y 轴(网格+GB 刻度) + X 轴(隔几天标日期) + 渐变面积 + // accent 折线 + 14 个数据点(末点强调),无第三方图表依赖。 import 'package:flutter/material.dart'; /// values: 每日流量(GB,旧→新);dates: 同长的 "YYYY-MM-DD"(用于 X 轴日期标)。 /// accent/grid/axis/surface 由调用方传 token 色(零硬编码)。 class UsageLineChart extends StatelessWidget { const UsageLineChart({ super.key, required this.values, required this.dates, required this.accent, required this.grid, required this.axis, required this.surface, }); final List values; final List dates; final Color accent, grid, axis, surface; @override Widget build(BuildContext context) { return SizedBox( height: 138, child: CustomPaint( painter: _LinePainter(values, dates, accent, grid, axis, surface), size: Size.infinite, ), ); } } class _LinePainter extends CustomPainter { _LinePainter(this.values, this.dates, this.accent, this.grid, this.axis, this.surface); final List values; final List dates; final Color accent, grid, axis, surface; // 边距:左留 Y 刻度,下留 X 日期。 static const double leftW = 30, bottomH = 16, topPad = 8, rightPad = 6; void _label(Canvas c, String s, Offset at, Color color, TextAlign align) { final tp = TextPainter( text: TextSpan( text: s, style: TextStyle(color: color, fontSize: 9, fontFeatures: const [FontFeature.tabularFigures()]), ), textDirection: TextDirection.ltr, )..layout(); var dx = at.dx; if (align == TextAlign.right) { dx = at.dx - tp.width; } else if (align == TextAlign.center) { dx = at.dx - tp.width / 2; } tp.paint(c, Offset(dx, at.dy)); } static String _md(String iso) { final p = iso.split('-'); if (p.length != 3) return iso; return '${int.tryParse(p[1]) ?? p[1]}/${int.tryParse(p[2]) ?? p[2]}'; } @override void paint(Canvas canvas, Size size) { final plotL = leftW, plotR = size.width - rightPad, plotT = topPad, plotB = size.height - bottomH; final plotW = plotR - plotL, plotH = plotB - plotT; final maxV = values.isEmpty ? 1.0 : values.reduce((a, b) => a > b ? a : b); final denom = maxV <= 0 ? 1.0 : maxV; // Y 轴:3 条网格(max / mid / 0) + 左侧 GB 刻度。 final gp = Paint() ..color = grid ..strokeWidth = 1; for (var k = 0; k <= 2; k++) { final frac = k / 2.0; // 0=顶(max),1=底(0) final y = plotT + plotH * frac; canvas.drawLine(Offset(plotL, y), Offset(plotR, y), gp); _label(canvas, (denom * (1 - frac)).toStringAsFixed(1), Offset(plotL - 5, y - 5), axis, TextAlign.right); } if (values.isEmpty) return; final n = values.length; final dx = n > 1 ? plotW / (n - 1) : 0.0; Offset pt(int i) => Offset(plotL + i * dx, plotT + plotH * (1 - values[i] / denom)); // 渐变面积 final area = Path()..moveTo(plotL, plotB); for (var i = 0; i < n; i++) { area.lineTo(pt(i).dx, pt(i).dy); } area ..lineTo(plotR, plotB) ..close(); canvas.drawPath( area, Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [accent.withOpacity(0.22), accent.withOpacity(0)], ).createShader(Rect.fromLTWH(plotL, plotT, plotW, plotH)), ); // 折线 final line = Path()..moveTo(pt(0).dx, pt(0).dy); for (var i = 1; i < n; i++) { line.lineTo(pt(i).dx, pt(i).dy); } canvas.drawPath( line, Paint() ..color = accent ..style = PaintingStyle.stroke ..strokeWidth = 2 ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round, ); // 14 个数据点:surface 光晕 + accent 实心,末点加大。 final halo = Paint()..color = surface; final dot = Paint()..color = accent; for (var i = 0; i < n; i++) { final r = i == n - 1 ? 3.6 : 2.6; canvas.drawCircle(pt(i), r + 1.4, halo); canvas.drawCircle(pt(i), r, dot); } // X 轴:隔几天标一个日期(始终含末点)。 final step = (n / 4).ceil().clamp(1, n); for (var i = 0; i < n; i += step) { _label(canvas, _md(i < dates.length ? dates[i] : ''), Offset(pt(i).dx, plotB + 3), axis, TextAlign.center); } if ((n - 1) % step != 0 && dates.isNotEmpty) { _label(canvas, _md(dates[n - 1]), Offset(pt(n - 1).dx, plotB + 3), axis, TextAlign.center); } } @override bool shouldRepaint(covariant _LinePainter old) => old.values != values || old.dates != dates || old.accent != accent; }