feat(client/stats): 统计页重设计 — 月/周/日周期卡 + 设备下拉 + 两周折线 + 修刷新
布局重做(对照确认稿): - 3 张周期卡(本月/本周/今日),每张 = ↓下行 · ↑上行 · 使用时长(PeriodCard) - 右上设备下拉(_DeviceDropdown,先只挂「全部」;每设备归因打通后填设备名,见 TODO #9/#10) - 柱状图 → 折线图:最近两周(14 天)按天流量(UsageLineChart,CustomPainter 零依赖) 数据源统一 + 修刷新(根治「本月流量冻住」): - 月/周/日 + 折线全从单一源 usageProvider(30) 聚合(取最后 N 天点求和,末点即今日, 避开 UTC/本地日期坑);不再 me.weekly + usage 两条管线 - usageProvider/deviceUsageProvider 改 autoDispose(进页重取)+ 下拉刷新(invalidate) + 刷新连带 meProvider.refresh。后端每 60s 更新,无需更勤 UI 全走 PangolinColors/Text/Spacing token 单源、零硬编码;中英 l10n key 补齐。 widget 数值断言测试(刀1)按新结构重写并通过;统计/连接页 golden 重生成 (非 CI 闸;components/auth 基线未动)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// period_card.dart — 周期统计卡(本月/本周/今日):左侧周期名 + 横排 3 指标。
|
||||
// 沿用 MetricCard 的 surface+border+shadow 与 mono 数值观感,3 张竖排成统计页主体。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
|
||||
/// 一个指标:数值 + 单位 + 标签(下行/上行/使用时长)。
|
||||
class StatMetric {
|
||||
const StatMetric(this.value, this.unit, this.label);
|
||||
final String value, unit, label;
|
||||
}
|
||||
|
||||
class PeriodCard extends StatelessWidget {
|
||||
const PeriodCard({super.key, required this.period, required this.metrics});
|
||||
|
||||
/// 周期名(本月/本周/今日)。
|
||||
final String period;
|
||||
final List<StatMetric> metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
border: Border.all(color: c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Row(children: [
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(period,
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
),
|
||||
for (var i = 0; i < metrics.length; i++)
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: i == 0
|
||||
? null
|
||||
: BoxDecoration(border: Border(left: BorderSide(color: c.border))),
|
||||
child: Column(children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: metrics[i].value,
|
||||
style: PangolinText.mono.copyWith(fontSize: 19, color: c.fg1, fontWeight: FontWeight.w500),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: ' ${metrics[i].unit}',
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(metrics[i].label,
|
||||
textAlign: TextAlign.center,
|
||||
style: PangolinText.caption.copyWith(color: c.fg2, fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// usage_line_chart.dart — 最近两周(14 天)按天流量折线图。
|
||||
// CustomPainter 手绘:渐变面积 + accent 折线 + 末点强调,无第三方图表依赖。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// values: 每日流量(GB,旧→新)。accent/grid 由调用方传 token 色(零硬编码)。
|
||||
class UsageLineChart extends StatelessWidget {
|
||||
const UsageLineChart({super.key, required this.values, required this.accent, required this.grid});
|
||||
final List<double> values;
|
||||
final Color accent, grid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 116,
|
||||
child: CustomPaint(painter: _LinePainter(values, accent, grid), size: Size.infinite),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinePainter extends CustomPainter {
|
||||
_LinePainter(this.values, this.accent, this.grid);
|
||||
final List<double> values;
|
||||
final Color accent, grid;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
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);
|
||||
}
|
||||
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 area = Path()..moveTo(0, size.height);
|
||||
for (var i = 0; i < n; i++) {
|
||||
area.lineTo(pt(i).dx, pt(i).dy);
|
||||
}
|
||||
area
|
||||
..lineTo(size.width, size.height)
|
||||
..close();
|
||||
canvas.drawPath(
|
||||
area,
|
||||
Paint()
|
||||
..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)),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
canvas.drawCircle(pt(n - 1), 3.2, Paint()..color = accent);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LinePainter old) =>
|
||||
old.values != values || old.accent != accent || old.grid != grid;
|
||||
}
|
||||
Reference in New Issue
Block a user