// stats_page.dart — 统计页(月/周/日 周期卡 + 设备下拉 + 最近两周流量折线)。 // 单一数据源:近30天每日用量(usageProvider,autoDispose 进页重取),月/周/日 + 折线全从此聚合。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../models/device.dart'; import '../models/usage_point.dart'; import '../pangolin_theme.dart'; import '../state/account_providers.dart'; import '../state/app_providers.dart'; import '../state/auth_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}); final bool isWide; @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final t = ref.watch(appTextProvider); // 选中设备(#10②):null=全部=账户级;否则只算该设备的本地日曲线。 final selDevice = ref.watch(statsDeviceProvider); final usageKey = (days: 30, device: selDevice); final usageAsync = ref.watch(usageProvider(usageKey)); final usage = usageAsync.valueOrNull ?? const []; // 先拉数据再渲染(不闪默认 0):auth 还在读 token,或已登录但 usage 还在拉 → 整页转圈。 // (auth 加载期 isLoggedIn=false → usage 会同步返回空,故必须并上 auth.isLoading。) final loading = ref.watch(authProvider).isLoading || usageAsync.isLoading; // 周期聚合:取最后 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); } 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), ]; } // 折线:最后 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 = loading ? Center(child: CircularProgressIndicator(color: c.accent, strokeWidth: 2.6)) : RefreshIndicator( color: c.accent, backgroundColor: c.surface, onRefresh: () async { ref.invalidate(usageProvider(usageKey)); 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(t: t), ]), 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: chartVals, dates: chartDates, accent: c.accent, grid: c.border, axis: c.fg3, surface: c.surface, ), ]), ), ], ), ); if (isWide) return body; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ AppTopBar(brand: t.brand), PageTitle(title: t.statsTitle), Expanded(child: body), ]); } } /// 字节 → (数值, 单位):<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'); } /// 统计页选中的设备(null = 全部;否则 device uuid)。第②层(按设备过滤卡/图) /// 待 per-device 每日曲线 API(#10);本层先把下拉内容接成真实设备。 final statsDeviceProvider = StateProvider((ref) => null); /// 设备下拉(右上角):「全部」+ 用户**已登录的真实设备**(来自 devicesProvider, /// 即 GET /v1/me/devices)。选某台 → 卡片/折线只算那台(#10②)。 class _DeviceDropdown extends ConsumerWidget { const _DeviceDropdown({required this.t}); final AppText t; @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final devices = ref.watch(devicesProvider).valueOrNull ?? const []; final selected = ref.watch(statsDeviceProvider); String nameOf(String uuid) { final d = devices.where((e) => e.uuid == uuid); return d.isEmpty ? '' : (d.first.name.isNotEmpty ? d.first.name : d.first.platform); } final label = selected == null ? t.allDevices : (nameOf(selected).isNotEmpty ? nameOf(selected) : t.allDevices); return PopupMenuButton( tooltip: '', offset: const Offset(0, 38), color: c.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(PangolinRadius.md), side: BorderSide(color: c.border), ), onSelected: (v) => ref.read(statsDeviceProvider.notifier).state = v.isEmpty ? null : v, itemBuilder: (_) => [ PopupMenuItem(value: '', child: Text(t.allDevices, style: PangolinText.sm.copyWith(color: c.fg1))), for (final d in devices) PopupMenuItem( value: d.uuid, child: Text(d.name.isNotEmpty ? d.name : d.platform, 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), ]), ), ); } }