summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-04-03-map-interaction.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-02 21:08:04 -1000
committerGitHub <noreply@github.com>2026-04-02 21:08:04 -1000
commitbf2223827c53fbc0e77d1af2a7d4654a7c248ee0 (patch)
treeff3a4f5c4e9c0ec9bbefddee605821b1c80c92fd /docs/superpowers/plans/2026-04-03-map-interaction.md
parente9df7afa1d96fde80c482e497d7c17617c2d95c3 (diff)
feat(map): interactive map with auto-follow, recenter button, and UI immersive mode (#2)
* docs: add map interaction design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add map interaction implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): add isFollowing state and gesture-driven manual mode to MapHandler * feat(ui): add fab_recenter pill button to map layout * feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing * fix(map): prevent fadeIn flash on cold start; consolidate fab_mob listener * fix(map): preserve user zoom level on recenter Capture the current camera zoom when the user gestures (entering manual mode) and pass it back to centerOnLocation in recenter(), so tapping Recenter returns to the user's chosen zoom rather than always snapping to the default 14. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): capture lastZoom on camera idle, not gesture start OnCameraMoveStartedListener fires before the gesture completes, so it captured the pre-gesture zoom. OnCameraIdleListener fires after the camera settles, giving the user's final intended zoom level. Only update lastZoom while in manual mode (isFollowing=false). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): guard recenter against null island and add KDoc - Skip recenter() if no GPS fix received (lastLat/lastLon still 0.0) to avoid animating to 0°N, 0°E - Add KDoc comment to recenter() consistent with other public methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-04-03-map-interaction.md')
-rw-r--r--docs/superpowers/plans/2026-04-03-map-interaction.md256
1 files changed, 256 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-03-map-interaction.md b/docs/superpowers/plans/2026-04-03-map-interaction.md
new file mode 100644
index 0000000..9f8fa13
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-03-map-interaction.md
@@ -0,0 +1,256 @@
+# Map Interaction Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add manual pan/zoom with auto-follow GPS centering, a Recenter button that appears when the user pans away, and full UI fade-out in manual mode.
+
+**Architecture:** `MapHandler` owns an `isFollowing: StateFlow<Boolean>` and registers a `MapLibreMap.OnCameraMoveStartedListener` to detect gesture-driven camera moves. `MainActivity` collects the flow and animates the bottom sheet, nav bar, FABs, and recenter button in/out.
+
+**Tech Stack:** MapLibre Android SDK (`MapLibreMap.OnCameraMoveStartedListener`, `REASON_API_GESTURE`), Kotlin `StateFlow`, Android `View.animate()`.
+
+---
+
+## File Map
+
+| File | What changes |
+|------|-------------|
+| `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon`, gesture listener, `recenter()`, guard in `centerOnLocation()` |
+| `android-app/app/src/main/res/layout/activity_main.xml` | Add `fab_recenter` pill button inside the map ConstraintLayout |
+| `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click |
+
+---
+
+### Task 1: Extend MapHandler with follow state and gesture detection
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`
+
+- [ ] **Step 1: Add imports and new fields**
+
+Open `MapHandler.kt`. Add these imports at the top (after existing imports):
+
+```kotlin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.maplibre.android.maps.MapLibreMap
+```
+
+Add these fields inside the `MapHandler` class, before `setupLayers`:
+
+```kotlin
+private val _isFollowing = MutableStateFlow(true)
+val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+private var lastLat: Double = 0.0
+private var lastLon: Double = 0.0
+```
+
+- [ ] **Step 2: Register the gesture listener in the constructor**
+
+Replace the class declaration line:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+```
+with:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ }
+```
+
+- [ ] **Step 3: Guard centerOnLocation and store last position**
+
+Replace the existing `centerOnLocation` method:
+```kotlin
+fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+ val position = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(zoom)
+ .build()
+ maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+}
+```
+
+- [ ] **Step 4: Add recenter()**
+
+Add this method after `centerOnLocation`:
+```kotlin
+fun recenter() {
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon)
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+git commit -m "feat(map): add isFollowing state and gesture-driven manual mode to MapHandler"
+```
+
+---
+
+### Task 2: Add the Recenter button to the layout
+
+**Files:**
+- Modify: `android-app/app/src/main/res/layout/activity_main.xml`
+
+- [ ] **Step 1: Add fab_recenter inside the ConstraintLayout**
+
+In `activity_main.xml`, find the ConstraintLayout that wraps `mapView` (around line 10). It currently contains `mapView` and `fragment_container`. Add the recenter button as a third child, before the closing `</androidx.constraintlayout.widget.ConstraintLayout>` tag:
+
+```xml
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginBottom="24dp" />
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android-app/app/src/main/res/layout/activity_main.xml
+git commit -m "feat(ui): add fab_recenter pill button to map layout"
+```
+
+---
+
+### Task 3: Wire MainActivity to animate UI on follow state changes
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`
+
+- [ ] **Step 1: Add imports**
+
+Add these imports to `MainActivity.kt` (with existing imports):
+```kotlin
+import androidx.cardview.widget.CardView
+import com.google.android.material.button.MaterialButton
+```
+
+- [ ] **Step 2: Add view fields**
+
+In the `MainActivity` class body, alongside the existing `fabRecordTrack` declaration, add:
+```kotlin
+private lateinit var fabMob: FloatingActionButton
+private lateinit var fabRecenter: MaterialButton
+private lateinit var bottomSheet: CardView
+private lateinit var bottomNav: BottomNavigationView
+```
+
+- [ ] **Step 3: Add the fade helpers**
+
+Add these two private methods to `MainActivity` (before `onStart`):
+
+```kotlin
+private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+}
+
+private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
+ }
+}
+```
+
+- [ ] **Step 4: Wire up views and observe isFollowing in initializeUI**
+
+In `initializeUI()`, after the existing `fabRecordTrack` setup, add:
+
+```kotlin
+fabMob = findViewById(R.id.fab_mob)
+fabRecenter = findViewById(R.id.fab_recenter)
+bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+bottomNav = findViewById(R.id.bottom_navigation)
+
+fabRecenter.setOnClickListener {
+ mapHandler?.recenter()
+}
+```
+
+- [ ] **Step 5: Observe isFollowing after mapHandler is created**
+
+In `setupMap()`, inside the `getMapAsync` lambda, after `mapHandler = MapHandler(maplibreMap)`, add:
+
+```kotlin
+lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack)
+ } else {
+ fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Manual smoke test**
+
+Build and install. Verify:
+1. App opens — bottom sheet, nav, FABs visible; no Recenter button
+2. Pan the map — all UI fades out, Recenter button fades in
+3. Tap Recenter — UI fades back in, map animates to GPS position, Recenter gone
+4. GPS updates while in manual mode — map does NOT jump back to GPS position
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+git commit -m "feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing"
+```
+
+---
+
+### Task 4: Request Gemini review, fix issues, loop until clean, merge
+
+- [ ] **Step 1: Push branch and open PR**
+```bash
+git push local main
+git push github main
+```
+Then open a PR (or request review inline if working on main).
+
+- [ ] **Step 2: Request code review using code-review:code-review skill**
+
+Invoke `code-review:code-review` skill against the PR. Address all issues with confidence ≥ 80.
+
+- [ ] **Step 3: Loop until review returns clean**
+
+Re-request review after each fix. Stop when no issues ≥ 80 are found.
+
+- [ ] **Step 4: Merge and push to both remotes**
+```bash
+gh pr merge <number> --repo thepeterstone/nav --squash --delete-branch
+git checkout main && git pull github main
+git push local main
+```