diff options
5 files changed, 411 insertions, 4 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index f9d4dbd..252761e 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -15,9 +15,11 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.button.MaterialButton import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import kotlinx.coroutines.Dispatchers @@ -53,6 +55,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> private lateinit var fragmentContainer: FrameLayout private lateinit var fabRecordTrack: FloatingActionButton + private lateinit var fabMob: FloatingActionButton + private lateinit var fabRecenter: MaterialButton + private lateinit var bottomSheet: CardView + private lateinit var bottomNav: BottomNavigationView private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -83,14 +89,20 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupBottomNavigation() setupHandlers() - findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener { - onActivateMob() - } - fabRecordTrack = findViewById(R.id.fab_record_track) fabRecordTrack.setOnClickListener { if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() } + + fabMob = findViewById(R.id.fab_mob) + fabMob.setOnClickListener { onActivateMob() } + fabRecenter = findViewById(R.id.fab_recenter) + bottomSheet = findViewById(R.id.instrument_bottom_sheet) + bottomNav = findViewById(R.id.bottom_navigation) + + fabRecenter.setOnClickListener { + mapHandler?.recenter() + } // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -232,6 +244,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapView?.onCreate(null) mapView?.getMapAsync { maplibreMap -> mapHandler = MapHandler(maplibreMap) + 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) + } + } + } val style = Style.Builder() .fromUri("https://tiles.openfreemap.org/styles/liberty") .withSource(RasterSource("openseamap-source", @@ -309,6 +332,23 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { return PolarTable(curves) } + 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 -> + if (v.visibility == View.VISIBLE && v.alpha == 1f) return@forEach + v.alpha = 0f + v.visibility = View.VISIBLE + v.animate().alpha(1f).setDuration(150).start() + } + } + override fun onStart() { super.onStart(); mapView?.onStart() } override fun onPause() { super.onPause(); mapView?.onPause() } override fun onStop() { super.onStop(); mapView?.onStop() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index cbc2e90..7c82808 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -24,12 +24,35 @@ import org.terst.nav.TidalCurrentState import org.terst.nav.track.TrackPoint import kotlin.math.cos import kotlin.math.sin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Handles MapLibre initialization, layers, and updates. */ class MapHandler(private val maplibreMap: MapLibreMap) { + init { + maplibreMap.addOnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { + _isFollowing.value = false + } + } + maplibreMap.addOnCameraIdleListener { + if (!_isFollowing.value) { + lastZoom = maplibreMap.cameraPosition.zoom + } + } + } + + private val _isFollowing = MutableStateFlow(true) + val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow() + + private var lastLat: Double = 0.0 + private var lastLon: Double = 0.0 + private var lastZoom: Double = 14.0 + private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source" private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source" private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer" @@ -95,6 +118,9 @@ class MapHandler(private val maplibreMap: MapLibreMap) { * Centers the map on the specified location. */ 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) @@ -103,6 +129,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) { } /** + * Restores auto-follow mode and animates the camera back to the last known GPS position. + * No-op if no GPS fix has been received yet. + */ + fun recenter() { + if (lastLat == 0.0 && lastLon == 0.0) return + _isFollowing.value = true + centerOnLocation(lastLat, lastLon, lastZoom) + } + + /** * Updates the anchor watch visualization on the map. */ fun updateAnchorWatch(state: AnchorWatchState) { diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index b4221ed..a3d347f 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -25,6 +25,22 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + <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" /> + </androidx.constraintlayout.widget.ConstraintLayout> <!-- Collapsible Instrument Bottom Sheet --> 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 +``` diff --git a/docs/superpowers/specs/2026-04-03-map-interaction-design.md b/docs/superpowers/specs/2026-04-03-map-interaction-design.md new file mode 100644 index 0000000..02e38e1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-map-interaction-design.md @@ -0,0 +1,59 @@ +# Map Interaction Design +_2026-04-03_ + +## Overview + +Enable free map pan/zoom while keeping GPS auto-follow as the default. When the user gestures on the map, the UI hides and a Recenter button appears. Tapping Recenter restores auto-follow and the full UI. + +## State + +A single `isFollowing: Boolean` flag owned by `MapHandler`. Starts `true`. This is the only piece of new state. + +## Entering Manual Mode + +`MapLibreMap.addOnCameraMoveStartedListener` fires with `REASON_API_GESTURE` when the user initiates a pan, pinch-zoom, or rotate. On that event: + +- `MapHandler.isFollowing` → `false` +- `MapHandler` emits via a new `StateFlow<Boolean> isFollowing` exposed to MainActivity +- `MapHandler.centerOnLocation()` becomes a no-op while `!isFollowing` — GPS updates still arrive, last position is stored + +## Returning to Auto-Follow + +`fab_recenter` click handler: + +- Calls `MapHandler.recenter()` — sets `isFollowing = true`, calls `centerOnLocation(lastLat, lastLon)` +- `isFollowing` StateFlow emits `true` → MainActivity restores UI + +## UI Changes + +### New element: `fab_recenter` + +- Pill-shaped button (`wrap_content` width, 40dp height, 20dp corner radius) +- Label: "⊙ Recenter" +- Position: centered horizontally, `24dp` above the bottom of the map `ConstraintLayout` +- `android:visibility="gone"` by default +- Elevation: 20dp (above the instrument sheet) + +### Visibility toggling (MainActivity) + +When `isFollowing` → `false`: +- Fade out (alpha 0, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track` +- Show `fab_recenter` (visibility VISIBLE, fade in 150ms) + +When `isFollowing` → `true`: +- Hide `fab_recenter` (fade out 150ms, then GONE) +- Fade in (alpha 1, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track` + +## Files to Change + +| File | Change | +|------|--------| +| `MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon` storage, `recenter()`, guard in `centerOnLocation()`, register `OnCameraMoveStartedListener` | +| `activity_main.xml` | Add `fab_recenter` pill button | +| `MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click | + +## Out of Scope + +- Tapping the map (without gesture) to restore UI — not requested +- Timeout to auto-restore UI — not requested +- Zoom-level persistence across recenter — not requested |
