air_pointer 0.2.2
air_pointer: ^0.2.2 copied to clipboard
Platform-agnostic canvas input for Flutter. Unifies mouse, trackpad, and MediaPipe hand-gesture events behind a single CanvasInputController.
0.2.2 #
Bug fixes #
- Drag-to-scroll interference —
CanvasScrollEventcould fire immediately after releasing a drag when the hand briefly adopted a pointing-like shape during the transition. Fixed by adding ascrollConfirmFramesgate (default2, ~67 ms at 30 fps) that requires N consecutive pointing frames before scroll activates, consistent with the existingpinchConfirmFramesguard for drag. SetscrollConfirmFrames: 1to restore the old single-frame-baseline behaviour.
New #
-
handLandmarkConnections— exported constant listing the 21 skeleton bone pairs for the MediaPipe hand topology. Iterate over it to draw a skeleton overlay without hand-coding the connections yourself. -
CanvasGestureEvent.confidence(double, default1.0) — ML confidence of the gesture classification. Always1.0on web (rule-based heuristic); populated from the backend's score on native when using a model-basedLandmarkProvidersuch ashand_detection. -
HandDetectionFrame.gestureConfidence/secondHandGestureConfidence(double, default1.0) — per-gesture confidence fields for native backends to expose ML scores that flow through toCanvasGestureEvent.confidence. -
GestureInputSourceconfidence thresholds (web) — three new constructor paramsminHandDetectionConfidence,minHandPresenceConfidence, andminTrackingConfidence(each default0.5, matching MediaPipe defaults). Tune these to trade sensitivity against false positives without modifying the JS worker. -
HandDetectionFrame.worldLandmarks/secondWorldLandmarks— optional world-space landmark lists (metric scale, hand-centre origin) for native backends to expose orientation-invariant landmark data. On web, populated from MediaPipe'sworldLandmarksresult automatically. -
GestureDebugInfo.worldLandmarks/secondWorldLandmarks— world landmarks forwarded through the debug snapshot so overlays and classifiers can consume them. -
HandDetectionFrame.boundingBox/secondHandBoundingBox(Rect?) — axis-aligned bounding box of each detected hand in normalised image coordinates. Native backends set this from their ML model's output. On web, computed automatically as the min/max envelope of the 21 image-space landmarks. -
GestureDebugInfo.boundingBox/secondHandBoundingBox(Rect?) — bounding box forwarded through the debug snapshot for overlay rendering. -
StylusInputSource— input source for Apple Pencil, Samsung S-Pen, and anyPointerDeviceKind.stylus/PointerDeviceKind.invertedStylusdevice. Maps pen contact to element-drag canvas events (Down/Move/Up, tap, double-tap, hover, cancel). Filters out mouse/touch/trackpad events so it can be combined withMouseInputSourceorTouchInputSourcewithout duplicates. Exposes aneraserModeStreamthat emitstrue/falseon eraser-vs-tip mode changes. -
CanvasDownEvent.pressure/CanvasMoveEvent.pressure(double, default1.0) — hardware pen pressure (0.0–1.0) carried by stylus down and move events. Backwards-compatible: non-stylus sources emit1.0; existing construction and pattern-matching compiles unchanged. -
defaultPointerSource()— factory function that returns the best-fitCanvasInputSourcefor the current platform:TouchInputSourceon Android / iOS,MouseInputSourceeverywhere else (including web). UseskIsWebanddefaultTargetPlatformfrompackage:flutter/foundation.dart, so it is safe to call on web without conditional imports.GestureInputSourceis not included — it requires platform-specific configuration that cannot be inferred automatically. -
TouchInputSource— mobile-native input source for Android and iOS. Single-finger drag →CanvasScrollEvent(direct-manipulation pan); two-finger pinch/spread →CanvasScaleEvent; fling →CanvasScrollEventwith non-zerovelocityfield for consumer momentum animations. Tap detection uses rawListenerevents rather thanGestureDetectorso taps are never dropped byScaleGestureRecognizer's kPanSlop movement threshold. -
CanvasScrollEvent.velocity(Offset, defaultOffset.zero) — fling velocity field added toCanvasScrollEvent. Non-zero only on fling events emitted byTouchInputSource. Backwards-compatible: existing code that constructs or pattern-matchesCanvasScrollEventcompiles unchanged.
Documentation #
- README — Documented three previously undocumented public API surfaces:
CanvasInputControllermuting (muteWhenActive/activeStream),GestureInputSource.statusStreamlifecycle states, andGestureInputSource.setFilterParams(). - README — Added self-hosting section documenting
mediaPipeBaseUrlandmodelAssetUrlconstructor parameters (Flutter Web only); corrected the "no self-hosted model" limitation which was inaccurate — self-hosting has been wired since 0.1.0. - KNOWN_LIMITATIONS — Corrected "No rotation gesture" bullet: rotation IS
emitted by
GestureInputSourceviaCanvasScaleEvent.rotation; onlyMouseInputSourceomits it (Flutter'sScaleGestureRecognizerlimitation). - CONTRIBUTING — Fixed wrong directory paths (
lib/src/filters/→lib/src/filter/, removed non-existentlib/src/calibration/); added development prerequisites table. - SECURITY — Replaced GitHub default template with accurate policy for a 0.2.x package (correct version table, advisory link, SLA, scope table).
- CLAUDE.md — Added architecture guide for AI-assisted development, covering the js_interop boundary invariant, quality gates, testing philosophy, and breaking-change rules.
Fixes #
pubspec.yamlversion bumped from0.2.0to0.2.1(was incorrectly behindCHANGELOG.md); addedhomepageandissue_trackerfields.GestureInputSource(native) —dispose()now wraps_frameSub?.cancel()inunawaited(), consistent with the threeStreamController.close()calls below it.
Example #
- 3D Room — Camera now starts inside the room (dist 900 → 250) for an immersive interior view instead of a dollhouse perspective from outside.
- 3D Room — Furniture can now be rotated on its Y-axis: horizontal scroll
while a piece is selected rotates it in-place; two-finger twist
(
CanvasScaleEvent.rotation) also rotates the selected piece instead of the camera.
0.2.1 #
Bug fixes #
MouseInputSource—_lastTapTimeis now cleared in the drag path of_onScaleEndso a tap followed by a quick drag no longer poisons the double-tap window for the next genuine tap.MouseInputSource—_onPointerCancelnow resets_isPinchZoomingand_lastTapTimeso an OS-interrupted gesture cannot leave the source in pinch-zoom mode (causing the next tap to be swallowed asCanvasScaleEndEvent) or produce a spuriousCanvasDoubleTapEvent.HandGestureRecognizer— the inter-tap timer (_timeSinceLastDwellS) now advances duringGesturePhase.down(pinch-drag) and pointing-finger scroll so time spent in those phases correctly counts toward the double-tap window.HandGestureRecognizer—_timeSinceLastDwellSis reset todouble.infinitywhen grace expires, preventing a stale tap from a previous tracking session from chaining as a double-tap on re-entry.HandGestureRecognizer— after aCanvasDoubleTapEventfires,_timeSinceLastDwellSis reset todouble.infinityinstead of0, matchingMouseInputSourcebehaviour (a third rapid dwell no longer emits a second double-tap).GestureInputSource(native) —maxHandsconstructor parameter is now stored as a readablefinal int maxHandsfield instead of being silently discarded; callers can query it when configuring theirLandmarkProvider.CanvasGestureEvent— newisSecondHandfield (bool, defaultfalse) distinguishes primary-hand gestures from secondary-hand gestures; bothGestureInputSourcebackends now setisSecondHand: trueon secondary-hand emissions.GestureInputSource(web) —classifyGestureis no longer called twice per frame per hand; the already-computed locals are reused when buildingGestureDebugInfo.GestureInputSource(web) —recognized_gesture.dartimport sorted to correct alphabetical position (lint:directives_ordering).
0.2.0 #
New events #
CanvasDoubleTapEvent— emitted alongside the secondCanvasTapEventwhen two taps occur withindoubleTapWindow(default 300 ms).MouseInputSourcedetects it via a DateTime gap between releases;HandGestureRecognizervia elapsed time between consecutive dwell taps.CanvasLongPressEvent— emitted after the pointer (or cursor) holds still forlongPressDuration.MouseInputSourceusesGestureDetector.onLongPressStart;HandGestureRecognizeruses a configurablelongPressDurationthreshold shared with the dwell timer (shorter threshold always wins).CanvasGestureEvent(gesture)— edge-triggered on eachRecognizedGesturechange (non-none) for both the primary and secondary hand. Fires once when a gesture starts; resets when the hand is lost so the same gesture can fire again on re-entry.
HandGestureRecognizer #
longPressDuration— new constructor param;Duration.zero= disabled (default).doubleTapWindow— new constructor param controlling the inter-tap interval that qualifies as a double tap (default 300 ms).longPressDurationS/doubleTapWindowS— public getters for the above.- Pointing-finger scroll now emits both horizontal and vertical deltas
(
Offset(scrollDx, scrollDy)instead ofOffset(0, scrollDy)). _checkDwellrefactored to_checkDwellEventsreturningList<PointerInputEvent>to support multiple simultaneous events (tap + double-tap).
MouseInputSource #
CanvasCancelEventemitted fromListener.onPointerCancelwhen the OS interrupts an active drag (context menu, window switch, etc.).CanvasLongPressEventviaGestureDetector.onLongPressStart. If a drag was in progress,CanvasCancelEventis emitted first to close it cleanly.- Double-tap detected via a
DateTimegap between successive tap releases.
GestureInputSource #
longPressDurationanddoubleTapWindowforwarded toHandGestureRecognizeron both web and native variants.maxHandsparam (default 2) forwarded to the MediaPipe web worker'snumHandsoption; set to 1 to improve performance in single-hand apps.CanvasGestureEventemitted for secondary hand gestures in addition to the primary hand._lastGestureresets tononewhen tracking is lost, so the same gesture fires again when the hand re-enters the frame.
Breaking changes #
OneEuroFilterremoved from the public barrel export (air_pointer.dart). It was an internal smoothing detail; import directly frompackage:air_pointer/src/filter/one_euro_filter.dartif needed.
0.1.0 #
Initial release.
Events #
PointerInputEventsealed hierarchy —CanvasTapEvent,CanvasDownEvent,CanvasMoveEvent,CanvasUpEvent,CanvasCancelEvent,CanvasHoverEvent,CanvasScrollEvent(withisTrackpad),CanvasScaleEvent(withrotation),CanvasScaleEndEvent,CanvasSwipeEvent(withSwipeDirectionandvelocity). All events use theCanvasprefix to avoid collisions with Flutter's own pointer events.
Core abstractions #
CanvasInputSource— abstract boundary; all input origins implement this contract.CanvasInputController— merges events from multipleCanvasInputSources into a single broadcast stream; foldsbuildSurfacewrappers in order.
MouseInputSource #
- Maps Flutter gesture-arena callbacks to canvas events: tap →
CanvasTapEvent, one-finger drag → Down/Move/Up, two-finger pinch →CanvasScaleEvent, scroll wheel and native trackpad pinch (PointerScaleEvent) →CanvasScrollEvent/CanvasScaleEvent, mouse hover →CanvasHoverEvent. scrollMultiplier— scales scroll deltas before emission.tapSlop— configurable tap-slop threshold (default 10 px).isTrackpadonCanvasScrollEvent— true for macOS/iOS two-finger pan; consumers can skip ticker-based inertia since the OS already provides momentum.
GestureInputSource (Flutter Web) #
MediaPipe HandLandmarker running in a dedicated web worker (off the main thread).
Zero-copy ImageBitmap transfer. Camera permission / hardware / context errors all
produce typed HandTrackingStatus states.
HandGestureRecognizer— pure-Dart state machine; fully testable without a camera.- Acquisition gate: N consecutive frames (default 3) to confirm hand presence.
- Hysteresis: separate close (default 0.05) and open (default 0.08) thresholds prevent chatter near the boundary.
- Grace window: N frames (default 5) before declaring the hand lost; cursor freezes and dwell progress is preserved through brief occlusions.
- Clutch / Midas-touch guard: pinch is blocked until the hand opens after confirmation, preventing accidental drags when the hand enters the frame already pinched.
CanvasCancelEvent(notCanvasUpEvent) when the hand exits during an active drag.- Two-hand spread →
CanvasScaleEventwith rotation delta;CanvasScaleEndEventwhen the second hand leaves. - Dwell-click: cursor must hold still within
dwellRadiusfordwellDurationto emitCanvasTapEvent. Progress is reported viaGestureDebugInfo.dwellProgress(0–1). Dwell is preserved through the grace window so brief occlusions don't reset progress. - Pointing-finger scroll: index extended + middle curled →
CanvasScrollEventdriven by vertical fingertip movement. Enabled viascrollEnabled: true; scaled byscrollScale. - Swipe gesture: fast directional movement (velocity >
swipeThresholdpx/s) in an open hand emitsCanvasSwipeEvent. 60/40 dominance ratio prevents diagonal false-positives; 400 ms cooldown suppresses repeated firing from one gesture. Disabled by default (swipeThreshold: 0).
OneEuroFilter— adaptive low-pass filter (Casiez et al., CHI 2012) for landmark coordinates. Exposesvelocityfor prediction and swipe detection.GestureCalibrator— accumulates open/closed pose samples fromGestureDebugInfo.pinchDistanceand computes per-userCalibrationResult.LandmarkProvider— platform interface for native landmark sources (e.g. TFLite on iOS/Android); web implementation uses MediaPipe via web worker.- Debug support —
GestureInputSource.debugInfostream ofGestureDebugInfo(phase, pinch distance, dwell progress, pointing flag, landmarks, worker latency, round-trip latency).buildCameraPreviewreturns a live camera widget.
Example #
example/ ships two demos driven entirely through CanvasInputController:
- Sandbox canvas — draggable boxes with dot-grid background, inertia scrolling, two-finger pinch-to-zoom, dwell-click, pointing-finger scroll, debug overlay, camera preview, calibration dialog, and zoom badge.
- Netflix-style demo — scrollable content grid with hero section, card rows with horizontal scrolling, detail overlay, and full air-pointer interaction (pinch-drag with inertia, dwell-click with progress ring, pointing scroll, swipe navigation).