From f881a1f91f4e9f832ab987fce2b5220e100cee68 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 12 May 2026 13:13:55 -1000 Subject: 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 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 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 36 +++++ .../main/kotlin/org/terst/nav/track/SavedTrack.kt | 20 +++ .../org/terst/nav/track/SavedTracksFragment.kt | 101 +++++++++++++ .../kotlin/org/terst/nav/track/TackDetector.kt | 48 +++++++ .../main/kotlin/org/terst/nav/track/TackEvent.kt | 9 ++ .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 160 +++++++++++++++++++++ .../kotlin/org/terst/nav/track/TrackRepository.kt | 9 ++ .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 25 +++- .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 59 ++++++++ .../terst\\/nav/data/model/TideConstituent.kt" | 0 .../terst\\/nav/data/model/TidePrediction.kt" | 0 .../org\\/terst\\/nav/data/model/TideStation.kt" | 0 android-app/app/src/main/res/drawable/ic_back.xml | 9 ++ .../src/main/res/layout/fragment_saved_tracks.xml | 54 +++++++ .../app/src/main/res/layout/item_log_entry.xml | 55 +++++++ .../app/src/main/res/layout/item_saved_track.xml | 43 ++++++ .../main/res/layout/layout_instruments_sheet.xml | 15 ++ .../main/res/layout/layout_track_detail_sheet.xml | 91 ++++++++++++ .../org\\/terst\\/nav/data/model/TideModelTest.kt" | 0 19 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/SavedTrack.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/TackEvent.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt delete mode 100644 "android-app/app/src/main/kotlin/org\\/terst\\/nav/data/model/TideConstituent.kt" delete mode 100644 "android-app/app/src/main/kotlin/org\\/terst\\/nav/data/model/TidePrediction.kt" delete mode 100644 "android-app/app/src/main/kotlin/org\\/terst\\/nav/data/model/TideStation.kt" create mode 100644 android-app/app/src/main/res/drawable/ic_back.xml create mode 100644 android-app/app/src/main/res/layout/fragment_saved_tracks.xml create mode 100644 android-app/app/src/main/res/layout/item_log_entry.xml create mode 100644 android-app/app/src/main/res/layout/item_saved_track.xml create mode 100644 android-app/app/src/main/res/layout/layout_track_detail_sheet.xml delete mode 100644 "android-app/app/src/test/kotlin/org\\/terst\\/nav/data/model/TideModelTest.kt" (limited to 'android-app/app') 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(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, + val startMs: Long, + val summary: TrackSummary, + val tacks: List +) { + val endMs: Long get() = points.lastOrNull()?.timestampMs ?: startMs +} + +fun List.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(R.id.btn_back).setOnClickListener { + parentFragmentManager.popBackStack() + } + + val rv = view.findViewById(R.id.rv_saved_tracks) + val empty = view.findViewById(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() { + + private var items: List = emptyList() + + fun submitList(list: List) { + items = list + notifyDataSetChanged() + } + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val tvDate = view.findViewById(R.id.tv_track_date) + val tvStats = view.findViewById(R.id.tv_track_stats) + val tvTacks = view.findViewById(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): List { + if (points.size < 2 * HALF_WIN + 1) return emptyList() + + val results = mutableListOf() + 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(R.id.detail_title).text = + "Track · ${titleFmt.format(Instant.ofEpochMilli(track.startMs))}" + + val s = track.summary + view.findViewById(R.id.detail_distance).text = "%.1f".format(s.distanceNm) + view.findViewById(R.id.detail_max_sog).text = "%.1f".format(s.maxSogKt) + view.findViewById(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(R.id.detail_duration).text = + if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" + + val events = buildLogEvents(track) + val rv = view.findViewById(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 { + val events = mutableListOf() + 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, + private val onTap: (LogEvent) -> Unit +) : RecyclerView.Adapter() { + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val tvIcon = view.findViewById(R.id.tv_log_icon) + val tvTime = view.findViewById(R.id.tv_log_time) + val tvLabel = view.findViewById(R.id.tv_log_label) + val tvDetail = view.findViewById(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 = + 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(replay = 0) val trackSummary: SharedFlow = _trackSummary.asSharedFlow() + private val _savedTracks = MutableStateFlow>(emptyList()) + val savedTracks: StateFlow> = _savedTracks.asStateFlow() + + private val _selectedTrack = MutableStateFlow(null) + val selectedTrack: StateFlow = _selectedTrack.asStateFlow() + + /** Emits a lat/lon whenever the user taps a log entry to pan the map there. */ + private val _panToPosition = MutableSharedFlow>(replay = 0) + val panToPosition: SharedFlow> = _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, 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) { + 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() 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 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 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 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 @@ + + + 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 @@ + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 -- cgit v1.2.3