394 lines
13 KiB
Dart
394 lines
13 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|