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 ++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) (limited to 'android-app/app/src/main/kotlin/org') 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. */ -- cgit v1.2.3