Compare commits
1 Commits
dev
...
f501a959f3
| Author | SHA1 | Date | |
|---|---|---|---|
| f501a959f3 |
@@ -0,0 +1,36 @@
|
||||
/// page_viewed 的 pageName 编译期枚举(06 号报告 §5.2 字典 v2 + 03 号评估
|
||||
/// §3.2 Tab 映射)。禁止自由路由字符串;带参数路由必须归一化,任何
|
||||
/// UUID/ID 不得出现在 pageName 或 referrer 中(隐私红线第 5 条)。
|
||||
///
|
||||
/// 尚不存在的 M2 页面(petList/petDetail/petForm/recordForm/recordDetail)
|
||||
/// 先留枚举定义、不接线,待健康档案页面族落地时启用。
|
||||
enum AnalyticsPageName {
|
||||
// —— 字典 v2 初始集合(06 §5.2 验收 2)——
|
||||
login('login'),
|
||||
register('register'),
|
||||
home('home'),
|
||||
profile('profile'),
|
||||
petList('pet_list'),
|
||||
petDetail('pet_detail'),
|
||||
petForm('pet_form'),
|
||||
recordForm('record_form'),
|
||||
recordDetail('record_detail'),
|
||||
// —— 客户端现存页面/Tab 补充(03 §3.2 Tab 映射,需数据侧同步进字典)——
|
||||
create('create'),
|
||||
petArchive('pet_archive'),
|
||||
services('services'),
|
||||
postDetail('post_detail');
|
||||
|
||||
const AnalyticsPageName(this.pageName);
|
||||
|
||||
/// 上报值,snake_case,与 RouteSettings.name 一致。
|
||||
final String pageName;
|
||||
|
||||
static final Map<String, AnalyticsPageName> _byName = {
|
||||
for (final page in values) page.pageName: page,
|
||||
};
|
||||
|
||||
/// 路由名 → 枚举;不在枚举内返回 null(调用方不上报,06 §5.2 验收 4)。
|
||||
static AnalyticsPageName? fromRouteName(String? name) =>
|
||||
name == null ? null : _byName[name];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
|
||||
/// 集中式 page_viewed 路由埋点(03 号评估 §3.2:NavigatorObserver 派生,
|
||||
/// 页面零侵入;06 号报告 §5.2 验收 1/4)。
|
||||
///
|
||||
/// 只统计 [PageRoute](bottom sheet / dialog 是 PopupRoute,不计曝光);
|
||||
/// 路由名不在 [AnalyticsPageName] 枚举内不上报(后端会整条 rejected,
|
||||
/// 不白白消耗队列)。didPop 补报「回退后重新曝光的前一页」;弹回到
|
||||
/// 无名根路由(认证状态机的 AnimatedSwitcher 宿主)时经 [resolveRootPage]
|
||||
/// 解析当前根部页面。
|
||||
class AnalyticsRouteObserver extends NavigatorObserver {
|
||||
AnalyticsRouteObserver({required this.tracker, this.resolveRootPage});
|
||||
|
||||
final PageViewTracker tracker;
|
||||
|
||||
/// 根路由(无 RouteSettings.name)当前展示页的解析器,由 app.dart
|
||||
/// 按认证状态与主壳当前 Tab 提供;返回 null 则不补报。
|
||||
final AnalyticsPageName? Function()? resolveRootPage;
|
||||
|
||||
void _reportRoute(Route<dynamic>? route) {
|
||||
if (route is! PageRoute) return;
|
||||
final page = AnalyticsPageName.fromRouteName(route.settings.name);
|
||||
if (page != null) tracker.report(page);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
_reportRoute(route);
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
_reportRoute(newRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
if (previousRoute is! PageRoute) return;
|
||||
final page = AnalyticsPageName.fromRouteName(previousRoute.settings.name);
|
||||
if (page != null) {
|
||||
tracker.report(page);
|
||||
} else if (previousRoute.isFirst) {
|
||||
final rootPage = resolveRootPage?.call();
|
||||
if (rootPage != null) tracker.report(rootPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,58 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Simplified analytics client for M0 (report 13 + ticket 19): track events
|
||||
/// to backend POST /api/v1/events. Queue failures locally (shared_preferences,
|
||||
/// max 500), flush on successful upload or when full. Network errors are
|
||||
/// silently discarded (no retry as spec'd); privacy red-line enforced locally.
|
||||
/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events
|
||||
/// to backend POST /api/v1/events. In-memory queue flushed every 20 events;
|
||||
/// failed batches are re-queued (capped at 500, oldest dropped). Persistent
|
||||
/// segmented queue lands in M2 wave 2. Privacy red-line enforced locally.
|
||||
class AnalyticsService {
|
||||
AnalyticsService({
|
||||
required this.apiBaseUrl,
|
||||
required this.getAccessToken,
|
||||
required this.getSessionId,
|
||||
String? anonymousId,
|
||||
this._userId,
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4();
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
||||
_appVersion = 'unknown',
|
||||
_osVersion = _defaultOsVersion();
|
||||
|
||||
// M0:内存队列,满 _flushThreshold 条上传一次;持久化队列留 M1(报告 19 §3)。
|
||||
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||||
void setAppVersion(String version) {
|
||||
_appVersion = version;
|
||||
}
|
||||
|
||||
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波。
|
||||
static const _flushThreshold = 20;
|
||||
|
||||
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧。
|
||||
static const _maxQueuedEvents = 500;
|
||||
|
||||
final String apiBaseUrl;
|
||||
final String Function()? getAccessToken;
|
||||
final String? Function()? getAccessToken;
|
||||
|
||||
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||||
final String Function() getSessionId;
|
||||
|
||||
final String _anonymousId;
|
||||
String _appVersion;
|
||||
final String _osVersion;
|
||||
String? _userId;
|
||||
bool _flushing = false;
|
||||
final List<Map<String, dynamic>> _pendingEvents = [];
|
||||
|
||||
/// Sets userId after login (M0: no sessionId logic, simplified).
|
||||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||||
@visibleForTesting
|
||||
List<Map<String, dynamic>> get pendingEvents =>
|
||||
List.unmodifiable(_pendingEvents);
|
||||
|
||||
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
||||
static String _defaultOsVersion() {
|
||||
final major = RegExp(
|
||||
r'\d+',
|
||||
).firstMatch(Platform.operatingSystemVersion)?.group(0);
|
||||
return '${Platform.operatingSystem}-${major ?? 'unknown'}';
|
||||
}
|
||||
|
||||
/// Sets userId after login/restore.
|
||||
void identify(String userId) {
|
||||
_userId = userId;
|
||||
}
|
||||
@@ -47,18 +77,16 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
final event = {
|
||||
'eventId': const Uuid().v4(),
|
||||
'eventId': const Uuid().v7(),
|
||||
'eventName': eventName,
|
||||
'eventVersion': 1,
|
||||
'anonymousId': _anonymousId,
|
||||
if (_userId != null) 'userId': _userId,
|
||||
'sessionId': const Uuid().v4(), // Simplified: unique per event (M0)
|
||||
'sessionId': getSessionId(),
|
||||
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
||||
'appVersion': '1.0.0+1', // TODO: read from package_info_plus
|
||||
'appVersion': _appVersion,
|
||||
'platform': Platform.isAndroid ? 'android' : 'ios',
|
||||
'osVersion': Platform.isAndroid
|
||||
? 'android-14'
|
||||
: 'ios-17', // TODO: device_info_plus
|
||||
'osVersion': _osVersion,
|
||||
if (props != null && props.isNotEmpty) 'props': props,
|
||||
};
|
||||
|
||||
@@ -72,7 +100,8 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_pendingEvents.isEmpty) return;
|
||||
if (_flushing || _pendingEvents.isEmpty) return;
|
||||
_flushing = true;
|
||||
|
||||
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
|
||||
_pendingEvents.clear();
|
||||
@@ -80,7 +109,16 @@ class AnalyticsService {
|
||||
try {
|
||||
await _upload(batch);
|
||||
} catch (error) {
|
||||
debugPrint('Analytics upload failed, discarding batch: $error');
|
||||
// 顺手加固(03 §1.4 #1 的一行级缓解):失败不再整批丢弃,
|
||||
// 重回队首等下次冲刷;上限 500 条,超限丢最旧。真正的
|
||||
// shared_preferences 分段持久化队列属 M2 第二波。
|
||||
debugPrint('Analytics upload failed, requeueing batch: $error');
|
||||
_pendingEvents.insertAll(0, batch);
|
||||
if (_pendingEvents.length > _maxQueuedEvents) {
|
||||
_pendingEvents.removeRange(0, _pendingEvents.length - _maxQueuedEvents);
|
||||
}
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
|
||||
/// trackEvent 形状的回调(生产传 `AnalyticsService.trackEvent`,测试传录制桩)。
|
||||
typedef TrackEventFn =
|
||||
Future<void> Function(String eventName, [Map<String, dynamic>? props]);
|
||||
|
||||
/// page_viewed 上报的单一出口:维护 referrer 链与去重。
|
||||
///
|
||||
/// 路由事件(AnalyticsRouteObserver)、主壳 Tab 切换、认证状态机切页
|
||||
/// 三类曝光都经此上报,referrer(前一页 pageName)才能跨机制连贯;
|
||||
/// 栈底/冷启动首页 referrer 为 null(06 §5.2 验收 3)。
|
||||
class PageViewTracker {
|
||||
PageViewTracker(this._track);
|
||||
|
||||
final TrackEventFn _track;
|
||||
|
||||
String? _lastPageName;
|
||||
AnalyticsPageName? _currentTab;
|
||||
|
||||
/// 主壳当前 Tab(供回栈补报解析根路由曝光页)。
|
||||
AnalyticsPageName? get currentTab => _currentTab;
|
||||
|
||||
/// 上报一次页面曝光;与上一条相同的页面去重(Tab 重复点选等)。
|
||||
void report(AnalyticsPageName page) {
|
||||
if (page.pageName == _lastPageName) return;
|
||||
final referrer = _lastPageName;
|
||||
_lastPageName = page.pageName;
|
||||
final props = <String, dynamic>{'pageName': page.pageName};
|
||||
if (referrer != null) props['referrer'] = referrer;
|
||||
_track('page_viewed', props);
|
||||
}
|
||||
|
||||
/// 主壳 Tab 曝光:记录当前 Tab 后上报(IndexedStack 无路由事件,手动补点)。
|
||||
void reportTab(AnalyticsPageName page) {
|
||||
_currentTab = page;
|
||||
report(page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// sessionId 生命周期管理(13 号规范 §3.1、06 号报告 §5.1)。
|
||||
///
|
||||
/// 语义三条:冷启动生成新 sessionId;`paused → resumed` 间隔超过
|
||||
/// [timeout](默认 30 分钟)生成新 sessionId;未超过则沿用原值。
|
||||
/// sessionId 不落任何持久化存储——会话本该跨冷启动失效(03 号评估 §3.1,
|
||||
/// 纯内存方案,lastActiveAt 不持久化)。
|
||||
///
|
||||
/// 注意生命周期级联:前台恢复时状态机会依次经过
|
||||
/// `paused → hidden → inactive → resumed`,因此只在**离开 resumed 的第一次
|
||||
/// 变更**记录退后台时刻,后续级联状态不得覆盖,否则间隔永远趋近于零。
|
||||
class SessionTracker with WidgetsBindingObserver {
|
||||
SessionTracker({
|
||||
this.timeout = const Duration(minutes: 30),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_sessionId = const Uuid().v7();
|
||||
|
||||
/// 后台超时阈值;构造参数化便于测试注入。
|
||||
final Duration timeout;
|
||||
|
||||
/// 时钟注入口,测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
String _sessionId;
|
||||
DateTime? _leftForegroundAt;
|
||||
AppLifecycleState _lastState = AppLifecycleState.resumed;
|
||||
|
||||
/// 当前会话标识(UUIDv7)。
|
||||
String get sessionId => _sessionId;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final leftAt = _leftForegroundAt;
|
||||
if (leftAt != null && _now().difference(leftAt) > timeout) {
|
||||
_sessionId = const Uuid().v7();
|
||||
}
|
||||
_leftForegroundAt = null;
|
||||
} else if (_lastState == AppLifecycleState.resumed) {
|
||||
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
|
||||
_leftForegroundAt = _now();
|
||||
}
|
||||
_lastState = state;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
import 'package:patbond_flutter/analytics/session_tracker.dart';
|
||||
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
@@ -24,6 +30,10 @@ class _AppState extends State<App> {
|
||||
late final AppState appState;
|
||||
late final SessionManager sessionManager;
|
||||
late final AuthRepository authRepository;
|
||||
late final SessionTracker _sessionTracker;
|
||||
late final AnalyticsService _analytics;
|
||||
late final PageViewTracker _pageViewTracker;
|
||||
late final AnalyticsRouteObserver _routeObserver;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,7 +42,37 @@ class _AppState extends State<App> {
|
||||
sessionManager =
|
||||
widget.sessionManager ??
|
||||
SessionManager(store: const SecureTokenStore());
|
||||
|
||||
_sessionTracker = SessionTracker();
|
||||
WidgetsBinding.instance.addObserver(_sessionTracker);
|
||||
|
||||
_analytics = AnalyticsService(
|
||||
apiBaseUrl: patbondApiBaseUrl,
|
||||
getAccessToken: () => sessionManager.accessToken,
|
||||
getSessionId: () => _sessionTracker.sessionId,
|
||||
);
|
||||
_initAppVersion();
|
||||
|
||||
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
||||
_routeObserver = AnalyticsRouteObserver(
|
||||
tracker: _pageViewTracker,
|
||||
resolveRootPage: _resolveRootPage,
|
||||
);
|
||||
|
||||
authRepository = widget.authRepository ?? _buildRepository();
|
||||
|
||||
// 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件)
|
||||
sessionManager.addListener(_reportAuthStateChange);
|
||||
}
|
||||
|
||||
/// 异步读取 appVersion 并注入 analytics(启动后台任务,不阻塞渲染)。
|
||||
Future<void> _initAppVersion() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_analytics.setAppVersion('${info.version}+${info.buildNumber}');
|
||||
} catch (error) {
|
||||
// 读取失败回退 'unknown'(AnalyticsService 构造默认值已兜底)。
|
||||
}
|
||||
}
|
||||
|
||||
AuthRepository _buildRepository() {
|
||||
@@ -47,11 +87,33 @@ class _AppState extends State<App> {
|
||||
api: api,
|
||||
session: sessionManager,
|
||||
refresher: refresher,
|
||||
analytics: _analytics,
|
||||
);
|
||||
}
|
||||
|
||||
AnalyticsPageName? _resolveRootPage() {
|
||||
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||
return switch (sessionManager.status) {
|
||||
AuthStatus.unknown => null,
|
||||
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
||||
AuthStatus.authenticated => _pageViewTracker.currentTab,
|
||||
};
|
||||
}
|
||||
|
||||
void _reportAuthStateChange() {
|
||||
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||
final page = switch (sessionManager.status) {
|
||||
AuthStatus.unauthenticated => AnalyticsPageName.login,
|
||||
AuthStatus.authenticated => _pageViewTracker.currentTab,
|
||||
AuthStatus.unknown => null,
|
||||
};
|
||||
if (page != null) _pageViewTracker.report(page);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sessionManager.removeListener(_reportAuthStateChange);
|
||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||
appState.dispose();
|
||||
if (widget.sessionManager == null) sessionManager.dispose();
|
||||
super.dispose();
|
||||
@@ -74,6 +136,7 @@ class _AppState extends State<App> {
|
||||
return MainShellPage(
|
||||
key: const ValueKey('shell'),
|
||||
appState: appState,
|
||||
pageViewTracker: _pageViewTracker,
|
||||
onLogout: authRepository.logout,
|
||||
);
|
||||
}
|
||||
@@ -85,6 +148,7 @@ class _AppState extends State<App> {
|
||||
title: 'Patbond',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
navigatorObservers: [_routeObserver],
|
||||
home: ListenableBuilder(
|
||||
listenable: sessionManager,
|
||||
builder: (context, _) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。
|
||||
Route<T> fadePageRoute<T>(Widget page) {
|
||||
///
|
||||
/// [settings] 从 M2 起必传路由名(page_viewed 取数来源,03 号评估 §2.1),
|
||||
/// 名字用 `AnalyticsPageName` 枚举的 pageName 值。
|
||||
Route<T> fadePageRoute<T>(Widget page, {RouteSettings? settings}) {
|
||||
return PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/core/navigation/fade_route.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
@@ -130,7 +131,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
void _goRegister() {
|
||||
Navigator.of(context).push(
|
||||
fadePageRoute<void>(RegisterPage(authRepository: widget.authRepository)),
|
||||
fadePageRoute<void>(
|
||||
RegisterPage(authRepository: widget.authRepository),
|
||||
settings: RouteSettings(name: AnalyticsPageName.register.pageName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||
import 'package:patbond_flutter/features/home/home_page.dart';
|
||||
@@ -11,10 +13,18 @@ import 'package:patbond_flutter/state/app_state.dart';
|
||||
import 'package:patbond_flutter/widgets/common.dart';
|
||||
|
||||
class MainShellPage extends StatefulWidget {
|
||||
const MainShellPage({required this.appState, super.key, this.onLogout});
|
||||
const MainShellPage({
|
||||
required this.appState,
|
||||
super.key,
|
||||
this.pageViewTracker,
|
||||
this.onLogout,
|
||||
});
|
||||
|
||||
final AppState appState;
|
||||
|
||||
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
||||
final PageViewTracker? pageViewTracker;
|
||||
|
||||
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
|
||||
final Future<void> Function()? onLogout;
|
||||
|
||||
@@ -28,8 +38,25 @@ class _MainShellPageState extends State<MainShellPage> {
|
||||
|
||||
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
||||
|
||||
/// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表)。
|
||||
static const _tabPages = [
|
||||
AnalyticsPageName.home,
|
||||
AnalyticsPageName.create,
|
||||
AnalyticsPageName.petArchive,
|
||||
AnalyticsPageName.services,
|
||||
AnalyticsPageName.profile,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 初始 Tab 曝光补一次(03 号评估 §3.2 手动补点 1)。
|
||||
widget.pageViewTracker?.reportTab(_tabPages[currentIndex]);
|
||||
}
|
||||
|
||||
void selectTab(int index) {
|
||||
setState(() => currentIndex = index);
|
||||
widget.pageViewTracker?.reportTab(_tabPages[index]);
|
||||
}
|
||||
|
||||
void openServices(bool personal) {
|
||||
@@ -37,11 +64,13 @@ class _MainShellPageState extends State<MainShellPage> {
|
||||
showPersonalServices = personal;
|
||||
currentIndex = 3;
|
||||
});
|
||||
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
||||
}
|
||||
|
||||
void openPost(PostModel post) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
||||
builder: (context) =>
|
||||
PostDetailPage(appState: widget.appState, postId: post.id),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,11 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_darwin
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
@@ -208,6 +208,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,6 +336,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
dio: ^5.11.1
|
||||
flutter_secure_storage: ^11.0.0
|
||||
uuid: ^4.6.0
|
||||
package_info_plus: ^10.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_page_name.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_route_observer.dart';
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
|
||||
void main() {
|
||||
group('AnalyticsRouteObserver', () {
|
||||
testWidgets('push 路由报 page_viewed', (tester) async {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [observer],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(
|
||||
name: AnalyticsPageName.login.pageName,
|
||||
),
|
||||
builder: (_) => const Placeholder(),
|
||||
),
|
||||
),
|
||||
child: const Text('Go'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Go'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(events, hasLength(1));
|
||||
expect(events.first['event'], 'page_viewed');
|
||||
expect(events.first['pageName'], 'login');
|
||||
});
|
||||
|
||||
testWidgets('push 两页 referrer 链正确,pop 返回补报前一页', (tester) async {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [observer],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(
|
||||
name: AnalyticsPageName.login.pageName,
|
||||
),
|
||||
builder: (loginContext) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(loginContext).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(
|
||||
name: AnalyticsPageName.register.pageName,
|
||||
),
|
||||
builder: (registerContext) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(registerContext).pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Next'),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Go'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Go'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// push 两页:两条事件,referrer 链正确(06 §5.2 验收 5)。
|
||||
expect(events.map((e) => e['pageName']), ['login', 'register']);
|
||||
expect(events[0].containsKey('referrer'), isFalse);
|
||||
expect(events[1]['referrer'], 'login');
|
||||
|
||||
// pop 返回:didPop 补报重新曝光的前一页,referrer 为弹出页。
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(events, hasLength(3));
|
||||
expect(events[2]['pageName'], 'login');
|
||||
expect(events[2]['referrer'], 'register');
|
||||
});
|
||||
|
||||
testWidgets('pop 回无名根路由经 resolveRootPage 补报', (tester) async {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
final observer = AnalyticsRouteObserver(
|
||||
tracker: tracker,
|
||||
resolveRootPage: () => AnalyticsPageName.home,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [observer],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: RouteSettings(
|
||||
name: AnalyticsPageName.profile.pageName,
|
||||
),
|
||||
builder: (innerContext) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(innerContext).pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Go'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Go'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(events.map((e) => e['pageName']), ['profile', 'home']);
|
||||
expect(events[1]['referrer'], 'profile');
|
||||
});
|
||||
|
||||
testWidgets('未在枚举的路由名不上报', (tester) async {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [observer],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
settings: const RouteSettings(name: '/debug/unknown'),
|
||||
builder: (_) => const Placeholder(),
|
||||
),
|
||||
),
|
||||
child: const Text('Go'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Go'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(events, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('dialog 不上报(PopupRoute 不是 PageRoute)', (tester) async {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
final observer = AnalyticsRouteObserver(tracker: tracker);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [observer],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const AlertDialog(title: Text('Dialog')),
|
||||
),
|
||||
child: const Text('Show'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(events, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('PageViewTracker', () {
|
||||
test('连续相同页面去重(Tab 重复点选)', () {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
|
||||
tracker.report(AnalyticsPageName.home);
|
||||
tracker.report(AnalyticsPageName.home);
|
||||
tracker.report(AnalyticsPageName.profile);
|
||||
tracker.report(AnalyticsPageName.home);
|
||||
|
||||
expect(events, hasLength(3));
|
||||
expect(events.map((e) => e['pageName']), ['home', 'profile', 'home']);
|
||||
});
|
||||
|
||||
test('referrer 链跨机制连贯(首页无 referrer)', () {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
|
||||
tracker.report(AnalyticsPageName.home);
|
||||
tracker.report(AnalyticsPageName.login);
|
||||
tracker.report(AnalyticsPageName.profile);
|
||||
|
||||
expect(events[0].containsKey('referrer'), isFalse);
|
||||
expect(events[1]['referrer'], 'home');
|
||||
expect(events[2]['referrer'], 'login');
|
||||
});
|
||||
|
||||
test('reportTab 记录当前 Tab', () {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
final tracker = PageViewTracker((name, [props]) async {
|
||||
events.add({'event': name, ...?props});
|
||||
});
|
||||
|
||||
tracker.reportTab(AnalyticsPageName.petArchive);
|
||||
expect(tracker.currentTab, AnalyticsPageName.petArchive);
|
||||
expect(events.single['pageName'], 'pet_archive');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,59 +2,125 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||
|
||||
void main() {
|
||||
final uuidV7 = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
AnalyticsService buildService({
|
||||
String Function()? getSessionId,
|
||||
String? anonymousId,
|
||||
}) {
|
||||
var counter = 0;
|
||||
return AnalyticsService(
|
||||
apiBaseUrl: 'http://test',
|
||||
getAccessToken: null,
|
||||
getSessionId: getSessionId ?? () => 'session-${counter++}',
|
||||
anonymousId: anonymousId,
|
||||
);
|
||||
}
|
||||
|
||||
group('AnalyticsService', () {
|
||||
test('tracks event with required fields', () async {
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://test',
|
||||
getAccessToken: null,
|
||||
);
|
||||
final service = buildService(getSessionId: () => 'session-a');
|
||||
|
||||
// Never throws, never awaits network (fire-and-forget).
|
||||
expect(
|
||||
() => service.trackEvent('auth_login_succeeded', {
|
||||
'identifierType': 'username',
|
||||
'durationMs': 123,
|
||||
}),
|
||||
returnsNormally,
|
||||
);
|
||||
await service.trackEvent('auth_login_succeeded', {
|
||||
'identifierType': 'username',
|
||||
'durationMs': 123,
|
||||
});
|
||||
|
||||
final event = service.pendingEvents.single;
|
||||
expect(event['eventName'], 'auth_login_succeeded');
|
||||
expect(event['eventVersion'], 1);
|
||||
expect(event['sessionId'], 'session-a');
|
||||
expect(event['anonymousId'], isNotEmpty);
|
||||
expect(event['clientTs'], isNotEmpty);
|
||||
expect(event['appVersion'], isNotEmpty);
|
||||
expect(event['osVersion'], isNotEmpty);
|
||||
expect(event['props'], {'identifierType': 'username', 'durationMs': 123});
|
||||
});
|
||||
|
||||
test('eventId 为 UUIDv7 且逐事件唯一', () async {
|
||||
final service = buildService();
|
||||
|
||||
await service.trackEvent('auth_login_succeeded');
|
||||
await service.trackEvent('auth_logout');
|
||||
|
||||
final ids = service.pendingEvents
|
||||
.map((event) => event['eventId'] as String)
|
||||
.toList();
|
||||
expect(ids[0], matches(uuidV7));
|
||||
expect(ids[1], matches(uuidV7));
|
||||
expect(ids[0], isNot(ids[1]));
|
||||
});
|
||||
|
||||
test('同一 tracker 下多事件 sessionId 相同,不再每事件生成', () async {
|
||||
final service = buildService(getSessionId: () => 'tracker-session');
|
||||
|
||||
await service.trackEvent('auth_login_succeeded');
|
||||
await service.trackEvent('page_viewed', {'pageName': 'home'});
|
||||
await service.trackEvent('auth_logout');
|
||||
|
||||
final sessionIds = service.pendingEvents
|
||||
.map((event) => event['sessionId'])
|
||||
.toSet();
|
||||
expect(sessionIds, {'tracker-session'});
|
||||
});
|
||||
|
||||
test('appVersion 可注入更新(不再硬编码)', () async {
|
||||
final service = buildService();
|
||||
|
||||
service.setAppVersion('2.3.4+56');
|
||||
await service.trackEvent('auth_login_succeeded');
|
||||
|
||||
expect(service.pendingEvents.single['appVersion'], '2.3.4+56');
|
||||
});
|
||||
|
||||
test('rejects events with forbidden field patterns', () async {
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://test',
|
||||
getAccessToken: null,
|
||||
);
|
||||
final service = buildService();
|
||||
|
||||
// Forbidden field triggers local rejection (no throw, silent drop).
|
||||
await service.trackEvent('auth_login_succeeded', {
|
||||
'userPassword': 'leak', // Forbidden pattern
|
||||
});
|
||||
|
||||
// Event should be dropped (no assertion — just verify no crash).
|
||||
expect(service.pendingEvents, isEmpty);
|
||||
});
|
||||
|
||||
test('identify sets userId', () {
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://test',
|
||||
getAccessToken: null,
|
||||
);
|
||||
test('identify sets userId on subsequent events', () async {
|
||||
final service = buildService();
|
||||
|
||||
service.identify('user-123');
|
||||
await service.trackEvent('auth_login_succeeded');
|
||||
|
||||
// Subsequent events will carry userId (validated in integration tests).
|
||||
expect(service.pendingEvents.single['userId'], 'user-123');
|
||||
});
|
||||
|
||||
test('reset clears userId but keeps anonymousId', () {
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://test',
|
||||
getAccessToken: null,
|
||||
anonymousId: 'anon-123',
|
||||
);
|
||||
test('reset clears userId but keeps anonymousId', () async {
|
||||
final service = buildService(anonymousId: 'anon-123');
|
||||
|
||||
service.identify('user-123');
|
||||
service.reset();
|
||||
await service.trackEvent('auth_logout');
|
||||
|
||||
// userId cleared, anonymousId retained (validated in integration tests).
|
||||
final event = service.pendingEvents.single;
|
||||
expect(event.containsKey('userId'), isFalse);
|
||||
expect(event['anonymousId'], 'anon-123');
|
||||
});
|
||||
|
||||
test('上传失败批次重回队列而非整批丢弃', () async {
|
||||
// apiBaseUrl 指向不可达端口:满 20 条触发 flush 必然失败。
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://127.0.0.1:1',
|
||||
getAccessToken: null,
|
||||
getSessionId: () => 's',
|
||||
);
|
||||
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||||
}
|
||||
|
||||
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
|
||||
expect(service.pendingEvents.length, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/analytics/session_tracker.dart';
|
||||
|
||||
void main() {
|
||||
final uuidV7 = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
group('SessionTracker', () {
|
||||
test('冷启动生成 UUIDv7 格式的 sessionId', () {
|
||||
final tracker = SessionTracker();
|
||||
expect(tracker.sessionId, matches(uuidV7));
|
||||
});
|
||||
|
||||
test('短后台(≤30 分钟)resume 后沿用原 sessionId', () {
|
||||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||
final tracker = SessionTracker(now: () => clock);
|
||||
final original = tracker.sessionId;
|
||||
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
clock = clock.add(const Duration(minutes: 30));
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(tracker.sessionId, original);
|
||||
});
|
||||
|
||||
test('长后台(>30 分钟)resume 后生成新 sessionId', () {
|
||||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||
final tracker = SessionTracker(now: () => clock);
|
||||
final original = tracker.sessionId;
|
||||
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
clock = clock.add(const Duration(minutes: 31));
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(tracker.sessionId, isNot(original));
|
||||
expect(tracker.sessionId, matches(uuidV7));
|
||||
});
|
||||
|
||||
test('真实生命周期级联下退后台时刻不被 inactive 覆盖', () {
|
||||
// 前台恢复时状态机级联 paused → hidden → inactive → resumed,
|
||||
// 若 inactive 覆盖记时,31 分钟长后台会被误判为 0 分钟。
|
||||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||
final tracker = SessionTracker(now: () => clock);
|
||||
final original = tracker.sessionId;
|
||||
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
clock = clock.add(const Duration(minutes: 31));
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(tracker.sessionId, isNot(original));
|
||||
});
|
||||
|
||||
test('连续多次短后台幂等,仅超阈值才换新', () {
|
||||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||||
final tracker = SessionTracker(now: () => clock);
|
||||
final original = tracker.sessionId;
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
clock = clock.add(const Duration(minutes: 10));
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
expect(tracker.sessionId, original);
|
||||
}
|
||||
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
clock = clock.add(const Duration(minutes: 35));
|
||||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
expect(tracker.sessionId, isNot(original));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user