From ca57e40adc0b89e7dc5409475f7510c0c188d715 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 18:09:53 +0000 Subject: feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 66d1abe..552bf99 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -68,4 +68,17 @@ app:layout_anchor="@id/bottom_navigation" app:layout_anchorGravity="top|start" /> + + + -- cgit v1.2.3 From 2e0c420020526873906fedd024b50baef56011fa Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 2 Apr 2026 21:53:08 +0000 Subject: fix(ui): anchor both FABs to instrument sheet (squash merge recovery) Both fab_mob and fab_record_track now anchor to instrument_bottom_sheet so they sit above the sheet peek zone rather than being occluded by the sheet's 16dp elevation. Resolves stash conflict in settings.local.json. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 4 +++- android-app/app/src/main/res/layout/activity_main.xml | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bcb3577..4fe9a49 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -57,7 +57,9 @@ "Bash(screen -list)", "Bash(adb logcat:*)", "Bash(grep -v \"^--$\")", - "Bash(./gradlew assembleDebug)" + "Bash(./gradlew assembleDebug)", + "Skill(commit-commands:commit-push-pr)", + "Skill(code-review:code-review)" ] } } 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 552bf99..68abc60 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -65,7 +65,7 @@ android:contentDescription="Man Overboard" app:srcCompat="@android:drawable/ic_dialog_alert" app:backgroundTint="@color/mob_button_background" - app:layout_anchor="@id/bottom_navigation" + app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|start" /> @@ -78,7 +78,7 @@ android:focusable="true" android:contentDescription="Record Track" app:srcCompat="@drawable/ic_track_record" - app:layout_anchor="@id/bottom_navigation" + app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|end" /> -- cgit v1.2.3 From 04d990f8066312e79532cad9f70f7b4c92a3d9c8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 02:44:25 +0000 Subject: fix(ui): raise both FABs fully above instrument sheet top edge anchorGravity=top centers the FAB on the sheet edge, leaving half the button occluded. Increase marginBottom to 44dp (28dp to clear the FAB radius + 16dp gap) so both buttons sit fully above the sheet. Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 68abc60..edac466 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,7 +59,8 @@ android:id="@+id/fab_mob" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_margin="16dp" + android:layout_marginStart="16dp" + android:layout_marginBottom="44dp" android:clickable="true" android:focusable="true" android:contentDescription="Man Overboard" @@ -73,7 +74,8 @@ android:id="@+id/fab_record_track" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_margin="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="44dp" android:clickable="true" android:focusable="true" android:contentDescription="Record Track" -- cgit v1.2.3 From e9df7afa1d96fde80c482e497d7c17617c2d95c3 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 02:58:19 +0000 Subject: fix(ui): raise FAB elevation above CardView sheet to fix z-order The instrument sheet CardView has cardElevation=16dp which was rendering on top of the FABs (default ~6dp elevation). Set app:elevation=20dp on both FABs so they always appear above the sheet. Also reverts oversized marginBottom back to standard 16dp all-round now that elevation stacking is correct. Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 edac466..b4221ed 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,13 +59,13 @@ android:id="@+id/fab_mob" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginBottom="44dp" + android:layout_margin="16dp" android:clickable="true" android:focusable="true" android:contentDescription="Man Overboard" app:srcCompat="@android:drawable/ic_dialog_alert" app:backgroundTint="@color/mob_button_background" + app:elevation="20dp" app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|start" /> @@ -74,12 +74,12 @@ android:id="@+id/fab_record_track" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="16dp" - android:layout_marginBottom="44dp" + android:layout_margin="16dp" android:clickable="true" android:focusable="true" android:contentDescription="Record Track" app:srcCompat="@drawable/ic_track_record" + app:elevation="20dp" app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|end" /> -- cgit v1.2.3 From bf2223827c53fbc0e77d1af2a7d4654a7c248ee0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 2 Apr 2026 21:08:04 -1000 Subject: feat(map): interactive map with auto-follow, recenter button, and UI immersive mode (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add map interaction design spec Co-Authored-By: Claude Sonnet 4.6 * docs: add map interaction implementation plan Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 48 +++- .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 36 +++ .../app/src/main/res/layout/activity_main.xml | 16 ++ .../plans/2026-04-03-map-interaction.md | 256 +++++++++++++++++++++ .../specs/2026-04-03-map-interaction-design.md | 59 +++++ 5 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-03-map-interaction.md create mode 100644 docs/superpowers/specs/2026-04-03-map-interaction-design.md (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 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(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 = _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) @@ -102,6 +128,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) { maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000) } + /** + * 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. */ 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" /> + + 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` 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 = _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 `` tag: + +```xml + +``` + +- [ ] **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 --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 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 -- cgit v1.2.3 From a8d851e5bfb78b065f10d457bf3ce8f2c771bb4c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 4 Apr 2026 04:53:50 +0000 Subject: refactor(ui): stabilize layout by moving BottomNav outside CoordinatorLayout --- .../app/src/main/res/layout/activity_main.xml | 158 +++++++++++---------- 1 file changed, 82 insertions(+), 76 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 a3d347f..b8df5c9 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -1,102 +1,108 @@ - - - + android:layout_height="0dp" + android:layout_weight="1"> - + + android:layout_height="match_parent"> - - + - + + - + - - + - + + android:layout_height="wrap_content" + app:behavior_hideable="false" + app:behavior_peekHeight="120dp" + app:cardElevation="16dp" + app:cardCornerRadius="24dp" + app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - + + + + + + + + + + + - - - - - - - + -- cgit v1.2.3 From 5ee2dd8925afa858f466ae63db3f7df5c7516953 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 7 Apr 2026 06:42:51 +0000 Subject: fix(track): fix silent GPX save failure + add stop friction + quit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackStorage: openOutputStream null returned true (file never written). Added IS_PENDING flag to fix Android 10-11 race where insert succeeds but file isn't physically created yet. Added storage-mounted guard. TrackRepository now logs save failures. Stop tracking now requires a long press (haptic feedback) — prevents accidental mid-sail stops from a single tap. Quit button (top-right, tonal X) stops LocationService and calls finishAffinity(). Prompts if a track is in progress. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 32 +++++++++++++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 4 ++- .../kotlin/org/terst/nav/track/TrackStorage.kt | 39 ++++++++++++++++++++-- .../app/src/main/res/layout/activity_main.xml | 18 ++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 0309364..6263e13 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 @@ -9,6 +9,7 @@ import android.media.MediaPlayer import android.os.Build import android.os.Bundle import android.util.Log +import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout import androidx.activity.result.contract.ActivityResultContracts @@ -51,6 +52,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var fabRecordTrack: FloatingActionButton private lateinit var fabMob: FloatingActionButton private lateinit var fabRecenter: MaterialButton + private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } @@ -81,6 +83,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack = findViewById(R.id.fab_record_track) fabMob = findViewById(R.id.fab_mob) fabRecenter = findViewById(R.id.fab_recenter) + btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) @@ -89,8 +92,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupHandlers() setupMap() + // Single tap starts; long press stops (requires deliberate intent mid-sail) fabRecordTrack.setOnClickListener { - if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() + if (!viewModel.isRecording.value) viewModel.startTrack() + } + fabRecordTrack.setOnLongClickListener { + if (viewModel.isRecording.value) { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + viewModel.stopTrack() + true + } else false } fabMob.setOnClickListener { onActivateMob() } @@ -98,6 +109,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecenter.setOnClickListener { mapHandler?.recenter() } + + btnQuit.setOnClickListener { onQuitRequested() } // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -177,6 +190,23 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { showOverlay(fragment) } + private fun onQuitRequested() { + if (viewModel.isRecording.value) { + androidx.appcompat.app.AlertDialog.Builder(this) + .setMessage("Recording in progress. Quit and discard the current track?") + .setPositiveButton("Quit") { _, _ -> exitApp() } + .setNegativeButton("Cancel", null) + .show() + } else { + exitApp() + } + } + + private fun exitApp() { + stopService(Intent(this, LocationService::class.java)) + finishAffinity() + } + override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 228a484..7ef67af 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -1,6 +1,7 @@ package org.terst.nav.track import android.content.Context +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -45,7 +46,8 @@ class TrackRepository(context: Context) { if (summary.durationMs < 2 * 60_000L) return@withContext null _pastTracks.add(0, points) - storage.saveTrack(points, trackStartMs) + val saved = storage.saveTrack(points, trackStartMs) + if (!saved) Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart") summary } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt index 620431c..08e1dc9 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt @@ -5,11 +5,14 @@ import android.content.Context import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import java.io.File import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +private const val TAG = "TrackStorage" + /** * Persists completed tracks as GPX files in the shared Documents/Nav/ folder. * @@ -56,19 +59,49 @@ class TrackStorage(private val context: Context) { // ── API 29+ ────────────────────────────────────────────────────────────── private fun saveViaMediaStore(fileName: String, gpx: String): Boolean { + // Guard: external storage must be mounted before touching MediaStore + val storageState = Environment.getExternalStorageState() + if (storageState != Environment.MEDIA_MOUNTED) { + Log.e(TAG, "External storage not mounted (state=$storageState) — cannot save $fileName") + return false + } + + // IS_PENDING marks the entry as in-progress, preventing a race condition on + // Android 10-11 where insert() succeeds but openOutputStream() returns null + // because the file hasn't been physically created on disk yet. val values = ContentValues().apply { put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml") put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/") + put(MediaStore.MediaColumns.IS_PENDING, 1) } val uri = context.contentResolver.insert( MediaStore.Files.getContentUri("external"), values - ) ?: return false + ) ?: run { + Log.e(TAG, "MediaStore insert returned null for $fileName") + return false + } return runCatching { - context.contentResolver.openOutputStream(uri)?.use { it.write(gpx.toByteArray()) } + val stream = context.contentResolver.openOutputStream(uri) + if (stream == null) { + context.contentResolver.delete(uri, null, null) + Log.e(TAG, "openOutputStream null for $fileName — deleted orphan entry") + return@runCatching false + } + stream.use { it.write(gpx.toByteArray()) } + + // Clear IS_PENDING so the file is visible to other apps and file managers + val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + context.contentResolver.update(uri, update, null, null) + + Log.d(TAG, "Saved $fileName (${gpx.length} bytes) → $uri") true - }.getOrDefault(false) + }.getOrElse { e -> + Log.e(TAG, "Write failed for $fileName: ${e.message}") + runCatching { context.contentResolver.delete(uri, null, null) } + false + } } private fun loadViaMediaStore(): List> { 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 b8df5c9..0734476 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -30,6 +30,24 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + + +