Compare commits
1 Commits
6fef0db009
...
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:flutter/foundation.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Simplified analytics client for M0 (report 13 + ticket 19): track events
|
/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events
|
||||||
/// to backend POST /api/v1/events. Queue failures locally (shared_preferences,
|
/// to backend POST /api/v1/events. In-memory queue flushed every 20 events;
|
||||||
/// max 500), flush on successful upload or when full. Network errors are
|
/// failed batches are re-queued (capped at 500, oldest dropped). Persistent
|
||||||
/// silently discarded (no retry as spec'd); privacy red-line enforced locally.
|
/// segmented queue lands in M2 wave 2. Privacy red-line enforced locally.
|
||||||
class AnalyticsService {
|
class AnalyticsService {
|
||||||
AnalyticsService({
|
AnalyticsService({
|
||||||
required this.apiBaseUrl,
|
required this.apiBaseUrl,
|
||||||
required this.getAccessToken,
|
required this.getAccessToken,
|
||||||
|
required this.getSessionId,
|
||||||
String? anonymousId,
|
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;
|
static const _flushThreshold = 20;
|
||||||
|
|
||||||
|
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧。
|
||||||
|
static const _maxQueuedEvents = 500;
|
||||||
|
|
||||||
final String apiBaseUrl;
|
final String apiBaseUrl;
|
||||||
final String Function()? getAccessToken;
|
final String? Function()? getAccessToken;
|
||||||
|
|
||||||
|
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||||||
|
final String Function() getSessionId;
|
||||||
|
|
||||||
final String _anonymousId;
|
final String _anonymousId;
|
||||||
|
String _appVersion;
|
||||||
|
final String _osVersion;
|
||||||
String? _userId;
|
String? _userId;
|
||||||
|
bool _flushing = false;
|
||||||
final List<Map<String, dynamic>> _pendingEvents = [];
|
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) {
|
void identify(String userId) {
|
||||||
_userId = userId;
|
_userId = userId;
|
||||||
}
|
}
|
||||||
@@ -47,18 +77,16 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final event = {
|
final event = {
|
||||||
'eventId': const Uuid().v4(),
|
'eventId': const Uuid().v7(),
|
||||||
'eventName': eventName,
|
'eventName': eventName,
|
||||||
'eventVersion': 1,
|
'eventVersion': 1,
|
||||||
'anonymousId': _anonymousId,
|
'anonymousId': _anonymousId,
|
||||||
if (_userId != null) 'userId': _userId,
|
if (_userId != null) 'userId': _userId,
|
||||||
'sessionId': const Uuid().v4(), // Simplified: unique per event (M0)
|
'sessionId': getSessionId(),
|
||||||
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
||||||
'appVersion': '1.0.0+1', // TODO: read from package_info_plus
|
'appVersion': _appVersion,
|
||||||
'platform': Platform.isAndroid ? 'android' : 'ios',
|
'platform': Platform.isAndroid ? 'android' : 'ios',
|
||||||
'osVersion': Platform.isAndroid
|
'osVersion': _osVersion,
|
||||||
? 'android-14'
|
|
||||||
: 'ios-17', // TODO: device_info_plus
|
|
||||||
if (props != null && props.isNotEmpty) 'props': props,
|
if (props != null && props.isNotEmpty) 'props': props,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +100,8 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flush() async {
|
Future<void> _flush() async {
|
||||||
if (_pendingEvents.isEmpty) return;
|
if (_flushing || _pendingEvents.isEmpty) return;
|
||||||
|
_flushing = true;
|
||||||
|
|
||||||
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
|
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
|
||||||
_pendingEvents.clear();
|
_pendingEvents.clear();
|
||||||
@@ -80,7 +109,16 @@ class AnalyticsService {
|
|||||||
try {
|
try {
|
||||||
await _upload(batch);
|
await _upload(batch);
|
||||||
} catch (error) {
|
} 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: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/api_client.dart';
|
||||||
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.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 AppState appState;
|
||||||
late final SessionManager sessionManager;
|
late final SessionManager sessionManager;
|
||||||
late final AuthRepository authRepository;
|
late final AuthRepository authRepository;
|
||||||
|
late final SessionTracker _sessionTracker;
|
||||||
|
late final AnalyticsService _analytics;
|
||||||
|
late final PageViewTracker _pageViewTracker;
|
||||||
|
late final AnalyticsRouteObserver _routeObserver;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -32,7 +42,37 @@ class _AppState extends State<App> {
|
|||||||
sessionManager =
|
sessionManager =
|
||||||
widget.sessionManager ??
|
widget.sessionManager ??
|
||||||
SessionManager(store: const SecureTokenStore());
|
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();
|
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() {
|
AuthRepository _buildRepository() {
|
||||||
@@ -47,11 +87,33 @@ class _AppState extends State<App> {
|
|||||||
api: api,
|
api: api,
|
||||||
session: sessionManager,
|
session: sessionManager,
|
||||||
refresher: refresher,
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
sessionManager.removeListener(_reportAuthStateChange);
|
||||||
|
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||||
appState.dispose();
|
appState.dispose();
|
||||||
if (widget.sessionManager == null) sessionManager.dispose();
|
if (widget.sessionManager == null) sessionManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -74,6 +136,7 @@ class _AppState extends State<App> {
|
|||||||
return MainShellPage(
|
return MainShellPage(
|
||||||
key: const ValueKey('shell'),
|
key: const ValueKey('shell'),
|
||||||
appState: appState,
|
appState: appState,
|
||||||
|
pageViewTracker: _pageViewTracker,
|
||||||
onLogout: authRepository.logout,
|
onLogout: authRepository.logout,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,6 +148,7 @@ class _AppState extends State<App> {
|
|||||||
title: 'Patbond',
|
title: 'Patbond',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildAppTheme(),
|
theme: buildAppTheme(),
|
||||||
|
navigatorObservers: [_routeObserver],
|
||||||
home: ListenableBuilder(
|
home: ListenableBuilder(
|
||||||
listenable: sessionManager,
|
listenable: sessionManager,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。
|
/// 统一页面切换: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>(
|
return PageRouteBuilder<T>(
|
||||||
|
settings: settings,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/semantics.dart';
|
import 'package:flutter/semantics.dart';
|
||||||
import 'package:flutter/services.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/navigation/fade_route.dart';
|
||||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
@@ -130,7 +131,10 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
|
|
||||||
void _goRegister() {
|
void _goRegister() {
|
||||||
Navigator.of(context).push(
|
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: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/core/theme/app_theme.dart';
|
||||||
import 'package:patbond_flutter/features/create/create_page.dart';
|
import 'package:patbond_flutter/features/create/create_page.dart';
|
||||||
import 'package:patbond_flutter/features/home/home_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';
|
import 'package:patbond_flutter/widgets/common.dart';
|
||||||
|
|
||||||
class MainShellPage extends StatefulWidget {
|
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;
|
final AppState appState;
|
||||||
|
|
||||||
|
/// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。
|
||||||
|
final PageViewTracker? pageViewTracker;
|
||||||
|
|
||||||
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
|
/// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。
|
||||||
final Future<void> Function()? onLogout;
|
final Future<void> Function()? onLogout;
|
||||||
|
|
||||||
@@ -28,8 +38,25 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
|
|
||||||
static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料'];
|
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) {
|
void selectTab(int index) {
|
||||||
setState(() => currentIndex = index);
|
setState(() => currentIndex = index);
|
||||||
|
widget.pageViewTracker?.reportTab(_tabPages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openServices(bool personal) {
|
void openServices(bool personal) {
|
||||||
@@ -37,11 +64,13 @@ class _MainShellPageState extends State<MainShellPage> {
|
|||||||
showPersonalServices = personal;
|
showPersonalServices = personal;
|
||||||
currentIndex = 3;
|
currentIndex = 3;
|
||||||
});
|
});
|
||||||
|
widget.pageViewTracker?.reportTab(_tabPages[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPost(PostModel post) {
|
void openPost(PostModel post) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName),
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
PostDetailPage(appState: widget.appState, postId: post.id),
|
PostDetailPage(appState: widget.appState, postId: post.id),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,6 +336,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
dio: ^5.11.1
|
dio: ^5.11.1
|
||||||
flutter_secure_storage: ^11.0.0
|
flutter_secure_storage: ^11.0.0
|
||||||
uuid: ^4.6.0
|
uuid: ^4.6.0
|
||||||
|
package_info_plus: ^10.2.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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';
|
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AnalyticsService', () {
|
final uuidV7 = RegExp(
|
||||||
test('tracks event with required fields', () async {
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
final service = AnalyticsService(
|
|
||||||
apiBaseUrl: 'http://test',
|
|
||||||
getAccessToken: null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Never throws, never awaits network (fire-and-forget).
|
AnalyticsService buildService({
|
||||||
expect(
|
String Function()? getSessionId,
|
||||||
() => service.trackEvent('auth_login_succeeded', {
|
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 = buildService(getSessionId: () => 'session-a');
|
||||||
|
|
||||||
|
await service.trackEvent('auth_login_succeeded', {
|
||||||
'identifierType': 'username',
|
'identifierType': 'username',
|
||||||
'durationMs': 123,
|
'durationMs': 123,
|
||||||
}),
|
});
|
||||||
returnsNormally,
|
|
||||||
);
|
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 {
|
test('rejects events with forbidden field patterns', () async {
|
||||||
final service = AnalyticsService(
|
final service = buildService();
|
||||||
apiBaseUrl: 'http://test',
|
|
||||||
getAccessToken: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Forbidden field triggers local rejection (no throw, silent drop).
|
// Forbidden field triggers local rejection (no throw, silent drop).
|
||||||
await service.trackEvent('auth_login_succeeded', {
|
await service.trackEvent('auth_login_succeeded', {
|
||||||
'userPassword': 'leak', // Forbidden pattern
|
'userPassword': 'leak', // Forbidden pattern
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event should be dropped (no assertion — just verify no crash).
|
expect(service.pendingEvents, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('identify sets userId', () {
|
test('identify sets userId on subsequent events', () async {
|
||||||
final service = AnalyticsService(
|
final service = buildService();
|
||||||
apiBaseUrl: 'http://test',
|
|
||||||
getAccessToken: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
service.identify('user-123');
|
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', () {
|
test('reset clears userId but keeps anonymousId', () async {
|
||||||
final service = AnalyticsService(
|
final service = buildService(anonymousId: 'anon-123');
|
||||||
apiBaseUrl: 'http://test',
|
|
||||||
getAccessToken: null,
|
|
||||||
anonymousId: 'anon-123',
|
|
||||||
);
|
|
||||||
|
|
||||||
service.identify('user-123');
|
service.identify('user-123');
|
||||||
service.reset();
|
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