diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index fbace62..bd1164d 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -44,10 +44,10 @@ class StatsPage extends ConsumerWidget { ]; } - // 折线:最后 14 天每日总流量(GB)。 - final chart = [ - for (var i = usage.length > 14 ? usage.length - 14 : 0; i < usage.length; i++) usage[i].gbTotal, - ]; + // 折线:最后 14 天每日总流量(GB) + 对应日期(X 轴标注)。 + final chartPts = usage.length > 14 ? usage.sublist(usage.length - 14) : usage; + final chartVals = chartPts.map((p) => p.gbTotal).toList(); + final chartDates = chartPts.map((p) => p.date).toList(); final body = RefreshIndicator( color: c.accent, @@ -92,7 +92,14 @@ class StatsPage extends ConsumerWidget { Text('GB', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 11)), ]), const SizedBox(height: 10), - UsageLineChart(values: chart, accent: c.accent, grid: c.border), + UsageLineChart( + values: chartVals, + dates: chartDates, + accent: c.accent, + grid: c.border, + axis: c.fg3, + surface: c.surface, + ), ]), ), ], diff --git a/client/lib/widgets/usage_line_chart.dart b/client/lib/widgets/usage_line_chart.dart index 5cc1817..ed460f1 100644 --- a/client/lib/widgets/usage_line_chart.dart +++ b/client/lib/widgets/usage_line_chart.dart @@ -1,52 +1,100 @@ // usage_line_chart.dart — 最近两周(14 天)按天流量折线图。 -// CustomPainter 手绘:渐变面积 + accent 折线 + 末点强调,无第三方图表依赖。 +// CustomPainter 手绘:Y 轴(网格+GB 刻度) + X 轴(隔几天标日期) + 渐变面积 + +// accent 折线 + 14 个数据点(末点强调),无第三方图表依赖。 import 'package:flutter/material.dart'; -/// values: 每日流量(GB,旧→新)。accent/grid 由调用方传 token 色(零硬编码)。 +/// 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.accent, required this.grid}); + 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 Color accent, grid; + final List dates; + final Color accent, grid, axis, surface; @override Widget build(BuildContext context) { return SizedBox( - height: 116, - child: CustomPaint(painter: _LinePainter(values, accent, grid), size: Size.infinite), + height: 138, + child: CustomPaint( + painter: _LinePainter(values, dates, accent, grid, axis, surface), + size: Size.infinite, + ), ); } } class _LinePainter extends CustomPainter { - _LinePainter(this.values, this.accent, this.grid); + _LinePainter(this.values, this.dates, this.accent, this.grid, this.axis, this.surface); final List values; - final Color accent, grid; + 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 i = 1; i <= 3; i++) { - final y = size.height * i / 4; - canvas.drawLine(Offset(0, y), Offset(size.width, y), gp); + 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 maxV = values.reduce((a, b) => a > b ? a : b); - final denom = maxV <= 0 ? 1.0 : maxV; final n = values.length; - final dx = n > 1 ? size.width / (n - 1) : 0.0; - const topPad = 10.0, botPad = 6.0; - final h = size.height - topPad - botPad; - Offset pt(int i) => Offset(i * dx, topPad + h * (1 - values[i] / denom)); + 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(0, size.height); + // 渐变面积 + final area = Path()..moveTo(plotL, plotB); for (var i = 0; i < n; i++) { area.lineTo(pt(i).dx, pt(i).dy); } area - ..lineTo(size.width, size.height) + ..lineTo(plotR, plotB) ..close(); canvas.drawPath( area, @@ -54,10 +102,11 @@ class _LinePainter extends CustomPainter { ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [accent.withOpacity(0.26), accent.withOpacity(0)], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)), + 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); @@ -72,10 +121,26 @@ class _LinePainter extends CustomPainter { ..strokeCap = StrokeCap.round, ); - canvas.drawCircle(pt(n - 1), 3.2, Paint()..color = accent); + // 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.accent != accent || old.grid != grid; + old.values != values || old.dates != dates || old.accent != accent; } diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index f4c839e..308ba2b 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png index 24ea0cc..33f3eab 100644 Binary files a/client/test/golden/goldens/tablet_stats_dark_zh.png and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png index 6af0f10..43d2340 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_en.png and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png index ebb69d3..f1c4704 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_zh.png and b/client/test/golden/goldens/tablet_stats_light_zh.png differ