feat(mobile): Flutter iOS app v0.1 - #30
Merged
Merged
Conversation
…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
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.
6 tasks
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).
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
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)
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 fromAppConfig.tileServerBaseUrl.52.3-52.7N, 13.0-13.8E, zoom 10-18).EagerGestureRecognizer+ nativeonMapClick. Pinch-to-zoom is known not to work in this release (see Known limitations)./api/routeGeoJSON.12 min · 3.4 km) with Start and X-to-clear.Navigation mode
MapLibreMapinstance; Start flips a session flag rather than pushing a new screen, so tiles, style, gestures, and camera continuity are preserved.NavigationServicewires GraphHopper routes (/api/navigatereturns OSRM mapbox format) into a state stream.flutter_tts(de-DE).NavigationService.deviationStreamlistener refetches/api/navigateand callsreplaceRoute. ArerouteInProgressStream(true at start, false infinally) drives the rerouting toast — independent ofNavigationState.isOffRoutewhich only updates on the next GPS fix. On reroute completion the map polyline is refreshed from/api/route.Camera lifecycle state machine (
NavigationCameraController)awaitingFirstFix → following → free → arrived.trackingCompass.onCameraTrackingDismissed→freemode → RecenterFab appears.TripStatus.complete) zooms to destination at z17 and swaps ETA sheet for Arrived sheet.Auth & settings
Architecture
mapStyleProvider(FutureProvider),routeControllerProvider(NotifierProvider),navigationServiceProvider,navigationStateProvider+rerouteInProgressProvider(both StreamProvider.autoDispose),navigationCameraControllerProvider(ChangeNotifierProvider.autoDispose),navigationSessionProvider(StateProvider).AppConfigcarriesapiBaseUrlandtileServerBaseUrl, both overridable via--dart-define. Defaults target docker dev stack (127.0.0.1:3000/127.0.0.1:8080).web/src/lib/bicycle-style.jsvianpm --prefix web run build:mobile-style; CI fails if the committed artifact diverges.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:
iOS simulator smoke test job runs in CI (
ci-mobile), exercisingflutter pub get,dart format --set-exit-if-changed,flutter analyze,flutter test, and a boot test on an iPhone simulator.v0.1 scope notes
de-DE, GPS fallback hardcoded to Brandenburg Gate.ferrostar_flutteris a path dependency atpackages/ferrostar_flutter/.Known limitations (tracked for v0.2)
EagerGestureRecognizerclaims all pointer events so pan/tap/double-tap-drag behave correctly, but the native iOSUIPinchGestureRecognizerinside the MapLibre platform view never sees both fingers. Workaround: use double-tap-drag zoom. Fix requires a customOneSequenceGestureRecognizerthat yields the arena on a second simultaneous pointer./api/routerefetches.How to run
Test plan