summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-20 11:20:35 -1000
committerGitHub <noreply@github.com>2026-05-20 11:20:35 -1000
commit6c8ca71f3acc0686bbd351c53e4ea10a0e6e3c2d (patch)
tree482a355bb4b176ec2c23d72c3566d5f31573ee03 /android-app
parent4b0a4c76d94ace1593d66f6fd485b88b3b6c4994 (diff)
Optimize nav location tracking: embedded track detail map, tack detection fixes, dim past tracks
* Fix tack detection and convert track detail to embedded-map fragment TackDetector: revert STABILITY_MAX to 30° (was tightened too far to 20°), add 120s start-skip to filter GPS cold-start false positives, keep 45s minimum gap between tacks. TrackDetailSheet: convert from BottomSheetDialogFragment to a regular Fragment with an embedded MapLibreMap in the top half. This fixes the map centering issue (the embedded map is always sized to its own frame), eliminates accidental dismissal when dragging the map, and makes the view self-contained — back navigation restores the track list via the fragment back stack. Tack markers rendered on the embedded map; tapping a log event pans the embedded map directly. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn * Fix XML conflict markers left in layout_track_detail_sheet.xml https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn * Fix Kotlin conflict markers left in TrackDetailSheet.kt https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn * Fix compile errors on feature branch - SavedTracksFragment/VoiceLogFragment: remove references to SafState, safState, launchSafPicker, layout_empty, btn_setup_storage, and layout_log_entry — none of these exist on this branch - TrackStorage: add missing onCancellation lambda to cont.resume() - build.gradle: enable buildConfig generation (fixes unresolved BuildConfig) https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn * Dim past tracks: dark navy 0.12 opacity, solid lines https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt96
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt2
-rw-r--r--android-app/app/src/main/res/layout/layout_track_detail_sheet.xml184
5 files changed, 195 insertions, 113 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
index 44055ce..67f32f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
@@ -34,18 +34,20 @@ class SavedTracksFragment : Fragment() {
(requireActivity() as MainActivity).hideOverlays()
}
- val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
- val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
- val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
- val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
val adapter = SavedTrackAdapter { track ->
viewModel.selectTrack(track)
- (requireActivity() as MainActivity).hideOverlays()
- TrackDetailSheet.newInstance().show(parentFragmentManager, "track_detail")
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, TrackDetailSheet.newInstance())
+ .addToBackStack("track_detail")
+ .commit()
}
rv.adapter = adapter
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index 3c3385c..9668fd1 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -3,11 +3,12 @@ package org.terst.nav.track
import kotlin.math.abs
object TackDetector {
- private const val HALF_WIN = 2
- private const val MIN_DELTA = 60.0
- private const val MAX_DELTA = 140.0
- private const val STABILITY_MAX = 20.0
- private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks
+ private const val HALF_WIN = 2
+ private const val MIN_DELTA = 60.0
+ private const val MAX_DELTA = 140.0
+ private const val STABILITY_MAX = 30.0
+ private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks
+ private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise)
fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
if (points.size < 2 * HALF_WIN + 1) return emptyList()
@@ -16,6 +17,7 @@ object TackDetector {
var lastTackMs = Long.MIN_VALUE
for (i in HALF_WIN until points.size - HALF_WIN) {
+ if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue
if (points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue
val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg))
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
index 98205fd..cedf4e1 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -5,13 +5,25 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
+import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.maplibre.android.camera.CameraUpdateFactory
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.geometry.LatLngBounds
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.MapView
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.LineLayer
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
+import org.maplibre.geojson.Point
import org.terst.nav.R
import org.terst.nav.ui.MainViewModel
import java.time.Instant
@@ -30,9 +42,11 @@ data class LogEvent(
val detail: String
)
-class TrackDetailSheet : BottomSheetDialogFragment() {
+class TrackDetailSheet : Fragment() {
private val viewModel: MainViewModel by activityViewModels()
+ private lateinit var trackMapView: MapView
+ private var trackMap: MapLibreMap? = null
companion object {
fun newInstance() = TrackDetailSheet()
@@ -43,11 +57,13 @@ class TrackDetailSheet : BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val track = viewModel.selectedTrack.value ?: run {
- dismiss()
+ parentFragmentManager.popBackStack()
return
}
- view.findViewById<View>(R.id.btn_back).setOnClickListener { dismiss() }
+ view.findViewById<View>(R.id.btn_back).setOnClickListener {
+ parentFragmentManager.popBackStack()
+ }
val titleFmt = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
@@ -68,20 +84,72 @@ class TrackDetailSheet : BottomSheetDialogFragment() {
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
rv.adapter = LogEventAdapter(events) { event ->
- viewModel.panToTrackPoint(event.lat, event.lon)
+ trackMap?.easeCamera(CameraUpdateFactory.newLatLng(LatLng(event.lat, event.lon)), 300)
}
- (dialog as? BottomSheetDialog)?.behavior?.apply {
- isFitToContents = false
- halfExpandedRatio = 0.5f
- skipCollapsed = true
- isDraggable = false
- state = BottomSheetBehavior.STATE_HALF_EXPANDED
+ trackMapView = view.findViewById(R.id.track_map_view)
+ trackMapView.onCreate(savedInstanceState)
+ trackMapView.getMapAsync { map ->
+ trackMap = map
+ map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/bright")) { style ->
+ drawTrack(style, track.points)
+ drawTacks(style, track.tacks)
+ zoomToTrack(map, track.points)
+ }
}
- dialog?.window?.setDimAmount(0.2f)
+ }
+
+ private fun drawTrack(style: Style, points: List<TrackPoint>) {
+ if (points.size < 2) return
+ val source = GeoJsonSource("track-detail-source",
+ Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })))
+ style.addSource(source)
+ style.addLayer(LineLayer("track-detail-layer", "track-detail-source").apply {
+ setProperties(
+ PropertyFactory.lineColor("#2B8FC4"),
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
+ )
+ })
+ }
+
+ private fun drawTacks(style: Style, tacks: List<TackEvent>) {
+ if (tacks.isEmpty()) return
+ val features = tacks.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) }
+ val source = GeoJsonSource("tack-detail-source", FeatureCollection.fromFeatures(features))
+ style.addSource(source)
+ style.addLayer(CircleLayer("tack-detail-layer", "tack-detail-source").apply {
+ setProperties(
+ PropertyFactory.circleColor("#FDD835"),
+ PropertyFactory.circleRadius(7f),
+ PropertyFactory.circleStrokeColor("#F57F17"),
+ PropertyFactory.circleStrokeWidth(2f)
+ )
+ })
+ }
+
+ private fun zoomToTrack(map: MapLibreMap, points: List<TrackPoint>) {
+ if (points.size < 2) return
+ val bounds = LatLngBounds.Builder().apply {
+ points.forEach { include(LatLng(it.lat, it.lon)) }
+ }.build()
+ map.easeCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80), 600)
+ }
+
+ override fun onStart() { super.onStart(); if (::trackMapView.isInitialized) trackMapView.onStart() }
+ override fun onResume() { super.onResume(); if (::trackMapView.isInitialized) trackMapView.onResume() }
+ override fun onPause() { super.onPause(); if (::trackMapView.isInitialized) trackMapView.onPause() }
+ override fun onStop() { super.onStop(); if (::trackMapView.isInitialized) trackMapView.onStop() }
+ override fun onLowMemory() { super.onLowMemory(); if (::trackMapView.isInitialized) trackMapView.onLowMemory() }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ if (::trackMapView.isInitialized) trackMapView.onSaveInstanceState(outState)
}
override fun onDestroyView() {
+ if (::trackMapView.isInitialized) trackMapView.onDestroy()
super.onDestroyView()
viewModel.clearSelectedTrack()
}
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 0c31024..7614a49 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
@@ -315,7 +315,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
style.addLayer(LineLayer(TRACK_PAST_DIM_LAYER_ID, TRACK_PAST_DIM_SOURCE_ID).apply {
setProperties(
PropertyFactory.lineColor("#0B3050"),
- PropertyFactory.lineOpacity(0.22f),
+ PropertyFactory.lineOpacity(0.12f),
PropertyFactory.lineWidth(2.5f),
PropertyFactory.lineCap("round"),
PropertyFactory.lineJoin("round")
diff --git a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
index 97b1da8..993edf8 100644
--- a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
@@ -6,107 +6,117 @@
android:orientation="vertical"
android:background="?attr/colorSurface">
- <!-- Drag handle -->
- <View
- android:layout_width="36dp"
- android:layout_height="40dp"
- android:layout_gravity="center_horizontal"
- android:background="@drawable/drag_handle_bg" />
-
- <!-- Header -->
+ <!-- Track map — top half -->
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/track_map_view"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1" />
+
+ <!-- Detail panel — bottom half -->
<LinearLayout
android:layout_width="match_parent"
- android:layout_height="56dp"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:paddingStart="8dp"
- android:paddingEnd="16dp"
- android:elevation="4dp"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:orientation="vertical"
android:background="?attr/colorSurface">
- <ImageButton
- android:id="@+id/btn_back"
- android:layout_width="48dp"
- android:layout_height="48dp"
- android:src="@drawable/ic_back"
- android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Back" />
+ <!-- Header -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="56dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingStart="8dp"
+ android:paddingEnd="16dp"
+ android:elevation="4dp"
+ android:background="?attr/colorSurface">
- <TextView
- android:id="@+id/detail_title"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:textSize="17sp"
- android:textStyle="bold"
- android:layout_marginStart="4dp" />
+ <ImageButton
+ android:id="@+id/btn_back"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:src="@drawable/ic_back"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="Back" />
- </LinearLayout>
+ <TextView
+ android:id="@+id/detail_title"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:textSize="17sp"
+ android:textStyle="bold"
+ android:layout_marginStart="4dp" />
- <!-- Stats row: distance · duration · max · avg -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:paddingHorizontal="12dp"
- android:paddingTop="12dp"
- android:paddingBottom="12dp">
-
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
- </LinearLayout>
+ <!-- Stats row: distance · duration · max · avg -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingHorizontal="12dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp">
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
- </LinearLayout>
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
- </LinearLayout>
+ <View android:layout_width="match_parent" android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
- <View android:layout_width="match_parent" android:layout_height="1dp"
- android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="20dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="4dp"
+ android:text="Event Log"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant" />
- <TextView
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="20dp"
- android:paddingTop="12dp"
- android:paddingBottom="4dp"
- android:text="Event Log"
- android:textSize="13sp"
- android:textAllCaps="true"
- android:letterSpacing="0.08"
- android:textColor="?attr/colorOnSurfaceVariant" />
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/rv_log_entries"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1"
- android:clipToPadding="false"
- android:paddingBottom="24dp" />
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_log_entries"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:clipToPadding="false"
+ android:paddingBottom="24dp" />
+
+ </LinearLayout>
</LinearLayout>