diff --git a/lib/analytics/analytics_page_name.dart b/lib/analytics/analytics_page_name.dart new file mode 100644 index 0000000..8be1e15 --- /dev/null +++ b/lib/analytics/analytics_page_name.dart @@ -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 _byName = { + for (final page in values) page.pageName: page, + }; + + /// 路由名 → 枚举;不在枚举内返回 null(调用方不上报,06 §5.2 验收 4)。 + static AnalyticsPageName? fromRouteName(String? name) => + name == null ? null : _byName[name]; +} diff --git a/lib/analytics/analytics_route_observer.dart b/lib/analytics/analytics_route_observer.dart new file mode 100644 index 0000000..6c62c16 --- /dev/null +++ b/lib/analytics/analytics_route_observer.dart @@ -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? route) { + if (route is! PageRoute) return; + final page = AnalyticsPageName.fromRouteName(route.settings.name); + if (page != null) tracker.report(page); + } + + @override + void didPush(Route route, Route? previousRoute) { + _reportRoute(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _reportRoute(newRoute); + } + + @override + void didPop(Route route, Route? 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); + } + } +} diff --git a/lib/analytics/page_view_tracker.dart b/lib/analytics/page_view_tracker.dart new file mode 100644 index 0000000..ccb3c07 --- /dev/null +++ b/lib/analytics/page_view_tracker.dart @@ -0,0 +1,38 @@ +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; + +/// trackEvent 形状的回调(生产传 `AnalyticsService.trackEvent`,测试传录制桩)。 +typedef TrackEventFn = + Future Function(String eventName, [Map? 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 = {'pageName': page.pageName}; + if (referrer != null) props['referrer'] = referrer; + _track('page_viewed', props); + } + + /// 主壳 Tab 曝光:记录当前 Tab 后上报(IndexedStack 无路由事件,手动补点)。 + void reportTab(AnalyticsPageName page) { + _currentTab = page; + report(page); + } +} diff --git a/lib/core/navigation/fade_route.dart b/lib/core/navigation/fade_route.dart index 4dcb87f..a84ea58 100644 --- a/lib/core/navigation/fade_route.dart +++ b/lib/core/navigation/fade_route.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; /// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。 -Route fadePageRoute(Widget page) { +/// +/// [settings] 从 M2 起必传路由名(page_viewed 取数来源,03 号评估 §2.1), +/// 名字用 `AnalyticsPageName` 枚举的 pageName 值。 +Route fadePageRoute(Widget page, {RouteSettings? settings}) { return PageRouteBuilder( + settings: settings, transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 300), pageBuilder: (context, animation, secondaryAnimation) => page, diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index cb1e623..41c0823 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -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 { void _goRegister() { Navigator.of(context).push( - fadePageRoute(RegisterPage(authRepository: widget.authRepository)), + fadePageRoute( + RegisterPage(authRepository: widget.authRepository), + settings: RouteSettings(name: AnalyticsPageName.register.pageName), + ), ); } diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 310b9b4..ae0380a 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -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 Function()? onLogout; @@ -28,8 +38,25 @@ class _MainShellPageState extends State { 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 { showPersonalServices = personal; currentIndex = 3; }); + widget.pageViewTracker?.reportTab(_tabPages[3]); } void openPost(PostModel post) { Navigator.of(context).push( MaterialPageRoute( + settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName), builder: (context) => PostDetailPage(appState: widget.appState, postId: post.id), ), diff --git a/test/analytics/analytics_route_observer_test.dart b/test/analytics/analytics_route_observer_test.dart new file mode 100644 index 0000000..fa9a3a9 --- /dev/null +++ b/test/analytics/analytics_route_observer_test.dart @@ -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 = >[]; + 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( + 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 = >[]; + 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( + settings: RouteSettings( + name: AnalyticsPageName.login.pageName, + ), + builder: (loginContext) => ElevatedButton( + onPressed: () => Navigator.of(loginContext).push( + MaterialPageRoute( + 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 = >[]; + 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( + 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 = >[]; + 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( + 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 = >[]; + 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( + 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 = >[]; + 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 = >[]; + 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 = >[]; + 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'); + }); + }); +}