Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
@@ -0,0 +1,20 @@
import 'package:analytics_sdk/entity/analytics_tab.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/utils/widget_bridge.dart';
/// Tab 解析与 track 分发的包内共享工具。
///
/// 供 widget 层各 analytics 包装使用,消除跨文件重复逻辑。
/// 文件名以下划线开头,表示不对外暴露。
/// 按索引解析 tab,超出范围时用 tab_N 兜底。
AnalyticsTab resolveTab(List<AnalyticsTab> tabs, int index) =>
index < tabs.length ? tabs[index] : AnalyticsTab('tab_$index');
/// 解析 tab 展示名称,未配置时通过 PageNameMapper 自动映射。
String resolveTabName(AnalyticsTab tab) =>
tab.name ?? PageNameMapper.getPageName(tab.key);
/// 上报事件:优先使用测试注入的 override,否则走 WidgetBridge.track。
void trackWith(void Function(dynamic)? override, Object event) =>
(override ?? WidgetBridge.track)(event);
@@ -0,0 +1,163 @@
import 'package:analytics_sdk/entity/analytics_tab.dart';
import 'package:analytics_sdk/entity/app_page_view_event.dart';
import 'package:analytics_sdk/entity/navigation_event.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/utils/widget_bridge.dart';
import 'package:analytics_sdk/widget/_tab_helpers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// BottomNavigationBar 的 analytics 包装。
///
/// 使用 Listener + LayoutBuilder 方案:保持原始 child 完整进入 widget 树,
/// 通过指针位移计算 tab 索引,无需拷贝 BottomNavigationBar 的所有属性。
/// 这样 Flutter 升级新增属性时,SDK 不需要任何改动。
///
/// 不应直接使用;通过 [AnalyticsBottomNavExtension.withAnalytics] 调用。
class AnalyticsBottomNavWrapper extends StatefulWidget {
final BottomNavigationBar child;
final List<AnalyticsTab> tabs;
// ignore: invalid_use_of_visible_for_testing_member
/// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。
@visibleForTesting
final void Function(dynamic event)? onTrack;
const AnalyticsBottomNavWrapper({
super.key,
required this.child,
required this.tabs,
this.onTrack,
});
@override
State<AnalyticsBottomNavWrapper> createState() =>
_AnalyticsBottomNavWrapperState();
}
class _AnalyticsBottomNavWrapperState extends State<AnalyticsBottomNavWrapper> {
/// 记录 pointer-down 位置,用于判断是否为 tap(而非拖拽)
Offset? _tapDownPosition;
/// 去重:记录最近一次上报的 tab 索引,防止快速连点重复上报
int _lastTrackedIndex = -1;
@override
void didUpdateWidget(AnalyticsBottomNavWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
// 父组件以编程方式切换 currentIndex 时同步去重状态
if (widget.child.currentIndex != oldWidget.child.currentIndex) {
_lastTrackedIndex = widget.child.currentIndex;
}
}
/// 纯计算:根据 tap 坐标解析 tab 索引。
/// 返回 null 表示无效点击(越界、重复、当前已选中)。
int? _indexFromTap(
double tapX, double totalWidth, TextDirection textDirection) {
final itemCount = widget.child.items.length;
if (itemCount == 0 || totalWidth <= 0) return null;
var index =
(tapX / (totalWidth / itemCount)).floor().clamp(0, itemCount - 1);
// RTL 布局下 BottomNavigationBar items 从右到左排列,镜像索引
if (textDirection == TextDirection.rtl) {
index = itemCount - 1 - index;
}
if (index == _lastTrackedIndex || index == widget.child.currentIndex) {
return null;
}
return index;
}
/// 根据横坐标、容器宽度和文字方向解析 tab 索引并上报埋点。
///
/// 通过 [SchedulerBinding.addPostFrameCallback] 在渲染完成后上报
/// [AppPageViewEvent](含来路 key)和 [NavigationEvent],
/// pageLoadTime 反映从点击到渲染完成的真实耗时,与 [PageLifecycleObserver] 保持一致。
void _handleTapAt(
double tapX, double totalWidth, TextDirection textDirection) {
try {
if (widget.tabs.isEmpty) return;
final index = _indexFromTap(tapX, totalWidth, textDirection);
if (index == null) return;
_lastTrackedIndex = index;
final tab = resolveTab(widget.tabs, index);
final pageKey = tab.key;
final pageName = resolveTabName(tab);
PageNameMapper.addMapping(pageKey, pageName);
final referrerKey = PageLifecycleObserver.currentPageKey;
final referrerName = PageNameMapper.getPageName(referrerKey);
PageLifecycleObserver.recordNavigation(pageKey);
// 记录 tap 时刻;在下一帧渲染完成后测量实际耗时,与 PageLifecycleObserver 保持一致
final tapTime = DateTime.now();
final onTrackSnapshot = widget.onTrack;
SchedulerBinding.instance.addPostFrameCallback((_) {
final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds;
try {
trackWith(
onTrackSnapshot,
AppPageViewEvent(
userType: WidgetBridge.getUserType(),
pageKey: pageKey,
pageName: pageName,
referrerPageKey: referrerKey,
referrerPageName: referrerName,
currentPageKey: pageKey,
currentPageName: pageName,
pageLoadTime: pageLoadTime,
pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000,
));
trackWith(
onTrackSnapshot,
NavigationEvent(
navigationKey: pageKey,
navigationName: pageName,
));
} catch (e) {
Logger.analyticsSdk('BottomNavWrapper.post-frame 上报异常,已安全处理: $e');
}
});
} catch (e) {
Logger.analyticsSdk('BottomNavWrapper._handleTapAt 异常,已安全处理: $e');
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final textDirection = Directionality.of(context);
return Listener(
// 透传所有指针事件,不干扰 BottomNavigationBar 自身的手势识别
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_tapDownPosition = event.localPosition;
},
onPointerUp: (event) {
final down = _tapDownPosition;
_tapDownPosition = null;
if (down == null) return;
// 位移 > 18px(与 Flutter kTouchSlop 对齐)视为拖拽,不触发 tab 埋点
if ((event.localPosition - down).distance > 18) return;
_handleTapAt(
event.localPosition.dx, constraints.maxWidth, textDirection);
},
onPointerCancel: (_) {
_tapDownPosition = null;
},
// 原始 child 完整保留,onTap 由 BottomNavigationBar 自身调用
child: widget.child,
);
},
);
}
}
@@ -0,0 +1,158 @@
import 'dart:async';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/utils/widget_bridge.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// 埋点调试条 - 展示设备信息、埋点流程 7 步,便于排查某机型上报问题
///
/// release 包自动隐藏(无性能影响);debug 包由 WidgetBridge 内的 enableDebugBanner 控制。
class AnalyticsDebugBanner extends StatelessWidget {
const AnalyticsDebugBanner({super.key});
@override
Widget build(BuildContext context) {
if (kReleaseMode || !WidgetBridge.enableDebugBanner) {
return const SizedBox.shrink();
}
return const _AnalyticsDebugBannerContent();
}
}
class _AnalyticsDebugBannerContent extends StatefulWidget {
const _AnalyticsDebugBannerContent();
@override
State<_AnalyticsDebugBannerContent> createState() =>
_AnalyticsDebugBannerContentState();
}
class _AnalyticsDebugBannerContentState
extends State<_AnalyticsDebugBannerContent> {
List<Map<String, String>> _steps = [];
Timer? _refreshTimer;
@override
void initState() {
super.initState();
_refreshSteps();
_refreshTimer = Timer.periodic(
const Duration(seconds: 2), (_) => _refreshSteps());
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
void _refreshSteps() {
try {
if (mounted) {
setState(() {
_steps = WidgetBridge.getDebugSteps();
});
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final brand = SdkContext.deviceBrand ?? '';
final model = SdkContext.deviceModel ?? '';
final deviceInfo = brand.isNotEmpty || model.isNotEmpty
? '$brand $model'.trim()
: (kIsWeb
? 'Web'
: defaultTargetPlatform == TargetPlatform.android
? 'Android'
: defaultTargetPlatform == TargetPlatform.iOS
? 'iOS'
: defaultTargetPlatform.name);
final deviceId = SdkContext.deviceId ?? '';
final channel = SdkContext.channel ?? '';
return IgnorePointer(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.55),
border: const Border(
bottom: BorderSide(color: Colors.white, width: 2)),
),
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'【埋点测试包】',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2),
],
),
),
const SizedBox(height: 6),
Text(
'设备: $deviceInfo',
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
'deviceId: ${deviceId.isEmpty ? "未获取" : "${deviceId.length > 16 ? deviceId.substring(0, 16) : deviceId}..."}',
style: const TextStyle(fontSize: 11, color: Colors.white70),
),
Text(
'渠道: ${channel.isEmpty ? "未获取" : channel}',
style: const TextStyle(fontSize: 11, color: Colors.white70),
),
const SizedBox(height: 8),
const Text(
'埋点流程(卡在某步=该机型问题)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.yellow),
),
..._steps.map((s) {
final status = s['status'] ?? '';
final isOk = status.startsWith('✓');
final isFail = status.startsWith('✗');
var detail = s['detail'] ?? '';
if (detail.toString().length > 35) {
detail = '${detail.toString().substring(0, 35)}...';
}
final detailStr =
detail.isNotEmpty && detail != '-' ? ' ($detail)' : '';
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${s['name']}: $status$detailStr',
style: TextStyle(
fontSize: 10,
color: isOk
? Colors.greenAccent
: isFail
? Colors.orange
: Colors.white70,
),
),
);
}),
],
),
),
),
);
}
}
@@ -0,0 +1,165 @@
import 'package:analytics_sdk/entity/analytics_tab.dart';
import 'package:analytics_sdk/entity/app_page_view_event.dart';
import 'package:analytics_sdk/entity/navigation_event.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/utils/widget_bridge.dart';
import 'package:analytics_sdk/widget/_tab_helpers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// TabBar 的 analytics 包装。
///
/// 监听 [TabController],Tab 切换完成时在下一帧渲染后自动上报:
/// - [AppPageViewEvent]:含来路页面 key(取自切换前的 [PageLifecycleObserver.currentPageKey])
/// - [NavigationEvent]:用于导航行为报表
///
/// 同时调用 [PageLifecycleObserver.recordNavigation] 更新全局 currentPageKey
/// 及栈顶 effectivePageKey,确保后续 page_click 归属正确的子页面;
/// 即便从详情页 pop 返回,也能恢复到切换前的子 Tab key。
///
/// 不应直接使用;通过 [AnalyticsTabBarExtension.withAnalytics] 调用。
class AnalyticsTabBarWrapper extends StatefulWidget
implements PreferredSizeWidget {
final TabBar child;
final List<AnalyticsTab> tabs;
/// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。
@visibleForTesting
final void Function(dynamic event)? onTrack;
const AnalyticsTabBarWrapper({
super.key,
required this.child,
required this.tabs,
this.onTrack,
});
@override
Size get preferredSize => child.preferredSize;
@override
State<AnalyticsTabBarWrapper> createState() => _AnalyticsTabBarWrapperState();
}
class _AnalyticsTabBarWrapperState extends State<AnalyticsTabBarWrapper> {
/// 当前监听的 TabController;在 [didChangeDependencies] / [didUpdateWidget] 中维护。
TabController? _controller;
/// 最近一次上报的 tab 索引,过滤 indexIsChanging 动画期间的重复触发。
int _lastTrackedIndex = -1;
@override
void didChangeDependencies() {
super.didChangeDependencies();
try {
_attachController(_resolveController());
} catch (e) {
Logger.analyticsSdk(
'AnalyticsTabBarWrapper.didChangeDependencies 异常,已安全处理: $e');
}
}
@override
void didUpdateWidget(AnalyticsTabBarWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
try {
final newController = _resolveController();
if (newController != _controller) {
_attachController(newController);
_lastTrackedIndex = -1; // 新 controller 时重置,防止去重误判
}
} catch (e) {
Logger.analyticsSdk(
'AnalyticsTabBarWrapper.didUpdateWidget 异常,已安全处理: $e');
}
}
/// 优先取 widget.child.controller,回退到 [DefaultTabController]。
TabController? _resolveController() {
final controller = widget.child.controller;
if (controller != null) return controller;
try {
return DefaultTabController.of(context);
} catch (_) {
return null;
}
}
/// 切换监听目标:移除旧 controller 监听后绑定新 controller;controller 未变时直接返回。
void _attachController(TabController? controller) {
if (controller == _controller) return;
_controller?.removeListener(_onControllerChange);
_controller = controller;
_controller?.addListener(_onControllerChange);
}
/// TabController 索引变化回调。
///
/// 过滤动画帧(indexIsChanging)和重复索引后,通过 [SchedulerBinding.addPostFrameCallback]
/// 在渲染完成后上报 [AppPageViewEvent] 和 [NavigationEvent],
/// pageLoadTime 反映从点击到渲染完成的真实耗时。
void _onControllerChange() {
try {
final ctrl = _controller;
if (ctrl == null || ctrl.indexIsChanging) return;
final index = ctrl.index;
if (index == _lastTrackedIndex) return;
_lastTrackedIndex = index;
if (widget.tabs.isEmpty) return;
final tab = resolveTab(widget.tabs, index);
final pageKey = tab.key;
final pageName = resolveTabName(tab);
PageNameMapper.addMapping(pageKey, pageName);
final referrerKey = PageLifecycleObserver.currentPageKey;
final referrerName = PageNameMapper.getPageName(referrerKey);
PageLifecycleObserver.recordNavigation(pageKey);
final tapTime = DateTime.now();
final onTrackSnapshot = widget.onTrack;
SchedulerBinding.instance.addPostFrameCallback((_) {
final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds;
try {
trackWith(
onTrackSnapshot,
AppPageViewEvent(
userType: WidgetBridge.getUserType(),
pageKey: pageKey,
pageName: pageName,
referrerPageKey: referrerKey,
referrerPageName: referrerName,
currentPageKey: pageKey,
currentPageName: pageName,
pageLoadTime: pageLoadTime,
pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000,
));
trackWith(
onTrackSnapshot,
NavigationEvent(
navigationKey: pageKey,
navigationName: pageName,
));
} catch (e) {
Logger.analyticsSdk('TabBarWrapper.post-frame 上报异常,已安全处理: $e');
}
});
} catch (e) {
Logger.analyticsSdk('TabBarWrapper._onControllerChange 异常,已安全处理: $e');
}
}
@override
void dispose() {
_controller?.removeListener(_onControllerChange);
_controller = null;
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -0,0 +1,393 @@
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/entity/page_click_event.dart';
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
import 'package:analytics_sdk/utils/widget_bridge.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class GlobalClickWrapper extends StatefulWidget {
final Widget child;
const GlobalClickWrapper({
required this.child,
super.key,
});
@override
State<GlobalClickWrapper> createState() => _GlobalClickWrapperState();
}
class _GlobalClickWrapperState extends State<GlobalClickWrapper> {
DateTime? _lastClickTime;
Offset? _pointerDownPosition;
bool _pointerScrolled = false;
_ClickScrollSnapshot? _lastScrollSnapshot;
final Map<int, _PointerTouchStart> _touchStarts = <int, _PointerTouchStart>{};
final Map<int, Offset> _activeTouchPositions = <int, Offset>{};
double? _pinchStartDistance;
bool _pinchRecorded = false;
static const double _swipeThresholdPx = 50;
static const double _pinchThresholdPx = 20;
static const double _longPressMoveTolerancePx = 10;
static const _longPressDuration = Duration(milliseconds: 500);
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_recordScroll(notification);
return false;
},
child: Listener(
onPointerDown: (PointerDownEvent event) {
_pointerDownPosition = event.position;
_pointerScrolled = false;
_recordPointerDown(event);
},
onPointerMove: (PointerMoveEvent event) {
if (!_pointerScrolled && _pointerDownPosition != null) {
final dist = (event.position - _pointerDownPosition!).distance;
if (dist > kTouchSlop) {
_pointerScrolled = true;
}
}
_recordPointerMove(event);
},
onPointerUp: (PointerUpEvent event) {
if (!_pointerScrolled) {
_handleTap(event);
}
_recordPointerUp(event);
_pointerDownPosition = null;
_pointerScrolled = false;
},
onPointerCancel: (event) {
_recordPointerCancel(event);
_pointerDownPosition = null;
_pointerScrolled = false;
},
// 透传所有事件,不影响原有交互
behavior: HitTestBehavior.translucent,
child: widget.child,
),
);
}
void _handleTap(PointerUpEvent event) {
try {
// 点击节流:限制点击事件上报频率,避免高频点击造成性能问题
final now = DateTime.now();
if (_lastClickTime != null &&
now.difference(_lastClickTime!) < SdkConfig.clickThrottleDuration) {
return;
}
_lastClickTime = now;
// 允许触摸(移动端)、鼠标(桌面/Web)、触控板(macOS);过滤触控笔等其他设备。
final kind = event.kind;
if (kind != PointerDeviceKind.touch &&
kind != PointerDeviceKind.mouse &&
kind != PointerDeviceKind.trackpad) {
return;
}
final pageKey = PageLifecycleObserver.currentPageKey;
if (pageKey.isEmpty || pageKey == 'unknown_page') {
return;
}
final pageName = PageNameMapper.getPageName(pageKey);
// 保存事件位置信息,避免在异步回调中访问已失效的事件对象
final clickPosition = event.position;
// 异步获取 RenderBox,避免在 build 阶段调用 findRenderObject
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (!mounted) return;
final renderObject = context.findRenderObject();
if (renderObject == null || renderObject is! RenderBox) return;
final renderBox = renderObject;
final size = renderBox.size;
final clickX = clickPosition.dx.round();
final clickY = clickPosition.dy.round();
final localPosition = renderBox.globalToLocal(clickPosition);
final clickViewportX = localPosition.dx.round();
final clickViewportY = localPosition.dy.round();
final viewportWidth = size.width.round();
final viewportHeight = size.height.round();
final percentX = viewportWidth > 0
? (clickViewportX / viewportWidth * 100).round().clamp(0, 100)
: 0;
final percentY = viewportHeight > 0
? (clickViewportY / viewportHeight * 100).round().clamp(0, 100)
: 0;
final scrollSnapshot = _scrollSnapshotFor(
pageKey: pageKey,
clickViewportX: clickViewportX,
clickViewportY: clickViewportY,
viewportWidth: viewportWidth,
viewportHeight: viewportHeight,
);
Logger.globalClick(
'上报点击成功 → $pageKey ($clickX,$clickY) [$percentX%,$percentY%]');
WidgetBridge.track(PageClickEvent.fromViewport(
pageKey: pageKey,
pageName: pageName,
clickPageX: clickX,
clickPageY: clickY,
clickViewportX: clickViewportX,
clickViewportY: clickViewportY,
viewportWidth: viewportWidth,
viewportHeight: viewportHeight,
clickDocumentX: scrollSnapshot?.clickDocumentX,
clickDocumentY: scrollSnapshot?.clickDocumentY,
pageWidth: scrollSnapshot?.pageWidth,
pageHeight: scrollSnapshot?.pageHeight,
scrollTop: scrollSnapshot?.scrollTop,
scrollLeft: scrollSnapshot?.scrollLeft,
));
} catch (e) {
Logger.globalClick('点击事件处理异常,已安全处理: $e');
}
});
} catch (e) {
Logger.globalClick('_handleTap() 异常,已安全处理: $e');
}
}
void _recordScroll(ScrollNotification notification) {
try {
final pageKey = PageLifecycleObserver.currentPageKey;
if (pageKey.isEmpty || pageKey == 'unknown_page') return;
final metrics = notification.metrics;
final scrollOffset = _roundedNonNegativeFinite(metrics.pixels);
final viewportExtent =
_roundedNonNegativeFinite(metrics.viewportDimension);
final contentExtent = _roundedNonNegativeFinite(
metrics.maxScrollExtent + metrics.viewportDimension);
if (scrollOffset == null ||
viewportExtent == null ||
contentExtent == null ||
contentExtent <= 0) {
return;
}
if (metrics.axis == Axis.vertical) {
SessionBehaviorManager.instance.recordScroll(
scrollTop: scrollOffset,
viewportHeight: viewportExtent,
contentHeight: contentExtent,
);
_lastScrollSnapshot = _ClickScrollSnapshot(
pageKey: pageKey,
scrollTop: scrollOffset,
scrollLeft: 0,
pageWidth: -1,
pageHeight: contentExtent,
);
} else if (metrics.axis == Axis.horizontal) {
_lastScrollSnapshot = _ClickScrollSnapshot(
pageKey: pageKey,
scrollTop: 0,
scrollLeft: scrollOffset,
pageWidth: contentExtent,
pageHeight: -1,
);
}
} catch (e) {
Logger.globalClick('滚动快照记录异常,已安全处理: $e');
}
}
void _recordPointerDown(PointerDownEvent event) {
try {
if (event.kind != PointerDeviceKind.touch) return;
final now = DateTime.now();
if (_activeTouchPositions.isEmpty) {
SessionBehaviorManager.instance.recordTouchStart(at: now);
} else {
_pointerScrolled = true;
}
_touchStarts[event.pointer] = _PointerTouchStart(
position: event.position,
startedAt: now,
);
_activeTouchPositions[event.pointer] = event.position;
_updatePinchBaseline();
} catch (e) {
Logger.globalClick('触摸开始行为记录异常,已安全处理: $e');
}
}
void _recordPointerMove(PointerMoveEvent event) {
try {
if (event.kind != PointerDeviceKind.touch) return;
if (!_activeTouchPositions.containsKey(event.pointer)) return;
_activeTouchPositions[event.pointer] = event.position;
if (_activeTouchPositions.length < 2) return;
final distance = _firstTwoTouchDistance();
if (distance == null) return;
_pinchStartDistance ??= distance;
if (!_pinchRecorded &&
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
_pinchRecorded = true;
SessionBehaviorManager.instance.recordPinch();
}
} catch (e) {
Logger.globalClick('触摸移动行为记录异常,已安全处理: $e');
}
}
void _recordPointerUp(PointerUpEvent event) {
try {
if (event.kind != PointerDeviceKind.touch) return;
final now = DateTime.now();
final wasSingleTouch = _activeTouchPositions.length == 1;
final start = _touchStarts[event.pointer];
if (start != null && wasSingleTouch) {
_recordFinishedSingleTouch(
start: start,
endPosition: event.position,
endedAt: now,
);
}
_touchStarts.remove(event.pointer);
_activeTouchPositions.remove(event.pointer);
_updatePinchBaseline();
} catch (e) {
Logger.globalClick('触摸结束行为记录异常,已安全处理: $e');
}
}
void _recordPointerCancel(PointerCancelEvent event) {
try {
if (event.kind != PointerDeviceKind.touch) return;
_touchStarts.remove(event.pointer);
_activeTouchPositions.remove(event.pointer);
_updatePinchBaseline();
} catch (e) {
Logger.globalClick('触摸取消行为记录异常,已安全处理: $e');
}
}
void _recordFinishedSingleTouch({
required _PointerTouchStart start,
required Offset endPosition,
required DateTime endedAt,
}) {
final distance = (endPosition - start.position).distance;
if (distance >= _swipeThresholdPx) {
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
return;
}
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
distance <= _longPressMoveTolerancePx) {
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
}
}
void _updatePinchBaseline() {
if (_activeTouchPositions.length < 2) {
_pinchStartDistance = null;
_pinchRecorded = false;
return;
}
_pinchStartDistance = _firstTwoTouchDistance();
_pinchRecorded = false;
}
double? _firstTwoTouchDistance() {
if (_activeTouchPositions.length < 2) return null;
final iterator = _activeTouchPositions.values.iterator;
iterator.moveNext();
final first = iterator.current;
iterator.moveNext();
final second = iterator.current;
return (first - second).distance;
}
_ClickScrollSnapshot? _scrollSnapshotFor({
required String pageKey,
required int clickViewportX,
required int clickViewportY,
required int viewportWidth,
required int viewportHeight,
}) {
final snapshot = _lastScrollSnapshot;
if (snapshot == null || snapshot.pageKey != pageKey) return null;
return snapshot.resolve(
clickViewportX: clickViewportX,
clickViewportY: clickViewportY,
viewportWidth: viewportWidth,
viewportHeight: viewportHeight,
);
}
int _nonNegative(int value) => value < 0 ? 0 : value;
int? _roundedNonNegativeFinite(double value) {
if (!value.isFinite) return null;
return _nonNegative(value.round());
}
}
class _PointerTouchStart {
final Offset position;
final DateTime startedAt;
const _PointerTouchStart({
required this.position,
required this.startedAt,
});
}
class _ClickScrollSnapshot {
final String pageKey;
final int scrollTop;
final int scrollLeft;
final int pageWidth;
final int pageHeight;
final int clickDocumentX;
final int clickDocumentY;
const _ClickScrollSnapshot({
required this.pageKey,
required this.scrollTop,
required this.scrollLeft,
required this.pageWidth,
required this.pageHeight,
this.clickDocumentX = -1,
this.clickDocumentY = -1,
});
_ClickScrollSnapshot resolve({
required int clickViewportX,
required int clickViewportY,
required int viewportWidth,
required int viewportHeight,
}) {
final resolvedPageWidth = pageWidth >= 0 ? pageWidth : viewportWidth;
final resolvedPageHeight = pageHeight >= 0 ? pageHeight : viewportHeight;
return _ClickScrollSnapshot(
pageKey: pageKey,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
pageWidth: resolvedPageWidth,
pageHeight: resolvedPageHeight,
clickDocumentX: clickViewportX + scrollLeft,
clickDocumentY: clickViewportY + scrollTop,
);
}
}