summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-12 13:13:55 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-05-12 13:13:55 -1000
commitf881a1f91f4e9f832ab987fce2b5220e100cee68 (patch)
treecfa3d5e0c0f5e0d1cd2f74a49c20ce8c96b89ae2 /android-app/app/src
parent1fe6fc2b0c5cabf21c241f9a033e4d53dd475316 (diff)
Add saved tracks browser, tack detection, and map zoom-to-bounds
- TackDetector: sliding-window COG algorithm (60-140deg delta = tack/jibe) with stability check to prevent mixed-window false positives; 13 tests green - TackEvent, SavedTrack: new models wrapping points + summary + detected tacks - TrackRepository.getSavedTracks(): returns List<SavedTrack> with lazy load - MapHandler: zoomToTrackBounds (LatLngBounds fit with padding), panToPoint (log step-through), updateTackLayer (yellow circle markers) - MainViewModel: savedTracks + selectedTrack StateFlows, panToPosition SharedFlow - SavedTracksFragment: RecyclerView list with date/distance/duration/tack count - TrackDetailSheet: bottom sheet with stats + event log (tacks, speed changes, start/end); tapping an entry pans the map to that position - MainActivity: zoom map on track complete, wire Saved Tracks button in instrument sheet, observe panToPosition to drive map, show tack markers when a saved track is selected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt36
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt101
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt48
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt160
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt25
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt59
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt0
-rw-r--r--android-app/app/src/main/res/drawable/ic_back.xml9
-rw-r--r--android-app/app/src/main/res/layout/fragment_saved_tracks.xml54
-rw-r--r--android-app/app/src/main/res/layout/item_log_entry.xml55
-rw-r--r--android-app/app/src/main/res/layout/item_saved_track.xml43
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml15
-rw-r--r--android-app/app/src/main/res/layout/layout_track_detail_sheet.xml91
-rw-r--r--android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt0
19 files changed, 733 insertions, 1 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 39e0696..0df4069 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
@@ -40,6 +40,8 @@ import org.maplibre.android.style.sources.TileSet
import org.terst.nav.settings.HardwareSource
import org.terst.nav.thermal.ThermalState
import org.terst.nav.thermal.thermalFlow
+import org.terst.nav.track.SavedTracksFragment
+import org.terst.nav.track.TrackDetailSheet
import org.terst.nav.ui.*
import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.map.ParticleWindView
@@ -216,11 +218,32 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
lifecycleScope.launch {
viewModel.trackSummary.collect { summary ->
+ // Zoom the map to show the completed track before the summary sheet appears
+ val justCompleted = viewModel.pastTracks.value.firstOrNull()
+ if (justCompleted != null) {
+ val style = loadedStyleFlow.value
+ if (style != null) mapHandler?.zoomToTrackBounds(justCompleted)
+ }
org.terst.nav.track.TrackSummarySheet
.from(summary, viewModel.trackStartMs)
.show(supportFragmentManager, "track_summary")
}
}
+
+ // Pan the map when the user taps a log entry in TrackDetailSheet
+ lifecycleScope.launch {
+ viewModel.panToPosition.collect { (lat, lon) ->
+ mapHandler?.panToPoint(lat, lon)
+ }
+ }
+
+ // "Saved Tracks" button in the instrument sheet
+ findViewById<View>(R.id.btn_saved_tracks).setOnClickListener {
+ showOverlay(SavedTracksFragment())
+ bottomSheetBehavior.isHideable = true
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+ bottomNav.selectedItemId = R.id.nav_map // keep map tab highlighted
+ }
}
/** Updates all static unit label TextViews to match current [unitPrefs]. */
@@ -676,6 +699,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
mapHandler?.updateTrackLayer(style, active, visiblePast)
}
}
+
+ // When a saved track is selected: zoom to it and show tack markers
+ lifecycleScope.launch {
+ viewModel.selectedTrack.collect { track ->
+ val style = loadedStyleFlow.value ?: return@collect
+ if (track != null) {
+ mapHandler?.zoomToTrackBounds(track.points)
+ mapHandler?.updateTackLayer(style, track.tacks)
+ } else {
+ mapHandler?.clearTackLayer()
+ }
+ }
+ }
}
private fun rasterizeDrawable(drawableId: Int): Bitmap {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt
new file mode 100644
index 0000000..423592d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.track
+
+data class SavedTrack(
+ val points: List<TrackPoint>,
+ val startMs: Long,
+ val summary: TrackSummary,
+ val tacks: List<TackEvent>
+) {
+ val endMs: Long get() = points.lastOrNull()?.timestampMs ?: startMs
+}
+
+fun List<TrackPoint>.toSavedTrack(): SavedTrack {
+ require(isNotEmpty())
+ return SavedTrack(
+ points = this,
+ startMs = first().timestampMs,
+ summary = summarise(this),
+ tacks = TackDetector.detectTacks(this)
+ )
+}
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
new file mode 100644
index 0000000..b73b034
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
@@ -0,0 +1,101 @@
+package org.terst.nav.track
+
+import android.os.Bundle
+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.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
+import org.terst.nav.R
+import org.terst.nav.ui.MainViewModel
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+class SavedTracksFragment : Fragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.fragment_saved_tracks, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ view.findViewById<View>(R.id.btn_back).setOnClickListener {
+ parentFragmentManager.popBackStack()
+ }
+
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val empty = view.findViewById<TextView>(R.id.tv_empty)
+ rv.layoutManager = LinearLayoutManager(requireContext())
+ rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+
+ val adapter = SavedTrackAdapter { track ->
+ viewModel.selectTrack(track)
+ parentFragmentManager.popBackStack()
+ TrackDetailSheet.from(track).show(parentFragmentManager, "track_detail")
+ }
+ rv.adapter = adapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.savedTracks.collect { tracks ->
+ adapter.submitList(tracks)
+ empty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
+ rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+ }
+}
+
+private val DATE_FMT: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("EEE dd MMM yyyy · HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+
+class SavedTrackAdapter(
+ private val onSelect: (SavedTrack) -> Unit
+) : RecyclerView.Adapter<SavedTrackAdapter.VH>() {
+
+ private var items: List<SavedTrack> = emptyList()
+
+ fun submitList(list: List<SavedTrack>) {
+ items = list
+ notifyDataSetChanged()
+ }
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ val tvDate = view.findViewById<TextView>(R.id.tv_track_date)
+ val tvStats = view.findViewById<TextView>(R.id.tv_track_stats)
+ val tvTacks = view.findViewById<TextView>(R.id.tv_track_tacks)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(LayoutInflater.from(parent.context).inflate(R.layout.item_saved_track, parent, false))
+
+ override fun getItemCount() = items.size
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val track = items[position]
+ holder.tvDate.text = DATE_FMT.format(Instant.ofEpochMilli(track.startMs))
+
+ val s = track.summary
+ val hours = s.durationMs / 3_600_000
+ val minutes = (s.durationMs % 3_600_000) / 60_000
+ val duration = if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+ holder.tvStats.text = "%.1f nm · %s · avg %.1f kt".format(s.distanceNm, duration, s.avgSogKt)
+
+ if (track.tacks.isNotEmpty()) {
+ holder.tvTacks.text = "${track.tacks.size} tacks"
+ holder.tvTacks.visibility = View.VISIBLE
+ } else {
+ holder.tvTacks.visibility = View.GONE
+ }
+
+ holder.itemView.setOnClickListener { onSelect(track) }
+ }
+}
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
new file mode 100644
index 0000000..6616acf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -0,0 +1,48 @@
+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 SKIP_AFTER = 5
+ private const val STABILITY_MAX = 30.0
+
+ fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
+ if (points.size < 2 * HALF_WIN + 1) return emptyList()
+
+ val results = mutableListOf<TackEvent>()
+ var nextCandidate = 0
+
+ for (i in HALF_WIN until points.size - HALF_WIN) {
+ if (i < nextCandidate) continue
+
+ val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg))
+ val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg))
+ if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue
+
+ val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg)
+ val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg)
+ val delta = abs(angleDiff(cogBefore, cogAfter))
+
+ if (delta in MIN_DELTA..MAX_DELTA) {
+ results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter)
+ nextCandidate = i + SKIP_AFTER
+ }
+ }
+ return results
+ }
+
+ internal fun angleDiff(from: Double, to: Double): Double {
+ var diff = to - from
+ while (diff > 180) diff -= 360
+ while (diff < -180) diff += 360
+ return diff
+ }
+
+ private fun circularMean(a: Double, b: Double): Double {
+ val half = angleDiff(a, b) / 2.0
+ return ((a + half) % 360.0 + 360.0) % 360.0
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt
new file mode 100644
index 0000000..7c0bf85
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt
@@ -0,0 +1,9 @@
+package org.terst.nav.track
+
+data class TackEvent(
+ val index: Int,
+ val lat: Double,
+ val lon: Double,
+ val cogBefore: Double,
+ val cogAfter: Double
+)
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
new file mode 100644
index 0000000..0ec42f7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -0,0 +1,160 @@
+package org.terst.nav.track
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+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.BottomSheetDialogFragment
+import org.terst.nav.R
+import org.terst.nav.ui.MainViewModel
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.math.abs
+
+/** A notable event extracted from a track for display in the log step-through. */
+data class LogEvent(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val icon: String,
+ val label: String,
+ val detail: String
+)
+
+class TrackDetailSheet : BottomSheetDialogFragment() {
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ companion object {
+ private const val ARG_TRACK_INDEX = "track_index"
+
+ fun from(track: SavedTrack): TrackDetailSheet {
+ // We pass the track via the shared ViewModel — the BottomSheet reads selectedTrack.
+ return TrackDetailSheet()
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_track_detail_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val track = viewModel.selectedTrack.value ?: run { dismiss(); return }
+
+ val titleFmt = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ view.findViewById<TextView>(R.id.detail_title).text =
+ "Track · ${titleFmt.format(Instant.ofEpochMilli(track.startMs))}"
+
+ val s = track.summary
+ view.findViewById<TextView>(R.id.detail_distance).text = "%.1f".format(s.distanceNm)
+ view.findViewById<TextView>(R.id.detail_max_sog).text = "%.1f".format(s.maxSogKt)
+ view.findViewById<TextView>(R.id.detail_avg_sog).text = "%.1f".format(s.avgSogKt)
+ val hours = s.durationMs / 3_600_000
+ val minutes = (s.durationMs % 3_600_000) / 60_000
+ view.findViewById<TextView>(R.id.detail_duration).text =
+ if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+
+ val events = buildLogEvents(track)
+ val rv = view.findViewById<RecyclerView>(R.id.rv_log_entries)
+ rv.layoutManager = LinearLayoutManager(requireContext())
+ rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+ rv.adapter = LogEventAdapter(events) { event ->
+ viewModel.panToTrackPoint(event.lat, event.lon)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ viewModel.clearSelectedTrack()
+ }
+
+ private fun buildLogEvents(track: SavedTrack): List<LogEvent> {
+ val events = mutableListOf<LogEvent>()
+ val points = track.points
+ val tacks = track.tacks
+
+ // Start / end
+ points.firstOrNull()?.let { p ->
+ events += LogEvent(p.lat, p.lon, p.timestampMs, "🚩", "Start", formatPointDetail(p))
+ }
+
+ // Tacks — detected maneuvers
+ for (t in tacks) {
+ val p = points[t.index]
+ val delta = TackDetector.angleDiff(t.cogBefore, t.cogAfter)
+ val type = if (abs(delta) > 70) "Tack" else "Jibe"
+ val dir = if (delta > 0) "→ stbd" else "→ port"
+ events += LogEvent(p.lat, p.lon, p.timestampMs, "⬡",
+ "$type $dir",
+ "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta)))
+ }
+
+ // Significant speed changes (> 1.5 kt shift sustained over 2 consecutive points)
+ for (i in 1 until points.size - 1) {
+ val prev = points[i - 1].sogKnots
+ val curr = points[i].sogKnots
+ val next = points[i + 1].sogKnots
+ val rise = curr - prev
+ val fall = curr - next
+ val p = points[i]
+ if (rise > 1.5 && rise > fall) {
+ events += LogEvent(p.lat, p.lon, p.timestampMs, "↑",
+ "Speed up", "%.1f → %.1f kt".format(prev, curr))
+ } else if (fall > 1.5 && fall > rise) {
+ events += LogEvent(p.lat, p.lon, p.timestampMs, "↓",
+ "Slow down", "%.1f → %.1f kt".format(curr, next))
+ }
+ }
+
+ points.lastOrNull()?.let { p ->
+ events += LogEvent(p.lat, p.lon, p.timestampMs, "🏁", "End", formatPointDetail(p))
+ }
+
+ return events.sortedBy { it.timestampMs }
+ }
+
+ private fun formatPointDetail(p: TrackPoint): String {
+ val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg))
+ p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) }
+ p.depthMeters?.let { parts += "D %.0f m".format(it) }
+ return parts.joinToString(" ")
+ }
+}
+
+private val TIME_FMT: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+
+private class LogEventAdapter(
+ private val events: List<LogEvent>,
+ private val onTap: (LogEvent) -> Unit
+) : RecyclerView.Adapter<LogEventAdapter.VH>() {
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ val tvIcon = view.findViewById<TextView>(R.id.tv_log_icon)
+ val tvTime = view.findViewById<TextView>(R.id.tv_log_time)
+ val tvLabel = view.findViewById<TextView>(R.id.tv_log_label)
+ val tvDetail = view.findViewById<TextView>(R.id.tv_log_detail)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(LayoutInflater.from(parent.context).inflate(R.layout.item_log_entry, parent, false))
+
+ override fun getItemCount() = events.size
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val e = events[position]
+ holder.tvIcon.text = e.icon
+ holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs))
+ holder.tvLabel.text = e.label
+ holder.tvDetail.text = e.detail
+ holder.itemView.setOnClickListener { onTap(e) }
+ }
+}
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 1b4cf34..7f765a1 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
@@ -100,6 +100,15 @@ class TrackRepository(context: Context) {
_pastTracks.toList()
}
+ /**
+ * Returns all completed tracks as [SavedTrack] wrappers (summary + tacks pre-computed).
+ * Internally delegates to [getPastTracks]; the first call loads from disk.
+ */
+ suspend fun getSavedTracks(): List<SavedTrack> =
+ getPastTracks()
+ .filter { it.isNotEmpty() }
+ .map { it.toSavedTrack() }
+
companion object {
fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 3440.065 // Earth radius in nautical miles
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 0dc1705..cc51dd1 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -14,6 +14,7 @@ import org.terst.nav.ais.CpaThresholds
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.thermal.ThermalReading
import org.terst.nav.thermal.ThermalState
+import org.terst.nav.track.SavedTrack
import org.terst.nav.track.TrackPoint
import org.terst.nav.track.TrackRepository
import org.terst.nav.track.TrackStats
@@ -98,7 +99,10 @@ class MainViewModel(
init {
viewModelScope.launch {
runCatching { trackRepository.getPastTracks() }
- .onSuccess { _pastTracks.value = it }
+ .onSuccess {
+ _pastTracks.value = it
+ _savedTracks.value = trackRepository.getSavedTracks()
+ }
.onFailure { e -> NavLogger.e("track", "init: failed to load past tracks: ${e.javaClass.simpleName}: ${e.message}") }
}
}
@@ -129,6 +133,16 @@ class MainViewModel(
private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0)
val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow()
+ private val _savedTracks = MutableStateFlow<List<SavedTrack>>(emptyList())
+ val savedTracks: StateFlow<List<SavedTrack>> = _savedTracks.asStateFlow()
+
+ private val _selectedTrack = MutableStateFlow<SavedTrack?>(null)
+ val selectedTrack: StateFlow<SavedTrack?> = _selectedTrack.asStateFlow()
+
+ /** Emits a lat/lon whenever the user taps a log entry to pan the map there. */
+ private val _panToPosition = MutableSharedFlow<Pair<Double, Double>>(replay = 0)
+ val panToPosition: SharedFlow<Pair<Double, Double>> = _panToPosition.asSharedFlow()
+
fun startTrack() {
trackRepository.startTrack()
_trackPoints.value = emptyList()
@@ -142,6 +156,7 @@ class MainViewModel(
viewModelScope.launch {
val summary = trackRepository.stopTrack()
_pastTracks.value = trackRepository.getPastTracks()
+ _savedTracks.value = trackRepository.getSavedTracks()
_trackPoints.value = emptyList()
_isRecording.value = false
_trackStats.value = null
@@ -158,6 +173,7 @@ class MainViewModel(
points.forEach { trackRepository.addPoint(it) }
val summary = trackRepository.stopTrack()
_pastTracks.value = trackRepository.getPastTracks()
+ _savedTracks.value = trackRepository.getSavedTracks()
_trackPoints.value = emptyList()
_isRecording.value = false
_trackStats.value = null
@@ -391,6 +407,13 @@ class MainViewModel(
}
}
+ fun selectTrack(track: SavedTrack) { _selectedTrack.value = track }
+ fun clearSelectedTrack() { _selectedTrack.value = null }
+
+ fun panToTrackPoint(lat: Double, lon: Double) {
+ viewModelScope.launch { _panToPosition.emit(lat to lon) }
+ }
+
private inner class GeoThrottle(private val minAgeMs: Long, private val minDistNm: Double) {
private var lastLat = Double.NaN
private var lastLon = Double.NaN
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 a7d746f..32b3643 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
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import org.maplibre.android.camera.CameraPosition
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.Style
import org.maplibre.android.style.expressions.Expression
@@ -23,6 +24,7 @@ import org.maplibre.geojson.Polygon
import org.terst.nav.safety.AnchorWatchState
import org.terst.nav.TidalCurrentState
import org.terst.nav.data.model.WindArrow
+import org.terst.nav.track.TackEvent
import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
@@ -81,6 +83,9 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val WIND_GRID_SOURCE_ID = "wind-grid-source"
private val WIND_GRID_LAYER_ID = "wind-grid-arrows"
+ private val TACK_SOURCE_ID = "tack-source"
+ private val TACK_LAYER_ID = "tack-layer"
+
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
@@ -89,6 +94,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private var trackPastSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
private var windGridSource: GeoJsonSource? = null
+ private var tackSource: GeoJsonSource? = null
/**
* Initializes map layers for anchor watch, tidal currents, and user position.
@@ -342,6 +348,59 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
}
+ /**
+ * Animates the camera to show all [points] with [paddingPx] padding on each edge.
+ * Disables GPS follow so the view stays put.
+ */
+ fun zoomToTrackBounds(points: List<TrackPoint>, paddingPx: Int = 80) {
+ if (points.size < 2) return
+ _isFollowing.value = false
+ val bounds = LatLngBounds.Builder().apply {
+ points.forEach { include(LatLng(it.lat, it.lon)) }
+ }.build()
+ maplibreMap.easeCamera(
+ CameraUpdateFactory.newLatLngBounds(bounds, paddingPx),
+ 600
+ )
+ }
+
+ /**
+ * Pans (without zoom change) to a specific point from the log step-through.
+ */
+ fun panToPoint(lat: Double, lon: Double) {
+ _isFollowing.value = false
+ maplibreMap.easeCamera(
+ CameraUpdateFactory.newLatLng(LatLng(lat, lon)),
+ 300
+ )
+ }
+
+ /**
+ * Renders tack/jibe markers as yellow circles on the map.
+ * Lazily initialises the layer on first call.
+ */
+ fun updateTackLayer(style: Style, tacks: List<TackEvent>) {
+ if (tackSource == null) {
+ tackSource = GeoJsonSource(TACK_SOURCE_ID)
+ style.addSource(tackSource!!)
+ style.addLayer(CircleLayer(TACK_LAYER_ID, TACK_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.circleColor("#FDD835"),
+ PropertyFactory.circleRadius(7f),
+ PropertyFactory.circleStrokeColor("#F57F17"),
+ PropertyFactory.circleStrokeWidth(2f)
+ )
+ })
+ }
+ val features = tacks.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) }
+ tackSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ }
+
+ /** Clears all tack markers. */
+ fun clearTackLayer() {
+ tackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+
private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
val points = mutableListOf<Point>()
val degreesBetweenPoints = 8
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
deleted file mode 100644
index e69de29..0000000
--- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
+++ /dev/null
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
deleted file mode 100644
index e69de29..0000000
--- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
+++ /dev/null
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
deleted file mode 100644
index e69de29..0000000
--- a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
+++ /dev/null
diff --git a/android-app/app/src/main/res/drawable/ic_back.xml b/android-app/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 0000000..3b56c6e
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
+</vector>
diff --git a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
new file mode 100644
index 0000000..7485e59
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <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">
+
+ <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" />
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Saved Tracks"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:layout_marginStart="4dp" />
+
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="gone" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_saved_tracks"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:paddingBottom="80dp" />
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/item_log_entry.xml b/android-app/app/src/main/res/layout/item_log_entry.xml
new file mode 100644
index 0000000..fc53ef1
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_log_entry.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="10dp">
+
+ <TextView
+ android:id="@+id/tv_log_icon"
+ android:layout_width="28dp"
+ android:layout_height="wrap_content"
+ android:textSize="18sp"
+ android:gravity="center"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <TextView
+ android:id="@+id/tv_log_time"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginStart="8dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toEndOf="@id/tv_log_icon" />
+
+ <TextView
+ android:id="@+id/tv_log_label"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:textSize="14sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface"
+ android:layout_marginStart="8dp"
+ app:layout_constraintTop_toBottomOf="@id/tv_log_time"
+ app:layout_constraintStart_toEndOf="@id/tv_log_icon"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <TextView
+ android:id="@+id/tv_log_detail"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp"
+ app:layout_constraintTop_toBottomOf="@id/tv_log_label"
+ app:layout_constraintStart_toEndOf="@id/tv_log_icon"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ android:layout_marginStart="8dp" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/item_saved_track.xml b/android-app/app/src/main/res/layout/item_saved_track.xml
new file mode 100644
index 0000000..b0a7e24
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_saved_track.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_track_date"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:textSize="15sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/tv_track_tacks" />
+
+ <TextView
+ android:id="@+id/tv_track_tacks"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="13sp"
+ android:textColor="#FDD835"
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <TextView
+ android:id="@+id/tv_track_stats"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:textSize="13sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="4dp"
+ app:layout_constraintTop_toBottomOf="@id/tv_track_date"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index 59a92c3..f8dc365 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -339,4 +339,19 @@
</LinearLayout>
+ <!-- Saved Tracks button -->
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_saved_tracks"
+ style="@style/Widget.Material3.Button.OutlinedButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="8dp"
+ android:text="Saved Tracks"
+ android:textAllCaps="false"
+ app:layout_constraintTop_toBottomOf="@id/forecast_row"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
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
new file mode 100644
index 0000000..9b2eb30
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingBottom="16dp">
+
+ <!-- drag handle -->
+ <View
+ android:layout_width="40dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="8dp"
+ android:layout_marginBottom="8dp"
+ android:background="#BDBDBD" />
+
+ <TextView
+ android:id="@+id/detail_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="20dp"
+ android:paddingBottom="12dp"
+ android:textSize="17sp"
+ android:textStyle="bold" />
+
+ <!-- stats row: distance · duration · max · avg -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingHorizontal="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>
+
+ <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>
+
+ <View android:layout_width="match_parent" android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
+
+ <!-- event log -->
+ <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="300dp"
+ android:nestedScrollingEnabled="true" />
+
+</LinearLayout>
diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt
deleted file mode 100644
index e69de29..0000000
--- a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt
+++ /dev/null