summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt48
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt36
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml16
-rw-r--r--docs/superpowers/plans/2026-04-03-map-interaction.md256
-rw-r--r--docs/superpowers/specs/2026-04-03-map-interaction-design.md59
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