Skip to content

feat(mobile): paint mode — brush + rating overlay on iOS - #55

Merged
cafca merged 26 commits into
mainfrom
claude/nice-hofstadter-2fcbaf
Apr 24, 2026
Merged

feat(mobile): paint mode — brush + rating overlay on iOS#55
cafca merged 26 commits into
mainfrom
claude/nice-hofstadter-2fcbaf

Conversation

@cafca

@cafca cafca commented Apr 23, 2026

Copy link
Copy Markdown
Owner

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

  • Paint sheet + BrushFab: 7-color value ramp (-7, -3, -1, 0 eraser, 1, 3, 7) with design-ramp tokens and i18n.
  • Stroke geometry: clipper2-based inflation — drag a finger, get a rounded-rect polygon hugging the path; tap = recolor existing polygon.
  • Brush controller: stroke state machine, undo/redo stack, tap-feature lookup, optimistic local overlay append on paint-submit.
  • Rating overlay: GeoJSON overlay layers for painted polygons, with SSE-driven live refresh and tap probing.
  • API client: PUT /api/ratings/paint, undo, redo, overlay fetch.
  • Gestures: single-finger drag paints, two-finger pinch zooms, two-finger drag pans, mid-stroke handoff to map; details below.
  • Backend: same-value overpaint now merges via ST_Union inside 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 OneSequenceGestureRecognizer added to MapLibreMap's gestureRecognizers set claims single-pointer events via startTrackingPointer in addAllowedPointer, which blocks an outer pan recognizer from winning single-finger arenas. Overriding resolve() doesn't help — the claim already happened.

Resolution: in paint mode, the platform view's gestureRecognizers is empty. All recognizers sit on the outer RawGestureDetector:

  • PaintPanGestureRecognizer — single-pointer; rejects on 2nd pointer (hands off to pinch); 300 ms double-tap-drag guard mirroring web/src/lib/brush.svelte.js.
  • MultiPointerScaleGestureRecognizer — only accepts at ≥2 pointers; drives zoom via moveCamera(CameraUpdate.zoomBy(delta, focalPoint)) and pan via scrollBy(-dx, -dy) from focalPointDelta.
  • TapGestureRecognizer — recolors existing polygons.

Test plan

  • just test-mobile — 169/169 pass
  • flutter analyze — clean
  • just test-backend — includes paint_same_value_over_existing_merges_into_single_row
  • iOS sim via just dev-ios-sim:
    • Single-finger drag paints a polygon of the selected color
    • Two-finger pinch zooms around focal point
    • Two-finger drag pans the map
    • Mid-stroke 2nd finger drops the preview, hands off to pinch/pan
    • Lift both → single-finger drag paints cleanly (no double-tap false positive)
    • Tap on existing polygon recolors it
    • Paint color N over color N → single merged polygon, no seams
    • Undo / redo cycles through recent paints
    • Polygons painted on iOS appear in the web overlay (and vice versa)

cafca added 8 commits April 23, 2026 19:47
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.
@cafca cafca changed the title feat: brush paint mode — same-value merge, pinch zoom/pan, mobile UX feat(mobile): paint mode — brush + rating overlay on iOS Apr 23, 2026
cafca added 18 commits April 24, 2026 01:28
- 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.
@cafca
cafca merged commit 471c181 into main Apr 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant