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 createState() => _GlobalClickWrapperState(); } class _GlobalClickWrapperState extends State { DateTime? _lastClickTime; Offset? _pointerDownPosition; bool _pointerScrolled = false; _ClickScrollSnapshot? _lastScrollSnapshot; final Map _touchStarts = {}; final Map _activeTouchPositions = {}; 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( 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, ); } }