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:
wangjia
2026-06-28 17:43:14 +08:00
parent 47147a1041
commit 01ebafc05e
12 changed files with 351 additions and 135 deletions
+11
View File
@@ -62,6 +62,17 @@ abstract class AppText {
String get durMonth;
String get weekTraffic;
List<String> get days7;
String get byDevice;
String get noDeviceUsage;
// 统计页重设计:周期(月/周/日) · 指标(下行/上行/时长) · 折线 · 设备下拉
String get periodMonth;
String get periodWeek;
String get periodToday;
String get statDown;
String get statUp;
String get statDuration;
String get chartTwoWeeks;
String get allDevices;
// ── 账户页 ──
String get meTitle;
+20
View File
@@ -80,6 +80,26 @@ class StringsEn extends AppText {
String get weekTraffic => 'This week (GB)';
@override
List<String> get days7 => const ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
@override
String get byDevice => 'By device';
@override
String get noDeviceUsage => 'No device usage yet';
@override
String get periodMonth => 'This month';
@override
String get periodWeek => 'This week';
@override
String get periodToday => 'Today';
@override
String get statDown => 'Download';
@override
String get statUp => 'Upload';
@override
String get statDuration => 'Duration';
@override
String get chartTwoWeeks => 'Last 2 weeks';
@override
String get allDevices => 'All devices';
@override
String get meTitle => 'Account';
+20
View File
@@ -79,6 +79,26 @@ class StringsZh extends AppText {
String get weekTraffic => '本周流量 (GB)';
@override
List<String> get days7 => const ['', '', '', '', '', '', ''];
@override
String get byDevice => '设备明细';
@override
String get noDeviceUsage => '暂无设备用量';
@override
String get periodMonth => '本月';
@override
String get periodWeek => '本周';
@override
String get periodToday => '今日';
@override
String get statDown => '下行';
@override
String get statUp => '上行';
@override
String get statDuration => '使用时长';
@override
String get chartTwoWeeks => '最近两周流量';
@override
String get allDevices => '全部设备';
@override
String get meTitle => '我的';
+114 -96
View File
@@ -1,4 +1,5 @@
// stats_page.dart — 统计页(指标卡 + 本周柱状图)
// stats_page.dart — 统计页(月/周/日 周期卡 + 设备下拉 + 最近两周流量折线)。
// 单一数据源:近30天每日用量(usageProvider,autoDispose 进页重取),月/周/日 + 折线全从此聚合。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -6,9 +7,10 @@ import '../models/usage_point.dart';
import '../pangolin_theme.dart';
import '../state/account_providers.dart';
import '../state/app_providers.dart';
import '../state/connection_provider.dart';
import '../state/nodes_provider.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/period_card.dart';
import '../widgets/usage_line_chart.dart';
class StatsPage extends ConsumerWidget {
const StatsPage({super.key, required this.isWide});
@@ -18,87 +20,83 @@ class StatsPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
// 真实数据:周柱来自 me.weekly_gb;本月流量/时长来自 usage(30);延迟为生效节点实测。
final me = ref.watch(meProvider).valueOrNull;
final usage = ref.watch(usageProvider(30)).valueOrNull ?? const <UsagePoint>[];
final node = ref.watch(effectiveNodeProvider);
// 延迟:连接后取内核 urltest 实测最小值,否则取节点 TCP 探针 ping
final stats = ref.watch(vpnStatsProvider).valueOrNull;
final connected = ref.watch(connectionProvider).phase == VpnPhase.on;
var latencyMs = node.ping;
if (connected && stats != null) {
final ds = stats.urltestResults.where((r) => r.delayMs > 0).map((r) => r.delayMs);
if (ds.isNotEmpty) latencyMs = ds.reduce((a, b) => a < b ? a : b);
// 周期聚合:取最后 N 天的点求和。最后一点即今日(避开 UTC/本地日期换算)
(int down, int up, int min) sumLast(int days) {
var d = 0, u = 0, m = 0;
final start = usage.length > days ? usage.length - days : 0;
for (var i = start; i < usage.length; i++) {
d += usage[i].bytesDown;
u += usage[i].bytesUp;
m += usage[i].minutesUsed;
}
return (d, u, m);
}
final weekly = (me?.weeklyGb.length == 7) ? me!.weeklyGb : List<double>.filled(7, 0.0);
final maxV = weekly.fold<double>(0, (a, b) => a > b ? a : b);
final denom = maxV <= 0 ? 1.0 : maxV;
final monthGb = usage.fold<double>(0, (s, p) => s + p.gbTotal);
final monthHours = usage.fold<int>(0, (s, p) => s + p.minutesUsed) / 60.0;
// desktop 对照 dapp.jsx DStatspadding 32/8、卡 gap 14、柱 height 120·bar×86·宽 34。
final pad = isWide ? 32.0 : 20.0;
List<StatMetric> metricsFor(int days) {
final (d, u, m) = sumLast(days);
final dn = _fmtBytes(d), up = _fmtBytes(u);
return [
StatMetric(dn.$1, dn.$2, '${t.statDown}'),
StatMetric(up.$1, up.$2, '${t.statUp}'),
StatMetric((m / 60.0).toStringAsFixed(1), 'h', t.statDuration),
];
}
final metrics = [
(t.trafficMonth, monthGb.toStringAsFixed(1), 'GB'),
(t.avgPing, latencyMs > 0 ? '$latencyMs' : '', 'ms'),
(t.durMonth, monthHours.toStringAsFixed(1), 'h'),
// 折线:最后 14 天每日总流量(GB)。
final chart = <double>[
for (var i = usage.length > 14 ? usage.length - 14 : 0; i < usage.length; i++) usage[i].gbTotal,
];
final body = ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 8 : 0, pad, 24),
children: [
Row(children: [
for (var i = 0; i < metrics.length; i++)
Expanded(
child: Padding(
padding: EdgeInsets.only(right: i < metrics.length - 1 ? 14 : 0),
child: _MetricCard(label: metrics[i].$1, value: metrics[i].$2, unit: metrics[i].$3),
),
),
]),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.weekTraffic,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 18),
SizedBox(
// 柱最高 86 + 上下数值/星期标签两行 + 间距 ≈ 134,留余量 140 避免溢出。
height: 140,
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
for (var i = 0; i < weekly.length; i++)
Expanded(
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Text(weekly[i].toStringAsFixed(1), style: PangolinText.mono.copyWith(fontSize: 10.5, color: c.fg3)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 34),
child: Container(
height: 86 * (weekly[i] / denom),
decoration: BoxDecoration(
color: c.accent.withOpacity(0.85),
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
),
),
),
const SizedBox(height: 8),
Text(t.days7[i], style: PangolinText.caption.copyWith(color: c.fg3)),
]),
),
]),
),
final body = RefreshIndicator(
color: c.accent,
backgroundColor: c.surface,
onRefresh: () async {
ref.invalidate(usageProvider(30));
ref.invalidate(deviceUsageProvider(30));
await ref.read(meProvider.notifier).refresh();
},
child: ListView(
padding: EdgeInsets.fromLTRB(isWide ? 32 : 20, isWide ? 8 : 4, isWide ? 32 : 20, 24),
children: [
// 顶行:标题(宽屏) + 设备下拉(右上角)
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
if (isWide)
Text(t.statsTitle,
style: PangolinText.display.copyWith(color: c.fg1, fontSize: 20, fontWeight: FontWeight.w700))
else
const SizedBox.shrink(),
_DeviceDropdown(label: t.allDevices),
]),
),
],
const SizedBox(height: 14),
PeriodCard(period: t.periodMonth, metrics: metricsFor(30)),
const SizedBox(height: 12),
PeriodCard(period: t.periodWeek, metrics: metricsFor(7)),
const SizedBox(height: 12),
PeriodCard(period: t.periodToday, metrics: metricsFor(1)),
const SizedBox(height: 20),
// 折线卡
Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(t.chartTwoWeeks,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 13)),
Text('GB', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 11)),
]),
const SizedBox(height: 10),
UsageLineChart(values: chart, accent: c.accent, grid: c.border),
]),
),
],
),
);
if (isWide) return body;
@@ -110,29 +108,49 @@ class StatsPage extends ConsumerWidget {
}
}
class _MetricCard extends StatelessWidget {
const _MetricCard({required this.label, required this.value, required this.unit});
final String label, value, unit;
/// 字节 → (数值, 单位):<1GB 显示 MB,否则 GB(各 1 位小数)。
(String, String) _fmtBytes(int bytes) {
const gb = 1024 * 1024 * 1024, mb = 1024 * 1024;
if (bytes >= gb) return ((bytes / gb).toStringAsFixed(1), 'GB');
return ((bytes / mb).toStringAsFixed(1), 'MB');
}
/// 设备下拉(右上角):全部 + 近30天用过的设备名。
/// 当前每设备归因未打通(usage_device_daily 常空),先只挂「全部」;
/// 设备注册打通后(TODO #9/#10)填充设备名 + 按设备过滤。
class _DeviceDropdown extends StatelessWidget {
const _DeviceDropdown({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
return PopupMenuButton<String>(
tooltip: '',
offset: const Offset(0, 38),
color: c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.md),
side: BorderSide(color: c.border),
),
itemBuilder: (_) => [
PopupMenuItem(value: '', child: Text(label, style: PangolinText.sm.copyWith(color: c.fg1))),
],
child: Container(
padding: const EdgeInsets.fromLTRB(12, 6, 10, 6),
decoration: BoxDecoration(
color: c.surface,
border: Border.all(color: c.borderStrong),
borderRadius: BorderRadius.circular(PangolinRadius.full),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.smartphone, size: 14, color: c.accent),
const SizedBox(width: 7),
Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)),
const SizedBox(width: 5),
Icon(PangolinIcons.chevronDown, size: 13, color: c.fg3),
]),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 7),
Text.rich(TextSpan(
text: value,
style: PangolinText.mono.copyWith(fontSize: 24, color: c.fg1, fontWeight: FontWeight.w500),
children: [TextSpan(text: ' $unit', style: PangolinText.caption.copyWith(color: c.fg3))],
)),
]),
);
}
}
+12 -1
View File
@@ -6,6 +6,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/device.dart';
import '../models/device_usage.dart';
import '../models/me.dart';
import '../models/plan.dart';
import '../models/usage_point.dart';
@@ -55,11 +56,21 @@ final plansProvider = FutureProvider<List<Plan>>((ref) async {
});
/// 最近 N 天用量(GET /v1/usage?days=N)。未登录返回空。
final usageProvider = FutureProvider.family<List<UsagePoint>, int>((ref, days) async {
/// autoDispose:离开统计页即销毁,重进自动重取(配合下拉刷新/连接态变化 invalidate),
/// 根治「本月流量取一次就冻住」。后端每 60s 才更新,无需更勤刷新。
final usageProvider = FutureProvider.autoDispose.family<List<UsagePoint>, int>((ref, days) async {
if (!ref.watch(authProvider).isLoggedIn) return const [];
return ref.read(accountApiProvider).usage(days: days);
});
/// 最近 N 天按设备用量(GET /v1/usage/devices?days=N)。统计页设备下拉/明细用。
/// 未登录返回空。autoDispose 同 usageProvider。
final deviceUsageProvider =
FutureProvider.autoDispose.family<List<DeviceUsage>, int>((ref, days) async {
if (!ref.watch(authProvider).isLoggedIn) return const [];
return ref.read(accountApiProvider).deviceUsage(days: days);
});
/// 已登录设备列表(GET /v1/me/devices)。
class DevicesNotifier extends AsyncNotifier<List<Device>> {
@override
+68
View File
@@ -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)),
]),
),
),
]),
);
}
}
+81
View File
@@ -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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+25 -38
View File
@@ -1,10 +1,9 @@
// stats_page_test.dart — 问题① 前端统计「上屏数值对不对」widget 级断言。
// stats_page_test.dart — 问题① 前端统计「上屏数值对不对」widget 级断言(重设计后)
//
// 闭合最初诉求「统计准不准」的前端那一半:后端记账已 L4 e2e,前端此前只到
// 解析单测(device_usage_test/format_test)——解析对 ≠ 渲染对。本测试把一份
// **真实形态的后端响应**(/v1/me·/v1/usage·/v1/usage/devices)经真 ApiClient→
// AccountApi→provider 链(连带测 JSON 解析)喂给 StatsPage,断言指标卡/周柱/
// 分设备行上屏的数字是对的。注入 MockClient 不打真网络。
// 闭合「统计准不准」的前端那一半:把一份**真实形态的后端响应**(/v1/me·/v1/usage·
// /v1/usage/devices)经真 ApiClient→AccountApi→provider 链(连带测 JSON 解析)喂给
// 重设计后的 StatsPage,断言「本月/本周/今日」三张周期卡的 下行/上行/使用时长 上屏数字
// 正确。注入 MockClient 不打真网络。
import 'dart:convert';
import 'package:flutter/material.dart';
@@ -23,8 +22,7 @@ import 'package:pangolin_vpn/services/token_store.dart';
import 'package:pangolin_vpn/state/account_providers.dart';
import 'package:pangolin_vpn/state/auth_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import 'package:pangolin_vpn/widgets/device_stat_row.dart';
import 'package:pangolin_vpn/widgets/metric_card.dart';
import 'package:pangolin_vpn/widgets/period_card.dart';
import '../helpers/harness.dart';
@@ -52,7 +50,6 @@ class _LoggedInTokenStore implements TokenStore {
Future<String?> loadLastEmail() async => null;
}
// 真实形态后端响应(字段面对照 api_contract_test.dart 的冻结样本)。
final Map<String, dynamic> _meJson = {
'email': 'u@test.local',
'plan': 'free',
@@ -61,7 +58,7 @@ final Map<String, dynamic> _meJson = {
'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5],
};
// 两天用量:总 3.0 GB、总 120 分钟(2.0 h)
// 两天用量(旧→新):24日 down/up=1GiB·90min;25日(末天=今日)down/up=0.5GiB·30min
final Map<String, dynamic> _usageJson = {
'points': [
{'date': '2026-06-24', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 90},
@@ -69,11 +66,9 @@ final Map<String, dynamic> _usageJson = {
],
};
// 两台设备:iPhone 2.0GB/2.0h、MacBook 1.0GB/1.0h。
final Map<String, dynamic> _devicesJson = {
'devices': [
{'uuid': 'd1', 'name': 'iPhone', 'platform': 'ios', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 120},
{'uuid': 'd2', 'name': 'MacBook', 'platform': 'macos', 'bytes_up': _giB ~/ 2, 'bytes_down': _giB ~/ 2, 'minutes_used': 60},
],
};
@@ -90,22 +85,27 @@ MockClient _statsMockClient() => MockClient((req) async {
}
});
/// 排空 token 加载 → provider 取数(MockClient 异步)的链路。
Future<void> _drain(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 20));
}
}
Finder _metricCard(String label, String value) => find.byWidgetPredicate(
(w) => w is MetricCard && w.label == label && w.value == value,
/// 命中某周期卡的第 i 个指标(数值+单位)。
Finder _metric(String period, int i, String value, String unit) => find.byWidgetPredicate(
(w) =>
w is PeriodCard &&
w.period == period &&
w.metrics.length > i &&
w.metrics[i].value == value &&
w.metrics[i].unit == unit,
);
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
testWidgets('统计页:真实响应经解析后,指标卡/周柱/分设备数值上屏正确', (tester) async {
testWidgets('统计页:真实响应经解析后,月/周/日 周期卡 下行/上行/时长 上屏正确', (tester) async {
await tester.binding.setSurfaceSize(const Size(440, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
@@ -118,11 +118,9 @@ void main() {
refresh: () async => false,
client: _statsMockClient(),
)),
// 生效节点 ping=42;未连接 → 延迟取节点 ping。
effectiveNodeProvider.overrideWithValue(
const Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 42, uuid: 'hk-01'),
),
// 假桥:让 connection/vpnStats provider 不触原生(且不自动连接 → 未连接态)。
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
],
child: MaterialApp(
@@ -133,26 +131,15 @@ void main() {
));
await _drain(tester);
// ── 指标卡:本月流量 ΣgbTotal=3.0、本月时长 Σmin/60=2.0、延迟=节点 ping 42 ──
expect(_metricCard(t.trafficMonth, '3.0'), findsOneWidget, reason: '本月流量 = 2.0+1.0 GB');
expect(_metricCard(t.durMonth, '2.0'), findsOneWidget, reason: '本月时长 = 120min/60');
expect(_metricCard(t.avgPing, '42'), findsOneWidget, reason: '未连接 → 延迟取节点 ping');
// 本月/本周(仅 2 天,聚合相同):下行 Σ=1.5GB、上行 Σ=1.5GB、时长 120min/60=2.0h。
expect(_metric(t.periodMonth, 0, '1.5', 'GB'), findsOneWidget, reason: '本月下行 = 1.0+0.5 GiB');
expect(_metric(t.periodMonth, 1, '1.5', 'GB'), findsOneWidget, reason: '本月上行');
expect(_metric(t.periodMonth, 2, '2.0', 'h'), findsOneWidget, reason: '本月时长 = 120min/60');
expect(_metric(t.periodWeek, 0, '1.5', 'GB'), findsOneWidget, reason: '本周下行(仅 2 天=本月)');
// ── 周柱:7 根来自 me.weekly_gb,取两个唯一值核对(普通 Text 可 find.text)──
expect(find.text('0.1'), findsOneWidget, reason: 'weekly_gb[0]');
expect(find.text('1.5'), findsOneWidget, reason: 'weekly_gb[6]');
// ── 分设备:2 行,每行流量/时长正确 ──
expect(find.byType(DeviceStatRow), findsNWidgets(2));
expect(
find.byWidgetPredicate((w) =>
w is DeviceStatRow && w.name == 'iPhone' && w.gbText == '2.0' && w.hoursText == '2.0'),
findsOneWidget,
);
expect(
find.byWidgetPredicate((w) =>
w is DeviceStatRow && w.name == 'MacBook' && w.gbText == '1.0' && w.hoursText == '1.0'),
findsOneWidget,
);
// 今日(末天 25日):下行 0.5GiB=512MB、时长 30min/60=0.5h。
expect(_metric(t.periodToday, 0, '512.0', 'MB'), findsOneWidget, reason: '今日下行 = 0.5 GiB → MB');
expect(_metric(t.periodToday, 1, '512.0', 'MB'), findsOneWidget, reason: '今日上行');
expect(_metric(t.periodToday, 2, '0.5', 'h'), findsOneWidget, reason: '今日时长 = 30min/60');
});
}