diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 124d6e4..3c09dbe 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -62,6 +62,17 @@ abstract class AppText { String get durMonth; String get weekTraffic; List 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; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 604b87a..b59a3b2 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -80,6 +80,26 @@ class StringsEn extends AppText { String get weekTraffic => 'This week (GB)'; @override List 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'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 0176beb..7318413 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -79,6 +79,26 @@ class StringsZh extends AppText { String get weekTraffic => '本周流量 (GB)'; @override List 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 => '我的'; diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index 3b78873..fbace62 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -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 []; - 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.filled(7, 0.0); - final maxV = weekly.fold(0, (a, b) => a > b ? a : b); - final denom = maxV <= 0 ? 1.0 : maxV; - final monthGb = usage.fold(0, (s, p) => s + p.gbTotal); - final monthHours = usage.fold(0, (s, p) => s + p.minutesUsed) / 60.0; - // desktop 对照 dapp.jsx DStats:padding 32/8、卡 gap 14、柱 height 120·bar×86·宽 34。 - final pad = isWide ? 32.0 : 20.0; + List 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 = [ + 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( + 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))], - )), - ]), ); } } diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index a1c1887..2c92515 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -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>((ref) async { }); /// 最近 N 天用量(GET /v1/usage?days=N)。未登录返回空。 -final usageProvider = FutureProvider.family, int>((ref, days) async { +/// autoDispose:离开统计页即销毁,重进自动重取(配合下拉刷新/连接态变化 invalidate), +/// 根治「本月流量取一次就冻住」。后端每 60s 才更新,无需更勤刷新。 +final usageProvider = FutureProvider.autoDispose.family, 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, 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> { @override diff --git a/client/lib/widgets/period_card.dart b/client/lib/widgets/period_card.dart new file mode 100644 index 0000000..c169935 --- /dev/null +++ b/client/lib/widgets/period_card.dart @@ -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 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)), + ]), + ), + ), + ]), + ); + } +} diff --git a/client/lib/widgets/usage_line_chart.dart b/client/lib/widgets/usage_line_chart.dart new file mode 100644 index 0000000..5cc1817 --- /dev/null +++ b/client/lib/widgets/usage_line_chart.dart @@ -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 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 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; +} diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index eeeca88..f4c839e 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 7e58cbf..24ea0cc 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 35e3135..6af0f10 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 bdb3a6d..ebb69d3 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 diff --git a/client/test/widget/stats_page_test.dart b/client/test/widget/stats_page_test.dart index efc43bd..ac95eb1 100644 --- a/client/test/widget/stats_page_test.dart +++ b/client/test/widget/stats_page_test.dart @@ -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 loadLastEmail() async => null; } -// 真实形态后端响应(字段面对照 api_contract_test.dart 的冻结样本)。 final Map _meJson = { 'email': 'u@test.local', 'plan': 'free', @@ -61,7 +58,7 @@ final Map _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 _usageJson = { 'points': [ {'date': '2026-06-24', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 90}, @@ -69,11 +66,9 @@ final Map _usageJson = { ], }; -// 两台设备:iPhone 2.0GB/2.0h、MacBook 1.0GB/1.0h。 final Map _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 _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'); }); }