From b574aa77b63856cff934ec9fb82db347351d5652 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:17:28 +0000 Subject: [PATCH 1/3] Initial plan From c7a4aa50e4dfaba25fac19184db54e3becf913d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:23:46 +0000 Subject: [PATCH 2/3] Replace GestureDetector with Listener for low-latency drawing Use Listener widget instead of GestureDetector to eliminate gesture disambiguation delays that cause drawing latency on iPadOS with Apple Pencil. The Listener widget responds immediately to pointer events while GestureDetector waits to determine gesture type. Key changes: - Replace onScaleStart/Update/End with onPointerDown/Move/Up/Cancel - Add multi-touch detection to allow pinch-to-zoom gestures - Maintain tap detection for polygon mode and other modes - Rename _processEraserInput to _processEraserInputAt for clarity This significantly reduces drawing latency on iPadOS with Apple Pencil by bypassing the gesture recognition pipeline. Co-authored-by: hm21 <40503456+hm21@users.noreply.github.com> --- .../paint_editor/widgets/paint_canvas.dart | 162 ++++++++++++------ 1 file changed, 107 insertions(+), 55 deletions(-) diff --git a/lib/features/paint_editor/widgets/paint_canvas.dart b/lib/features/paint_editor/widgets/paint_canvas.dart index 92c1b653..60698765 100644 --- a/lib/features/paint_editor/widgets/paint_canvas.dart +++ b/lib/features/paint_editor/widgets/paint_canvas.dart @@ -122,6 +122,18 @@ class PaintCanvasState extends State { bool _hasPartialErasedAreas = false; + /// Tracks the number of active pointers to detect multi-touch gestures. + /// When more than one pointer is active, drawing is disabled to allow + /// pinch-to-zoom gestures. + int _activePointerCount = 0; + + /// Tracks whether the current gesture started as a multi-touch gesture. + /// Used to prevent drawing when the user is performing a pinch-to-zoom. + bool _isMultiTouch = false; + + /// Tracks the position of the first pointer for tap detection. + Offset? _pointerDownPosition; + bool get _isPartialEraser => widget.eraserMode == EraserMode.partial; bool get _isEraserMode => _paintCtrl.mode == PaintMode.eraser; bool get _isFreeStyleMode => @@ -142,13 +154,30 @@ class PaintCanvasState extends State { super.dispose(); } - /// This method is called when a scaling gesture for paint begins. It - /// captures the starting point of the gesture. + /// Handles the pointer down event for immediate response to touch/stylus + /// input. /// - /// It is not meant to be called directly but is an event handler for scaling - /// gestures. - void _onScaleStart(ScaleStartDetails details) { - final offset = details.localFocalPoint; + /// This uses the low-level [Listener] widget instead of [GestureDetector] + /// to eliminate gesture disambiguation delays, significantly reducing drawing + /// latency on devices like iPad with Apple Pencil. + void _onPointerDown(PointerDownEvent event) { + _activePointerCount++; + if (_activePointerCount > 1) { + // Multi-touch detected - disable drawing to allow pinch-to-zoom + _isMultiTouch = true; + // Cancel any ongoing drawing + if (_paintCtrl.busy) { + _paintCtrl + ..setInProgress(false) + ..reset(); + _activePaintStreamCtrl.add(null); + } + return; + } + + _pointerDownPosition = event.localPosition; + final offset = event.localPosition; + switch (widget.paintCtrl.mode) { case PaintMode.moveAndZoom: return; @@ -159,6 +188,7 @@ class PaintCanvasState extends State { return; case PaintMode.polygon: _addPolygonPoint(offset); + _checkPolygonIsComplete(); return; default: _paintCtrl @@ -169,24 +199,24 @@ class PaintCanvasState extends State { } } - /// Fires while the user is interacting with the screen to record paint. - /// - /// This method is called during an ongoing scaling gesture to record - /// paint actions. It captures the current position and updates the - /// paint controller accordingly. + /// Handles the pointer move event for continuous drawing updates. /// - /// It is not meant to be called directly but is an event handler for scaling - /// gestures. - void _onScaleUpdate(ScaleUpdateDetails details) { + /// This provides immediate response to pointer movement without the + /// gesture disambiguation delay that occurs with [GestureDetector]. + void _onPointerMove(PointerMoveEvent event) { + // Skip if multi-touch gesture is active (pinch-to-zoom) + if (_isMultiTouch || _activePointerCount > 1) return; + + final offset = event.localPosition; + switch (widget.paintCtrl.mode) { case PaintMode.moveAndZoom: case PaintMode.polygon: return; case PaintMode.eraser: - _processEraserInput(details); + _processEraserInputAt(offset); break; default: - final offset = details.localFocalPoint; if (!_paintCtrl.busy) { widget.onRefresh(); _paintCtrl.setInProgress(true); @@ -206,19 +236,39 @@ class PaintCanvasState extends State { } } - /// Fires when the user stops interacting with the screen. - /// - /// This method is called when a scaling gesture for paint ends. It - /// finalizes and records the paint action. - /// - /// It is not meant to be called directly but is an event handler for scaling - /// gestures. - void _onScaleEnd(ScaleEndDetails details) { + /// Handles the pointer up event to finalize drawing. + void _onPointerUp(PointerUpEvent event) { + _activePointerCount = max(0, _activePointerCount - 1); + + // If this was part of a multi-touch gesture, reset and return + if (_isMultiTouch) { + if (_activePointerCount == 0) { + _isMultiTouch = false; + } + return; + } + + final offset = event.localPosition; + + // Handle tap detection for polygon and other modes + if (_pointerDownPosition != null) { + final distance = (offset - _pointerDownPosition!).distance; + // If movement was minimal, treat as a tap + if (distance < 10) { + _tapDownDetails = TapDownDetails( + globalPosition: event.position, + localPosition: event.localPosition, + ); + widget.onTap(_tapDownDetails!); + _tapDownDetails = null; + } + } + _pointerDownPosition = null; + if (widget.paintCtrl.mode == PaintMode.moveAndZoom) { return; } else if (widget.paintCtrl.mode == PaintMode.eraser) { if (_isPartialEraser) widget.onRemovePartialEnd(_hasPartialErasedAreas); - return; } @@ -237,6 +287,24 @@ class PaintCanvasState extends State { _createPainting(offsets); } + /// Handles the pointer cancel event to clean up state. + void _onPointerCancel(PointerCancelEvent event) { + _activePointerCount = max(0, _activePointerCount - 1); + _pointerDownPosition = null; + + if (_activePointerCount == 0) { + _isMultiTouch = false; + } + + // Reset any ongoing drawing + if (_paintCtrl.busy) { + _paintCtrl + ..setInProgress(false) + ..reset(); + _activePaintStreamCtrl.add(null); + } + } + Offset _rotatePoint(Offset point, Offset center, double angle) { if (angle == 0) return point; @@ -252,9 +320,12 @@ class PaintCanvasState extends State { center; } - void _processEraserInput(ScaleUpdateDetails details) { + /// Processes eraser input at the given position. + /// + /// This method handles both full stroke and partial erasing based on + /// the current [EraserMode]. + void _processEraserInputAt(Offset focalPoint) { List removeIds = []; - final Offset focalPoint = details.localFocalPoint; final double stackScale = widget.layerStackScaleFactor; final Offset editorHalfSize = Offset( widget.editorBodySize.width, @@ -379,35 +450,16 @@ class PaintCanvasState extends State { return StreamBuilder( stream: _activePaintStreamCtrl.stream, builder: (context, snapshot) { - return GestureDetector( + // Use Listener instead of GestureDetector for immediate pointer + // response. This significantly reduces drawing latency on devices + // like iPad with Apple Pencil by eliminating gesture disambiguation + // delays. + return Listener( behavior: HitTestBehavior.translucent, - onScaleStart: _onScaleStart, - onScaleUpdate: _onScaleUpdate, - onScaleEnd: _onScaleEnd, - onTapDown: (details) { - _tapDownDetails = details; - if (_paintCtrl.mode == PaintMode.polygon) { - _addPolygonPoint(details.localPosition); - _checkPolygonIsComplete(); - } else if (_isFreeStyleMode || _isEraserMode) { - _onScaleStart(ScaleStartDetails( - focalPoint: details.localPosition, - localFocalPoint: details.localPosition)); - } - }, - onTapUp: (details) { - if (_isFreeStyleMode || _isEraserMode) { - _onScaleUpdate(ScaleUpdateDetails( - focalPoint: details.localPosition, - localFocalPoint: details.localPosition, - )); - _onScaleEnd(ScaleEndDetails()); - } - _tapDownDetails = null; - }, - onTap: () { - if (_tapDownDetails != null) widget.onTap(_tapDownDetails!); - }, + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, child: _paintCtrl.busy ? _paintCtrl.mode == PaintMode.blur || _paintCtrl.mode == PaintMode.pixelate From 6d4bfc8e7cbed0ba2a63451e599d48dc7c3a3539 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:25:31 +0000 Subject: [PATCH 3/3] Address code review feedback - Add _tapDistanceThreshold constant (10.0 logical pixels) for clarity - Move polygon completion check from pointer down to pointer up for tap detection to match original GestureDetector.onTapDown behavior - Add clarifying comment for eraser mode early return Co-authored-by: hm21 <40503456+hm21@users.noreply.github.com> --- .../paint_editor/widgets/paint_canvas.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/features/paint_editor/widgets/paint_canvas.dart b/lib/features/paint_editor/widgets/paint_canvas.dart index 60698765..3a84f3d6 100644 --- a/lib/features/paint_editor/widgets/paint_canvas.dart +++ b/lib/features/paint_editor/widgets/paint_canvas.dart @@ -134,6 +134,10 @@ class PaintCanvasState extends State { /// Tracks the position of the first pointer for tap detection. Offset? _pointerDownPosition; + /// Maximum distance in logical pixels between pointer down and up positions + /// for the interaction to be considered a tap rather than a drag gesture. + static const double _tapDistanceThreshold = 10.0; + bool get _isPartialEraser => widget.eraserMode == EraserMode.partial; bool get _isEraserMode => _paintCtrl.mode == PaintMode.eraser; bool get _isFreeStyleMode => @@ -187,8 +191,9 @@ class PaintCanvasState extends State { setState(() {}); return; case PaintMode.polygon: + // Only add the point on pointer down; completion check happens on + // pointer up when we can verify this was a tap (not a drag gesture) _addPolygonPoint(offset); - _checkPolygonIsComplete(); return; default: _paintCtrl @@ -254,11 +259,15 @@ class PaintCanvasState extends State { if (_pointerDownPosition != null) { final distance = (offset - _pointerDownPosition!).distance; // If movement was minimal, treat as a tap - if (distance < 10) { + if (distance < _tapDistanceThreshold) { _tapDownDetails = TapDownDetails( globalPosition: event.position, localPosition: event.localPosition, ); + // For polygon mode, check if the shape should be completed on tap + if (_paintCtrl.mode == PaintMode.polygon) { + _checkPolygonIsComplete(); + } widget.onTap(_tapDownDetails!); _tapDownDetails = null; } @@ -268,6 +277,8 @@ class PaintCanvasState extends State { if (widget.paintCtrl.mode == PaintMode.moveAndZoom) { return; } else if (widget.paintCtrl.mode == PaintMode.eraser) { + // Eraser mode doesn't create paintings - it only removes existing ones. + // The removal is handled during pointer move via _processEraserInputAt. if (_isPartialEraser) widget.onRemovePartialEnd(_hasPartialErasedAreas); return; }