Files
pangolin/client/lib/screens/stats_page.dart
T
wangjia f0832ccfcb
ci-pangolin / Lint — shellcheck (push) Successful in 6s
ci-pangolin / OpenAPI Sync Check (push) Successful in 21s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 7s
ci-pangolin / Flutter — analyze + test (push) Successful in 23s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 9s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 18s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m52s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
fix(client): 修 pro 会员启动闪广告/统计闪 0 — 加载门覆盖 auth 加载窗口
之前 meLoadingProvider=isLoading&&!hasValue 漏了两段:启动时 auth 异步读 token
期间 isLoggedIn=false → me 同步返回免费默认(广告);token 到后 me 拉真实值时
hasValue 已是免费默认 → !hasValue 不成立、仍不转圈。改为:
- meLoadingProvider:auth.isLoading 时转圈;未登录不转;已登录看 me.isLoading
- 统计页 loading:auth.isLoading || usageProvider.isLoading
未登录(确定访客)/golden(settle 后)不转圈,golden 不受影响。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:12:24 +08:00

171 lines
6.7 KiB
Dart

// stats_page.dart — 统计页(月/周/日 周期卡 + 设备下拉 + 最近两周流量折线)。
// 单一数据源:近30天每日用量(usageProvider,autoDispose 进页重取),月/周/日 + 折线全从此聚合。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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);
final usageAsync = ref.watch(usageProvider(30));
final usage = usageAsync.valueOrNull ?? const <UsagePoint>[];
// 先拉数据再渲染(不闪默认 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<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),
];
}
// 折线:最后 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(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: 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');
}
/// 设备下拉(右上角):全部 + 近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 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),
]),
),
);
}
}