Skip to content

feat(mobile): Flutter iOS app v0.1 - #30

Merged
cafca merged 69 commits into
mainfrom
claude/silly-roentgen-26bbf9
Apr 20, 2026
Merged

feat(mobile): Flutter iOS app v0.1#30
cafca merged 69 commits into
mainfrom
claude/silly-roentgen-26bbf9

Conversation

@cafca

@cafca cafca commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Summary

BeeBeeBike Flutter iOS app, v0.1. A Berlin-only bicycle navigation
app backed by the existing Rust/Axum API and the docker dev stack.

What ships

Map screen (browse mode)

  • MapLibre GL map with a self-contained style (assets/styles/beebeebike-style.json) that pulls all tiles, sprites, and glyphs from the local VersaTiles server via a {{TILE_BASE}} placeholder swapped at runtime from AppConfig.tileServerBaseUrl.
  • Camera is bounded to Berlin (52.3-52.7N, 13.0-13.8E, zoom 10-18).
  • Search bar opens a Photon-backed search screen with debounced geocoding; picking a result sets destination; origin auto-fills from GPS (falls back to Brandenburg Gate).
  • Tapping any point on the map sets destination (origin auto-GPS if unset).
  • Gestures: pan, double-tap-drag zoom, single-tap — working on iOS 17+ via EagerGestureRecognizer + native onMapClick. Pinch-to-zoom is known not to work in this release (see Known limitations).
  • FAB flies the camera to current GPS.
  • Route polyline + origin/destination markers rendered from /api/route GeoJSON.
  • Bottom sheet: home/saved placeholder → loading spinner → error state → route summary (12 min · 3.4 km) with Start and X-to-clear.

Navigation mode

  • Single MapLibreMap instance; Start flips a session flag rather than pushing a new screen, so tiles, style, gestures, and camera continuity are preserved.
  • Ferrostar-backed NavigationService wires GraphHopper routes (/api/navigate returns OSRM mapbox format) into a state stream.
  • Live turn banner, distance to next maneuver, Material turn icons.
  • ETA bottom sheet with arrival time + minutes remaining, TTS toggle, and close button.
  • Spoken instructions via flutter_tts (de-DE).
  • Rerouting: NavigationService.deviationStream listener refetches /api/navigate and calls replaceRoute. A rerouteInProgressStream (true at start, false in finally) drives the rerouting toast — independent of NavigationState.isOffRoute which only updates on the next GPS fix. On reroute completion the map polyline is refreshed from /api/route.

Camera lifecycle state machine (NavigationCameraController)

  • States: awaitingFirstFix → following → free → arrived.
  • First GPS fix → animate to user, engage trackingCompass.
  • User pan/zoom → onCameraTrackingDismissedfree mode → RecenterFab appears.
  • Tapping RecenterFab animates back and re-engages tracking.
  • Arrival (TripStatus.complete) zooms to destination at z17 and swaps ETA sheet for Arrived sheet.
  • Free-mode zoom changes persist across recenter; arrival resets.

Auth & settings

  • Anonymous sessions bootstrap on app launch, persisted via SharedPreferences.
  • Settings screen with login entry; LoginScreen with error surfacing.
  • Session-based cookie auth forwarded through Dio interceptor.

Architecture

  • Providers (Riverpod 2): mapStyleProvider (FutureProvider), routeControllerProvider (NotifierProvider), navigationServiceProvider, navigationStateProvider + rerouteInProgressProvider (both StreamProvider.autoDispose), navigationCameraControllerProvider (ChangeNotifierProvider.autoDispose), navigationSessionProvider (StateProvider).
  • Config: AppConfig carries apiBaseUrl and tileServerBaseUrl, both overridable via --dart-define. Defaults target docker dev stack (127.0.0.1:3000 / 127.0.0.1:8080).
  • Mobile style asset is generated from the shared web/src/lib/bicycle-style.js via npm --prefix web run build:mobile-style; CI fails if the committed artifact diverges.
  • Map screen decomposition: widgets/eta_sheet.dart, widgets/route_summary.dart, widgets/recenter_fab.dart, widgets/rerouting_toast.dart, widgets/arrived_sheet.dart, widgets/turn_banner.dart, navigation/camera_controller.dart, navigation/navigation_service.dart, navigation/maneuver_icons.dart, navigation/nav_constants.dart, services/route_drawing.dart, services/map_style_loader.dart.

Tests

62 flutter tests, all green. Coverage includes:

  • Camera state machine (all transitions + no-op paths)
  • RouteSummary, EtaSheet, RecenterFab, ReroutingToast, ArrivedSheet widgets
  • MapScreen navigation lifecycle: first-fix FAB hidden, free-mode FAB visible, rerouting toast follows stream, arrival swaps sheet, arrival clears toast mid-reroute
  • NavigationService: reroute on deviation, stateStream forwarding
  • Search screen: debounce, result tap, loading spinner
  • Route summary card states (loading, error, preview)
  • Login: error surface, success pop
  • Maneuver icon + distance + ETA formatting

iOS simulator smoke test job runs in CI (ci-mobile), exercising flutter pub get, dart format --set-exit-if-changed, flutter analyze, flutter test, and a boot test on an iPhone simulator.

v0.1 scope notes

  • Berlin-only: camera bounded to Berlin, TTS pinned to de-DE, GPS fallback hardcoded to Brandenburg Gate.
  • iOS only: Android and web targets not configured. ferrostar_flutter is a path dependency at packages/ferrostar_flutter/.

Known limitations (tracked for v0.2)

  • Pinch-to-zoom does not work. EagerGestureRecognizer claims all pointer events so pan/tap/double-tap-drag behave correctly, but the native iOS UIPinchGestureRecognizer inside the MapLibre platform view never sees both fingers. Workaround: use double-tap-drag zoom. Fix requires a custom OneSequenceGestureRecognizer that yields the arena on a second simultaneous pointer.
  • Polyline does not truncate behind the rider during navigation.
  • During reroute completion the polyline disappears for ~100-500 ms while /api/route refetches.
  • Reroute overwrites a named origin (e.g. "Alexanderplatz") with synthetic GPS.
  • Arrival only triggers when GPS physically reaches destination; iOS simulator requires scripted location replay to exercise.

How to run

# Stack:
docker compose -f compose.yml -f compose.dev.yml up

# App (from repo root):
cd mobile && flutter pub get && flutter run -d ios

# Non-default stack:
flutter run -d ios \
  --dart-define=BEEBEEBIKE_API_BASE_URL=http://host:3000 \
  --dart-define=BEEBEEBIKE_TILE_SERVER_BASE_URL=http://host:8080

Test plan

  • Stack up, app boots, Berlin map renders (German labels, S-Bahn ring visible)
  • Pan, two-finger rotate, double-tap-drag zoom work
  • Pan stops at Berlin bounds
  • Tap map → destination set, origin auto-GPS, polyline drawn, route summary shows minutes/km
  • Search bar → type → pick result → destination set with polyline
  • X on route summary clears the route and returns to Home/Saved
  • FAB flies camera to current GPS
  • Start → nav banner appears, first fix engages trackingCompass
  • Pan during nav → RecenterFab appears; tap → camera re-engages tracking
  • Deviate from route → rerouting toast shows → disappears on success → polyline visibly updates to new route
  • Reach destination → ETA sheet swaps to Arrived sheet, camera centers on destination at z17
  • TTS speaks German instructions; mute toggle icon swaps
  • Close (X) during nav returns to browse; route persists unless explicitly cleared

@cafca cafca changed the title feat(mobile): Flutter iOS app v0.1 — auth, routing, navigation shell feat(mobile): Flutter iOS app v0.1 Apr 17, 2026
cafca added 26 commits April 18, 2026 11:21
…le iteration

Adopts @versatiles/style as the upstream of the bicycle-planning style; web
runtime and mobile build call the same shared builder; mobile bundles a
pre-baked artifact whose URLs are placeholders swapped at runtime for
AppConfig.tileServerBaseUrl. CI guards against drift.

Adds mobile services for style loading and route polyline drawing, a
gesture-arena-safe map tap region with widget tests, and assorted map/nav
screen iteration. Splits AppConfig into apiBaseUrl + tileServerBaseUrl.
Also fixes navigationCameraControllerProvider to use ChangeNotifierProvider
so that ref.watch correctly rebuilds consumers when camera mode changes.
Single-subscription async* stream cannot be re-listened after its
subscription is cancelled. Leaving and re-entering NavigationScreen
threw 'Bad state: Stream has already been listened to'.

Convert locationStream field from Stream<UserLocation> to
LocationStreamFactory — a zero-arg function — and invoke it inside
start() so each navigation session gets a fresh underlying stream.
Previously tapping Start pushed NavigationScreen, which instantiated a
second MapLibreMap. Two platform views fought for resources and caused
a visible slide-in panel change instead of a seamless transition.

Merge all navigation overlays (TurnBanner, ReroutingToast, RecenterFab,
ArrivedSheet, ETA sheet) into MapScreen. Gate them on a new
navigationSessionProvider (StateProvider<bool>) instead of a route push.
The single MapLibreMap persists across the browse ↔ navigate transition.

Also address code review items:
- rename NavigationService.locationStream → locationStreamFactory
  for clarity (it's Stream<UserLocation> Function(), not a Stream)
- clear _rerouting in _handleArrival so rerouting toast can't overlap
  the arrived sheet
- fire cam.onFirstFix() before the map-controller null check, mirroring
  _handleArrival; guarantees state transition even if map isn't ready
- drop _followZoomOnStart constant (duplicated cam.followZoom default);
  browse-mode initial zoom is 13 (Berlin overview), nav animates to
  cam.followZoom on first fix

Deletes NavigationScreen and its tests. New
map_screen_navigation_test.dart covers the same four lifecycle cases
plus arrival-while-off-route clears the toast.
Walkthrough on iOS sim surfaced several bugs in the unified MapScreen:

1. Pinch-to-zoom and rotate broken. Root cause: MapTapRegion (Listener
   wrapper) was fine on its own, but something in the Scaffold/MaterialApp
   tree was winning the gesture arena on iOS and starving the UiKitView
   of its second pointer. Fix: drop the custom Listener, pass
   EagerGestureRecognizer to MapLibreMap, and use the plugin's native
   onMapClick for taps. Deletes the now-unneeded MapTapRegion widget
   and its tests.

2. Tap-to-set-destination silently did nothing on first use. RouteController
   early-returns from _maybeLoadPreview when origin is null, so a tap
   without a prior search never computed a route. _handleMapTap now
   resolves current GPS (or the Berlin fallback) and sets the origin
   before setting the destination, matching the flow in _BrowseOverlay.

3. Cancelling navigation wiped the preview. _endNavigationSession now
   preserves the route state by default; only clearRoute: true on
   arrival tears it down.

4. TTS spoke German instructions with English pronunciation. Flutter TTS
   defaults to the device locale; set the language to de-DE explicitly
   in flutterTtsProvider.

5. Reroute pipeline had no visibility in console. Added debugPrints at
   each step of deviation → loadNavigationRoute → replaceRoute so the
   next walkthrough can diagnose why the toast stays stuck.

47/47 tests pass.
Log every meaningful event during a nav session so the console (or
DevTools logging view) shows the full life cycle end-to-end:

  NavigationService: start <lat,lng> -> <lat,lng>
  NavigationService: initial route fetched
  NavigationService: controller created, subscriptions wired
  NavigationService: spoken "<instruction>"
  NavigationService: deviation <m>m, rerouting...
  NavigationService: replaceRoute succeeded
  NavigationService: disposing controller + subs

  MapScreen: starting navigation <origin> -> <destination>
  MapScreen: navigation started
  MapScreen: first GPS fix <lat,lng>
  MapScreen: isOffRoute -> true/false
  MapScreen: arrival fired
  MapScreen: ending navigation (clearRoute=<bool>)

Search and browse-side taps intentionally stay silent so the log is
focused on the navigation life cycle. 47/47 tests pass.
…reroute

The rerouting toast was keyed off NavigationState.isOffRoute, but
ferrostar's replaceRoute swaps in a fresh native controller that
emits no new state until the next GPS update. The toast therefore
stuck on indefinitely after a successful reroute.

NavigationService now exposes rerouteInProgressStream that flips
true at the start of the reroute handler and false in its finally
block. MapScreen watches it via rerouteInProgressProvider.

On the true->false transition MapScreen also refetches the preview
from current GPS so the map visibly updates to the new route
(/api/navigate returns OSRM-encoded polyline which doesn't feed
the RoutePreview geometry; /api/route returns GeoJSON which does).
Lets users clear a pending route and return to the home bottom
sheet without picking a new destination. Useful for manual/
integration testing and a common UX expectation.
Splits map_screen.dart (was 660 lines) into focused units:

- widgets/eta_sheet.dart — EtaSheet pulled out of _MapScreenState
- navigation/nav_constants.dart — kEtaSheetHeight, kArrivalZoom
- navigation/maneuver_icons.dart — iconForManeuver, formatDistance,
  formatEta

Also:

- fix: ETA-sheet close is now an IconButton with tooltip (a11y
  parity with RouteSummary close)
- fix: arrival zoom uses kArrivalZoom (17) per spec, not the
  user's last free-zoom level
- chore: trim navigation debugPrint calls to just the breadcrumbs
  needed to understand intent from a log scroll (start, end, first
  fix, arrival, reroute start/done, deviation distance). Drops
  per-event verbose logs.
- test: RouteSummary + EtaSheet close buttons, maneuver-icon helper
…_/___ wildcards

flutter analyze flagged __ and ___ as non-lowerCamelCase identifiers
in pattern destructuring. Replace the 4-tuple return of _pumpNavActive
with a _NavHarness class — clearer at call sites and lint-clean.
@cafca
cafca merged commit 7986e7b into main Apr 20, 2026
9 checks passed
cafca added a commit that referenced this pull request Apr 20, 2026
Server had stale compose.prod.yml with STATIC_DIR=/app/frontend/dist,
but the current image serves static assets from /app/web/dist
(renamed in #30). Prod served 404 on /.

Deploy now checks out the repo and scps compose.prod.yml to the
server so env config stays in lockstep with the image.
cafca added a commit that referenced this pull request Apr 20, 2026
* fix(deploy): sync compose.prod.yml to server before restart

Server had stale compose.prod.yml with STATIC_DIR=/app/frontend/dist,
but the current image serves static assets from /app/web/dist
(renamed in #30). Prod served 404 on /.

Deploy now checks out the repo and scps compose.prod.yml to the
server so env config stays in lockstep with the image.

* docs(deploy): sync graphhopper config too, document minimal server layout

Extend the scp step to ship backend/graphhopper_config.yml alongside
compose.prod.yml so every file compose.prod.yml references comes from
CI. Add DEPLOY.md describing the authoritative server layout and the
rule that anything not referenced by compose.prod.yml should be
deleted from the server.

* refactor(deploy): split host-specific config into server-side .env

Previously compose.prod.yml entangled app config (STATIC_DIR, LISTEN_ADDR,
PHOTON_URL — all image-bakable), orchestration (service wiring) and host
identity (DB password, memory limits, host ports) in one file that CI
overwrote on every deploy. This let image/compose drift break prod (the
STATIC_DIR /app/frontend/dist vs /app/web/dist mismatch that served 404
on /) and kept secrets in git.

New split:
- Image defaults own app config (already the case in backend/src/config.rs
  and backend/Dockerfile; compose just stops overriding them).
- compose.prod.yml is a pure orchestration template with ${VAR} refs,
  synced by CI.
- Host-specific values live in ~/beebeebike/.env on the prod host:
  POSTGRES_PASSWORD, memory limits (DB_MEM_LIMIT, GRAPHHOPPER_MEM_LIMIT,
  TILES_MEM_LIMIT, BACKEND_MEM_LIMIT), Postgres tuning, GRAPHHOPPER_JAVA_OPTS,
  reverse-proxy-facing host ports. Never committed, never touched by CI.
  .env.example documents the schema.

Deploy now pulls all images and recreates the whole stack (needed once
because graphhopper's config mount moved to backend/graphhopper_config.yml
and the tiles/db services pick up their memory limits from .env).
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