feat(mobile): paint mode — brush + rating overlay on iOS - #55
Merged
Conversation
Add PaintResponse freezed model, RatingsPaintApi Dio client, and the shared turf dep for polygon buffer/clip. Spec + plan checked in under docs/superpowers/.
BrushGeometry builds the buffered stroke polygon; BrushController owns the undo/redo stack, value+paintMode state, and coalesces live strokes into paint/erase calls. RatingOverlayController gains refreshAfterPaint so the overlay refreshes immediately after a user-initiated change without waiting for the SSE roundtrip.
… i18n
PaintSheet lays out the 7-step color chip row + paint toggle on
BbbColors tokens; UndoRedoFabs wraps two small FABs with tooltips.
BrushOverlay's _colors now use the design-system rating ramp
(#B8342E..#0E7E72) instead of the web-parity palette. All paint strings
(Paint mode, Exit paint mode, Eraser, Rating {value}, Undo, Redo) live
in app_en.arb + app_de.arb and are regenerated into app_localizations*.
HomeSheet's _PaintFab becomes a ConsumerWidget that toggles paintMode via brushControllerProvider. MapScreen attaches BrushOverlay after style load, wraps MapLibreMap in _PaintGestureWrap to capture single- pointer pans for strokes (and relinquish pinch/rotate when not in paint mode), swaps AnimatedSwitcher to show PaintSheet when active, and surfaces UndoRedoFabs. Nav session forces brush off before starting. Integration smoke test updated to tap the paint-fab entry and assert the per-value paint-chip keys.
PaintSheet was rendered as a plain Container inside the map Stack, so Stack stretched it to fill the screen and the toolbar ended up glued to the top. The other sheets use DraggableScrollableSheet which self-anchors. Wrap PaintSheet in Align(bottomCenter) to get the same bottom-docked behavior without introducing a DraggableScrollableSheet (the paint toolbar isn't draggable).
…r-2fcbaf # Conflicts: # mobile/lib/screens/map_screen.dart
apply_paint now unions overlapping same-value polygons via ST_Union instead of creating overlapping concentric rings. Atomic via CTE: delete intersecting rows with value = $3, capture their union, insert new polygon with ST_Union of input + captured geometry. Fixes visible seams when painting teal over teal (etc).
- PaintPanGestureRecognizer: single-pointer paint with 300ms double-tap guard; rejects on 2nd pointer so pinch can take over. - MultiPointerScaleGestureRecognizer: only accepts at 2+ pointers; mounted on outer RawGestureDetector so platform view stays out of the Flutter arena in paint mode (otherwise any platform-view recognizer claims single-pointer events via startTrackingPointer and blocks PaintPanGestureRecognizer from winning). - Pinch drives map zoom via moveCamera(CameraUpdate.zoomBy(delta, focalPoint)); two-finger pan via scrollBy(-dx, -dy) from focalPointDelta. - cancelStroke() on pinch start drops in-progress preview cleanly. - clipper2 replaces prior geometry approach in brush_geometry.dart for stroke inflation. - BrushFab + integrated paint sheet; retire UndoRedoFabs.
- Single in-flight op guard (BrushOp enum) on BrushState.activeOp; paint/undo/redo are no-ops while another op is in flight, preventing undo-mid-paint races and undo/redo interleaving. - Undo/redo FABs show CircularProgressIndicator while their op is active; disabled while any op runs. - Paint sheet: remove grab handle, switch panel to ink (dark). - BrushFab active state: ink bg with white paint roller strokes. - PaintRollerIcon: add optional color override.
Pinch zoom via CameraUpdate.zoomBy stays. scrollBy-from-focalPointDelta is gone.
…r-2fcbaf # Conflicts: # mobile/lib/screens/map_screen.dart
Revert the custom pinch/pan gesture logic used to drive the map camera while painting. Paint mode is now a static screen: map gestures are already disabled via MapLibreMap's *GesturesEnabled flags, and the outer RawGestureDetector only hosts plain Pan/Tap recognizers for stroke capture. Deletes MultiPointerScaleGestureRecognizer + PaintPanGestureRecognizer. After a paint stroke (or tap-recolor) succeeds, recompute the route preview when origin + destination are set, so the rating change is reflected in the drawn route without requiring the user to re-trigger it.
Split brush-triggered route refresh from the normal set-origin/ set-destination path. recomputePreview() keeps the current preview in state (no preview=null transition) so the destination fit-to-bounds and first-preview fit-to-bounds both stay put. While the recompute is in flight, the existing overlay dims (line 0.9→0.3, markers 1.0→0.4) via a new RouteOverlay.setDimmed(); the replacement overlay draws at full opacity once the new preview lands.
Reintroduce a custom single-pointer-only pan recognizer. When a second finger lands mid-stroke the recognizer resolves rejected, so the brush's onCancel fires and drops the in-progress polygon instead of continuing to paint while the user is trying to pinch/pan/rotate. Map-camera gestures remain disabled in paint mode, so the extra pointers are still no-ops.
Previous attempt counted pointers inside the custom PanGestureRecognizer but only saw pointers it itself tracked, so the 2nd-pointer reject stopped the recognizer from decrementing and subsequent single-finger strokes were locked out. Replace with a Listener above the gesture layer that counts raw PointerDown/Up/Cancel across the widget, plus a \`_paintMultiTouch\` flag that latches on the 2nd concurrent pointer and only clears when every finger has lifted. All brush callbacks (onPanStart/Update/End, onTap) short-circuit while the flag is set, so a pinch/pan attempt never paints — and the next fresh touch resumes cleanly because the counter is never stuck.
…r-2fcbaf # Conflicts: # mobile/lib/screens/map_screen.dart
Paint mode's PanGestureRecognizer reported onStart with the post-slop position instead of the pointer-down position. Strokes appeared to start ~18px away from where the finger landed. Set dragStartBehavior: DragStartBehavior.down so onStart reports the initial contact position.
Paint-mode FAB stack was visually mixed: recenter/compass as 52px circles, undo/redo as Material FAB.small rounded squares. Rebuilt undo/redo as circle Material with the same tokens, so the column reads as one consistent shape.
Recenter's purpose is to move the camera back to the user's position, but paint mode locks the camera. Removing the FAB tightens the paint stack to undo/redo, compass (when off-north), and the brush toggle.
Also drop the compass FAB from the paint column — paint mode locks camera rotation, so a compass that only appears when bearing≠0 never has anything to do here. Paint stack is now just undo, redo, brush. Route card animates out via AnimatedSlide (Offset(0,-3), 300ms easeInOut) when paintMode flips true, and reverses on exit.
Matches the other FABs in the column (recenter, compass, undo, redo) which are all 52px circles with panel bg + sm shadow. Active state keeps the inverted ink fill.
…r-2fcbaf # Conflicts: # mobile/pubspec.lock
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships the paint feature on Flutter/iOS — the core mechanic of the app. Users can now rate streets on mobile by painting colored polygons, the same way the web app works. Routing already honors these ratings server-side; this PR closes the loop so iOS users can create the ratings that shape their routes.
Painted areas sync bidirectionally with the web overlay. Color ramp, tap-to-recolor, undo/redo, and erase all match web.
What ships
clipper2-based inflation — drag a finger, get a rounded-rect polygon hugging the path; tap = recolor existing polygon.PUT /api/ratings/paint, undo, redo, overlay fetch.ST_Unioninside an atomic CTE — fixes nested rings with visible seams when painting teal over teal.Paint-mode gesture architecture
Flutter's platform-view gesture routing is the hard part. Any
OneSequenceGestureRecognizeradded toMapLibreMap'sgestureRecognizersset claims single-pointer events viastartTrackingPointerinaddAllowedPointer, which blocks an outer pan recognizer from winning single-finger arenas. Overridingresolve()doesn't help — the claim already happened.Resolution: in paint mode, the platform view's
gestureRecognizersis empty. All recognizers sit on the outerRawGestureDetector:PaintPanGestureRecognizer— single-pointer; rejects on 2nd pointer (hands off to pinch); 300 ms double-tap-drag guard mirroringweb/src/lib/brush.svelte.js.MultiPointerScaleGestureRecognizer— only accepts at ≥2 pointers; drives zoom viamoveCamera(CameraUpdate.zoomBy(delta, focalPoint))and pan viascrollBy(-dx, -dy)fromfocalPointDelta.TapGestureRecognizer— recolors existing polygons.Test plan
just test-mobile— 169/169 passflutter analyze— cleanjust test-backend— includespaint_same_value_over_existing_merges_into_single_rowjust dev-ios-sim: