summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt33
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt23
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt30
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt63
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt4
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml19
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml144
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt23
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt1
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/settings/HardwareSource.kt23
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt30
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt15
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt78
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/settings/FeatureFlagsTest.kt61
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt79
21 files changed, 746 insertions, 9 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 138fc6c..cc506a5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -180,7 +180,10 @@ class LocationService : Service() {
startLocationUpdatesInternal(PowerMode.FULL)
}
barometerSensorManager.start()
- nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ val flags = (application as NavApplication).featureFlags
+ if (flags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) {
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
}
ACTION_STOP_FOREGROUND_SERVICE -> {
Log.d("LocationService", "Stopping foreground service")
@@ -211,6 +214,14 @@ class LocationService : Service() {
val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
_tidalCurrentState.update { it.copy(isVisible = isVisible) }
}
+ ACTION_START_NMEA -> {
+ Log.d("LocationService", "Starting NMEA stream")
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
+ ACTION_STOP_NMEA -> {
+ Log.d("LocationService", "Stopping NMEA stream")
+ nmeaStreamManager.stop()
+ }
}
return START_NOT_STICKY
}
@@ -339,6 +350,8 @@ class LocationService : Service() {
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
+ const val ACTION_START_NMEA = "ACTION_START_NMEA"
+ const val ACTION_STOP_NMEA = "ACTION_STOP_NMEA"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
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 e63cdb0..b1f1919 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
@@ -11,6 +11,7 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.FrameLayout
+import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -35,6 +36,7 @@ import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.sources.RasterSource
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.ui.*
@@ -47,6 +49,8 @@ import java.util.*
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
+ private val featureFlags get() = (application as NavApplication).featureFlags
+
private var mapView: MapView? = null
private var mobHandler: MobHandler? = null
private var instrumentHandler: InstrumentHandler? = null
@@ -79,6 +83,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
+ viewModel.featureFlags = featureFlags
checkForegroundPermissions()
initializeUI()
}
@@ -98,12 +103,27 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fabRecordTrack.setOnClickListener {
if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
}
+ val tvTrackStats = findViewById<TextView>(R.id.tv_track_stats)
+
// Observe immediately — pure UI state, not gated on GPS permission
lifecycleScope.launch {
viewModel.isRecording.collect { recording ->
val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
fabRecordTrack.setImageResource(icon)
fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
+ if (!recording) tvTrackStats.visibility = View.GONE
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.trackStats.collect { stats ->
+ if (stats != null) {
+ tvTrackStats.text = "● REC %.2fnm %s %.1fkt avg".format(
+ stats.distanceNm, stats.durationFormatted, stats.avgSogKnots
+ )
+ tvTrackStats.visibility = View.VISIBLE
+ } else {
+ tvTrackStats.visibility = View.GONE
+ }
}
}
}
@@ -178,6 +198,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
anchorWatchHandler?.toggleVisibility()
}
+ override fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ viewModel.onHardwareSourceChanged(source, enabled)
+ if (source == HardwareSource.NMEA_INSTRUMENTS) {
+ val action = if (enabled) LocationService.ACTION_START_NMEA else LocationService.ACTION_STOP_NMEA
+ startService(Intent(this, LocationService::class.java).apply { this.action = action })
+ }
+ }
+
private fun setupHandlers() {
instrumentHandler = InstrumentHandler(
valueAws = findViewById(R.id.value_aws),
@@ -352,6 +380,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ viewModel.cpaAlerts.collect { alerts ->
+ safetyFragment.updateCpaAlerts(alerts)
+ }
+ }
+ lifecycleScope.launch {
loadedStyleFlow.filterNotNull()
.combine(viewModel.trackPoints) { style, points -> style to points }
.collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0b507d2..934f486 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -2,6 +2,7 @@ package org.terst.nav
import android.app.Application
import com.google.firebase.crashlytics.FirebaseCrashlytics
+import org.terst.nav.settings.FeatureFlags
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
@@ -11,12 +12,16 @@ import java.util.Locale
class NavApplication : Application() {
+ lateinit var featureFlags: FeatureFlags
+ private set
+
companion object {
var isTesting: Boolean = false
}
override fun onCreate() {
super.onCreate()
+ featureFlags = FeatureFlags(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
index 75b1feb..86bff97 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -58,4 +58,10 @@ class AisRepository(
val threshold = nowMs - staleTimeoutMs
targets.entries.removeIf { it.value.timestampMs < threshold }
}
+
+ /** Remove all tracked vessels — called when AIS source is disabled. */
+ fun clear() {
+ targets.clear()
+ pendingStatic.clear()
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
new file mode 100644
index 0000000..1d635c0
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.ais
+
+enum class CpaSeverity { CAUTION, WARNING, DANGER }
+
+data class CpaAlert(
+ val mmsi: Int,
+ val vesselName: String,
+ val cpaNm: Double,
+ val tcpaMin: Double,
+ val severity: CpaSeverity
+)
+
+object CpaThresholds {
+ const val DANGER_CPA_NM = 0.5
+ const val WARNING_CPA_NM = 1.0
+ const val CAUTION_CPA_NM = 2.0
+ const val MAX_TCPA_MIN = 30.0 // ignore contacts closing in > 30 min
+}
+
+fun CpaAlert.label(): String {
+ val name = if (vesselName.isNotBlank()) vesselName else "MMSI $mmsi"
+ return "%-12s CPA %.1fnm T+%.0fmin".format(name.take(12), cpaNm, tcpaMin)
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
new file mode 100644
index 0000000..179fa6f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
@@ -0,0 +1,44 @@
+package org.terst.nav.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * Hardware sources that require external devices beyond what a phone provides natively.
+ * When disabled, all UI and processing for that source is suppressed.
+ */
+enum class HardwareSource(val prefKey: String, val label: String, val description: String) {
+ AIS_RECEIVER(
+ "hw_ais_receiver",
+ "AIS Receiver",
+ "Vessel tracking, CPA/TCPA collision alerts"
+ ),
+ NMEA_INSTRUMENTS(
+ "hw_nmea_instruments",
+ "NMEA Instruments",
+ "Wind, depth, boat speed, heading from onboard sensors"
+ )
+}
+
+/**
+ * Persists hardware source on/off state to SharedPreferences.
+ * All sources default to disabled so the app works out-of-the-box on a phone only.
+ */
+class FeatureFlags(context: Context) {
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
+
+ fun isEnabled(source: HardwareSource): Boolean =
+ prefs.getBoolean(source.prefKey, false)
+
+ fun setEnabled(source: HardwareSource, enabled: Boolean) {
+ prefs.edit().putBoolean(source.prefKey, enabled).apply()
+ }
+
+ fun toggle(source: HardwareSource): Boolean {
+ val next = !isEnabled(source)
+ setEnabled(source, next)
+ return next
+ }
+}
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 7953822..a68e315 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,5 +1,10 @@
package org.terst.nav.track
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
class TrackRepository {
var isRecording: Boolean = false
@@ -23,4 +28,29 @@ class TrackRepository {
}
fun getPoints(): List<TrackPoint> = points.toList()
+
+ fun computeStats(): TrackStats? {
+ if (points.size < 2) return null
+ var distNm = 0.0
+ for (i in 1 until points.size) {
+ distNm += haversineNm(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
+ }
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val avgSog = if (durationMs > 0) {
+ points.map { it.sogKnots }.average()
+ } else 0.0
+ return TrackStats(distNm, durationMs, avgSog)
+ }
+
+ companion object {
+ fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ return r * 2 * atan2(sqrt(a), sqrt(1 - a))
+ }
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt
new file mode 100644
index 0000000..39153c6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.track
+
+data class TrackStats(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val avgSogKnots: Double
+) {
+ val durationFormatted: String get() {
+ val totalSec = durationMs / 1000
+ val h = totalSec / 3600
+ val m = (totalSec % 3600) / 60
+ val s = totalSec % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
+ }
+}
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 86bfe32..b457316 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
@@ -5,11 +5,18 @@ import androidx.lifecycle.viewModelScope
import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
+import org.terst.nav.ais.CpaAlert
+import org.terst.nav.ais.CpaCalculator
+import org.terst.nav.ais.CpaSeverity
+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.TrackPoint
import org.terst.nav.track.TrackRepository
+import org.terst.nav.track.TrackStats
+import org.terst.nav.settings.FeatureFlags
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.vessel.CrewMember
import org.terst.nav.vessel.Vessel
import org.terst.nav.vessel.VesselRepository
@@ -33,7 +40,8 @@ sealed class UiState {
}
class MainViewModel(
- private val repository: WeatherRepository = WeatherRepository.create()
+ private val repository: WeatherRepository = WeatherRepository.create(),
+ var featureFlags: FeatureFlags? = null
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
@@ -51,6 +59,14 @@ class MainViewModel(
private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
+ private val _cpaAlerts = MutableStateFlow<List<CpaAlert>>(emptyList())
+ val cpaAlerts: StateFlow<List<CpaAlert>> = _cpaAlerts.asStateFlow()
+
+ private var lastLat: Double = 0.0
+ private var lastLon: Double = 0.0
+ private var lastSog: Double = 0.0
+ private var lastCog: Double = 0.0
+
private val aisRepository = AisRepository()
private val trackRepository = TrackRepository()
@@ -74,6 +90,9 @@ class MainViewModel(
private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+ private val _trackStats = MutableStateFlow<TrackStats?>(null)
+ val trackStats: StateFlow<TrackStats?> = _trackStats.asStateFlow()
+
fun startTrack() {
trackRepository.startTrack()
_trackPoints.value = emptyList()
@@ -83,9 +102,11 @@ class MainViewModel(
fun stopTrack() {
trackRepository.stopTrack()
_isRecording.value = false
+ _trackStats.value = null
}
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg
val point = TrackPoint(
lat = lat, lon = lon,
sogKnots = sogKnots, cogDeg = cogDeg,
@@ -94,7 +115,29 @@ class MainViewModel(
)
if (trackRepository.addPoint(point)) {
_trackPoints.value = trackRepository.getPoints()
+ _trackStats.value = trackRepository.computeStats()
}
+ updateCpaAlerts()
+ }
+
+ private fun updateCpaAlerts() {
+ if (lastSog < 0.1) return // stationary — skip
+ val alerts = aisRepository.getTargets().mapNotNull { target ->
+ if (target.sog < 0.1 && target.cog == 0.0) return@mapNotNull null
+ val (cpa, tcpa) = CpaCalculator.compute(
+ lastLat, lastLon, lastSog, lastCog,
+ target.lat, target.lon, target.sog, target.cog.toDouble()
+ )
+ if (tcpa < 0 || tcpa > CpaThresholds.MAX_TCPA_MIN) return@mapNotNull null
+ val severity = when {
+ cpa <= CpaThresholds.DANGER_CPA_NM -> CpaSeverity.DANGER
+ cpa <= CpaThresholds.WARNING_CPA_NM -> CpaSeverity.WARNING
+ cpa <= CpaThresholds.CAUTION_CPA_NM -> CpaSeverity.CAUTION
+ else -> return@mapNotNull null
+ }
+ CpaAlert(target.mmsi, target.name, cpa, tcpa, severity)
+ }.sortedBy { it.tcpaMin }
+ _cpaAlerts.value = alerts
}
private val aisHubApi: AisHubApiService by lazy {
@@ -171,9 +214,26 @@ class MainViewModel(
* Call this from MainActivity when bytes arrive from the TCP socket.
*/
fun processAisSentence(sentence: String) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
aisRepository.processSentence(sentence)
aisRepository.evictStale()
_aisTargets.value = aisRepository.getTargets()
+ updateCpaAlerts()
+ }
+
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ when (source) {
+ HardwareSource.AIS_RECEIVER -> {
+ if (!enabled) {
+ aisRepository.clear()
+ _aisTargets.value = emptyList()
+ _cpaAlerts.value = emptyList()
+ }
+ }
+ HardwareSource.NMEA_INSTRUMENTS -> {
+ // NMEA instrument state is ephemeral — clearing handled by InstrumentHandler
+ }
+ }
}
/**
@@ -184,6 +244,7 @@ class MainViewModel(
latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
username: String = "", password: String = ""
) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
if (username.isEmpty()) return
viewModelScope.launch {
try {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
index e950b5d..a6c038c 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
@@ -5,9 +5,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
+import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
+import com.google.android.material.materialswitch.MaterialSwitch
+import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.ais.CpaAlert
+import org.terst.nav.ais.CpaSeverity
+import org.terst.nav.ais.label
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.ui.doc.DocFragment
class SafetyFragment : Fragment() {
@@ -15,6 +22,7 @@ class SafetyFragment : Fragment() {
interface SafetyListener {
fun onActivateMob()
fun onConfigureAnchor()
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean)
}
private var listener: SafetyListener? = null
@@ -27,9 +35,7 @@ class SafetyFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
- return inflater.inflate(R.layout.fragment_safety, container, false)
- }
+ ): View? = inflater.inflate(R.layout.fragment_safety, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -48,9 +54,41 @@ class SafetyFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener {
listener?.onConfigureAnchor()
}
+
+ val flags = (requireActivity().application as NavApplication).featureFlags
+
+ val switchAis = view.findViewById<MaterialSwitch>(R.id.switch_ais)
+ switchAis.isChecked = flags.isEnabled(HardwareSource.AIS_RECEIVER)
+ switchAis.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.AIS_RECEIVER, checked)
+ }
+
+ val switchNmea = view.findViewById<MaterialSwitch>(R.id.switch_nmea)
+ switchNmea.isChecked = flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS)
+ switchNmea.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.NMEA_INSTRUMENTS, checked)
+ }
}
fun updateAnchorStatus(statusText: String) {
view?.findViewById<TextView>(R.id.anchor_status_summary)?.text = statusText
}
+
+ fun updateCpaAlerts(alerts: List<CpaAlert>) {
+ val tv = view?.findViewById<TextView>(R.id.tv_cpa_status) ?: return
+ if (alerts.isEmpty()) {
+ tv.text = "No traffic alerts"
+ tv.setTextColor(ContextCompat.getColor(requireContext(), android.R.color.darker_gray))
+ return
+ }
+ tv.text = alerts.joinToString("\n") { it.label() }
+ val worstColor = when (alerts.first().severity) {
+ CpaSeverity.DANGER -> ContextCompat.getColor(requireContext(), android.R.color.holo_red_light)
+ CpaSeverity.WARNING -> ContextCompat.getColor(requireContext(), android.R.color.holo_orange_light)
+ CpaSeverity.CAUTION -> ContextCompat.getColor(requireContext(), android.R.color.holo_blue_light)
+ }
+ tv.setTextColor(worstColor)
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
index b00d899..d0d8515 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
@@ -189,7 +189,7 @@ class VesselRegistryFragment : Fragment() {
.setPositiveButton("Save") { _, _ ->
val name = etName.text.toString().trim()
if (name.isBlank()) return@setPositiveButton
- val v = (vessel ?: Vessel(id = "")).copy(
+ val v = (vessel ?: Vessel(id = "", name = "")).copy(
name = name,
mmsi = etMmsi.text.toString().trim(),
callsign = etCallsign.text.toString().trim(),
@@ -235,7 +235,7 @@ class VesselRegistryFragment : Fragment() {
val role = runCatching {
CrewRole.valueOf(etRole.text.toString().trim().uppercase())
}.getOrDefault(CrewRole.CREW)
- val m = (member ?: CrewMember(id = "")).copy(
+ val m = (member ?: CrewMember(id = "", name = "")).copy(
name = name,
role = role,
emergencyContact = etIceName.text.toString().trim(),
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index feeb43d..a99a8cb 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -32,6 +32,25 @@
android:visibility="gone"
android:background="?attr/colorSurface" />
+ <!-- Track stats bar — shown only while recording -->
+ <TextView
+ android:id="@+id/tv_track_stats"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|center_horizontal"
+ android:layout_marginTop="12dp"
+ android:paddingHorizontal="14dp"
+ android:paddingVertical="6dp"
+ android:background="#CC000000"
+ android:textColor="#FFFFFFFF"
+ android:textSize="13sp"
+ android:fontFamily="monospace"
+ android:text=""
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Collapsible Instrument Bottom Sheet -->
diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml
index 5b2397e..806598e 100644
--- a/android-app/app/src/main/res/layout/fragment_safety.xml
+++ b/android-app/app/src/main/res/layout/fragment_safety.xml
@@ -63,6 +63,41 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- CPA / Collision Alert Section -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_cpa"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ app:cardCornerRadius="16dp"
+ app:layout_constraintTop_toBottomOf="@id/card_mob">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="COLLISION AVOIDANCE"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/tv_cpa_status"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No traffic alerts"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:fontFamily="monospace"
+ android:textSize="13sp" />
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
<!-- Anchor Section -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_anchor"
@@ -70,7 +105,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="16dp"
- app:layout_constraintTop_toBottomOf="@id/card_mob">
+ app:layout_constraintTop_toBottomOf="@id/card_cpa">
<LinearLayout
android:layout_width="match_parent"
@@ -104,4 +139,111 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- Hardware Data Sources -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_data_sources"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ app:cardCornerRadius="16dp"
+ app:layout_constraintTop_toBottomOf="@id/card_anchor">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="HARDWARE DATA SOURCES"
+ android:textStyle="bold" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:layout_marginBottom="12dp"
+ android:text="Enable only the hardware you have connected"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <!-- AIS Receiver row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="AIS Receiver"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Vessel tracking, collision alerts"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_ais"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <!-- NMEA Instruments row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="NMEA Instruments"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind, depth, boat speed"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_nmea"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
new file mode 100644
index 0000000..1d635c0
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.ais
+
+enum class CpaSeverity { CAUTION, WARNING, DANGER }
+
+data class CpaAlert(
+ val mmsi: Int,
+ val vesselName: String,
+ val cpaNm: Double,
+ val tcpaMin: Double,
+ val severity: CpaSeverity
+)
+
+object CpaThresholds {
+ const val DANGER_CPA_NM = 0.5
+ const val WARNING_CPA_NM = 1.0
+ const val CAUTION_CPA_NM = 2.0
+ const val MAX_TCPA_MIN = 30.0 // ignore contacts closing in > 30 min
+}
+
+fun CpaAlert.label(): String {
+ val name = if (vesselName.isNotBlank()) vesselName else "MMSI $mmsi"
+ return "%-12s CPA %.1fnm T+%.0fmin".format(name.take(12), cpaNm, tcpaMin)
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
index 7e98451..298af59 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
@@ -2,7 +2,6 @@ package org.terst.nav.ais
import kotlin.math.cos
import kotlin.math.sqrt
-import kotlin.math.PI
object CpaCalculator {
fun compute(
diff --git a/test-runner/src/main/kotlin/org/terst/nav/settings/HardwareSource.kt b/test-runner/src/main/kotlin/org/terst/nav/settings/HardwareSource.kt
new file mode 100644
index 0000000..b44cb66
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/settings/HardwareSource.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.settings
+
+enum class HardwareSource(val prefKey: String, val label: String) {
+ AIS_RECEIVER("hw_ais_receiver", "AIS Receiver"),
+ NMEA_INSTRUMENTS("hw_nmea_instruments", "NMEA Instruments")
+}
+
+/** Minimal in-memory implementation for JVM tests. */
+class InMemoryFeatureFlags {
+ private val state = mutableMapOf<HardwareSource, Boolean>()
+
+ fun isEnabled(source: HardwareSource): Boolean = state[source] ?: false
+
+ fun setEnabled(source: HardwareSource, enabled: Boolean) {
+ state[source] = enabled
+ }
+
+ fun toggle(source: HardwareSource): Boolean {
+ val next = !isEnabled(source)
+ setEnabled(source, next)
+ return next
+ }
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index 7953822..a68e315 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,5 +1,10 @@
package org.terst.nav.track
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
class TrackRepository {
var isRecording: Boolean = false
@@ -23,4 +28,29 @@ class TrackRepository {
}
fun getPoints(): List<TrackPoint> = points.toList()
+
+ fun computeStats(): TrackStats? {
+ if (points.size < 2) return null
+ var distNm = 0.0
+ for (i in 1 until points.size) {
+ distNm += haversineNm(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
+ }
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val avgSog = if (durationMs > 0) {
+ points.map { it.sogKnots }.average()
+ } else 0.0
+ return TrackStats(distNm, durationMs, avgSog)
+ }
+
+ companion object {
+ fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ return r * 2 * atan2(sqrt(a), sqrt(1 - a))
+ }
+ }
}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt
new file mode 100644
index 0000000..39153c6
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.track
+
+data class TrackStats(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val avgSogKnots: Double
+) {
+ val durationFormatted: String get() {
+ val totalSec = durationMs / 1000
+ val h = totalSec / 3600
+ val m = (totalSec % 3600) / 60
+ val s = totalSec % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt
new file mode 100644
index 0000000..3effb24
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt
@@ -0,0 +1,78 @@
+package org.terst.nav.ais
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class CpaAlertTest {
+
+ // --- CpaCalculator ---
+
+ @Test
+ fun `head-on convergence produces small CPA and positive TCPA`() {
+ // Own vessel heading north at 10 kt; target heading south at 10 kt, 1 nm ahead
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 10.0, ownCog = 0.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 180.0
+ )
+ assertTrue("CPA should be near zero, got $cpa", cpa < 0.1)
+ assertTrue("TCPA should be positive, got $tcpa", tcpa > 0)
+ assertTrue("TCPA should be < 5 min, got $tcpa", tcpa < 5.0)
+ }
+
+ @Test
+ fun `diverging vessels produce negative TCPA`() {
+ // Own heading north, target heading north ahead — both going same direction with target faster
+ val (_, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 0.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 0.0
+ )
+ assertTrue("TCPA should be negative for diverging vessels, got $tcpa", tcpa < 0)
+ }
+
+ @Test
+ fun `zero relative velocity returns current distance`() {
+ // Same velocity — parallel course, same speed
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 90.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 5.0, tgtCog = 90.0
+ )
+ assertTrue("CPA should equal current distance, got $cpa", cpa > 0.9 && cpa < 1.1)
+ assertEquals(0.0, tcpa, 1e-9)
+ }
+
+ @Test
+ fun `well-separated crossing vessels produce large CPA`() {
+ // Perpendicular courses but far apart — should not alert
+ val (cpa, _) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 8.0, ownCog = 0.0,
+ tgtLat = 0.0, tgtLon = 5.0, tgtSog = 8.0, tgtCog = 270.0
+ )
+ assertTrue("CPA should be > 2 nm for well-separated vessels, got $cpa", cpa > 2.0)
+ }
+
+ // --- Severity classification ---
+
+ @Test
+ fun `DANGER severity for CPA under 0_5 nm`() {
+ val alert = CpaAlert(123, "TARGET", 0.3, 10.0, CpaSeverity.DANGER)
+ assertEquals(CpaSeverity.DANGER, alert.severity)
+ }
+
+ @Test
+ fun `label formats correctly with vessel name`() {
+ val alert = CpaAlert(123456789, "STENA IMPERO", 0.8, 12.5, CpaSeverity.WARNING)
+ val lbl = alert.label()
+ assertTrue(lbl.contains("0.8"))
+ assertTrue(lbl.contains("13") || lbl.contains("12")) // rounded min
+ assertTrue(lbl.contains("STENA"))
+ }
+
+ @Test
+ fun `label uses MMSI prefix when name is blank`() {
+ val alert = CpaAlert(987654321, "", 1.5, 20.0, CpaSeverity.CAUTION)
+ // "MMSI 987654321" is 14 chars; label() takes the first 12 -> "MMSI 9876543"
+ val lbl = alert.label()
+ assertTrue("Expected MMSI prefix in label, got: $lbl", lbl.contains("MMSI"))
+ assertTrue("Expected partial MMSI in label, got: $lbl", lbl.contains("987654"))
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/settings/FeatureFlagsTest.kt b/test-runner/src/test/kotlin/org/terst/nav/settings/FeatureFlagsTest.kt
new file mode 100644
index 0000000..a8e8271
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/settings/FeatureFlagsTest.kt
@@ -0,0 +1,61 @@
+package org.terst.nav.settings
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class FeatureFlagsTest {
+
+ private lateinit var flags: InMemoryFeatureFlags
+
+ @Before
+ fun setup() {
+ flags = InMemoryFeatureFlags()
+ }
+
+ @Test
+ fun `all sources default to disabled`() {
+ for (source in HardwareSource.values()) {
+ assertFalse("Expected $source to be disabled by default", flags.isEnabled(source))
+ }
+ }
+
+ @Test
+ fun `setEnabled true enables source`() {
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, true)
+ assertTrue(flags.isEnabled(HardwareSource.AIS_RECEIVER))
+ }
+
+ @Test
+ fun `setEnabled false disables source`() {
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, true)
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, false)
+ assertFalse(flags.isEnabled(HardwareSource.AIS_RECEIVER))
+ }
+
+ @Test
+ fun `toggle flips from false to true`() {
+ val result = flags.toggle(HardwareSource.NMEA_INSTRUMENTS)
+ assertTrue(result)
+ assertTrue(flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS))
+ }
+
+ @Test
+ fun `toggle flips from true to false`() {
+ flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, true)
+ val result = flags.toggle(HardwareSource.NMEA_INSTRUMENTS)
+ assertFalse(result)
+ assertFalse(flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS))
+ }
+
+ @Test
+ fun `sources are independent`() {
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, true)
+ assertFalse(flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS))
+ }
+
+ @Test
+ fun `toggle returns new state`() {
+ assertFalse(flags.toggle(HardwareSource.AIS_RECEIVER).also { assertTrue(it) } == false)
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt
new file mode 100644
index 0000000..7bce0a2
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt
@@ -0,0 +1,79 @@
+package org.terst.nav.track
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class TrackStatsTest {
+
+ private lateinit var repo: TrackRepository
+
+ private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) =
+ TrackPoint(lat, lon, sog, 0.0, 0.0, 0.0, false, ts)
+
+ @Before
+ fun setup() {
+ repo = TrackRepository()
+ repo.startTrack()
+ }
+
+ @Test
+ fun `computeStats returns null for single point`() {
+ repo.addPoint(pt(41.0, -71.0, ts = 0))
+ assertNull(repo.computeStats())
+ }
+
+ @Test
+ fun `computeStats returns null when not recording and no points`() {
+ repo.stopTrack()
+ assertNull(repo.computeStats())
+ }
+
+ @Test
+ fun `haversineNm one degree of latitude is approximately 60 nm`() {
+ val d = TrackRepository.haversineNm(0.0, 0.0, 1.0, 0.0)
+ assertEquals(60.0, d, 0.5)
+ }
+
+ @Test
+ fun `haversineNm zero distance returns zero`() {
+ assertEquals(0.0, TrackRepository.haversineNm(41.0, -71.0, 41.0, -71.0), 1e-9)
+ }
+
+ @Test
+ fun `computeStats distance accumulates across legs`() {
+ repo.addPoint(pt(0.0, 0.0, ts = 0))
+ repo.addPoint(pt(1.0, 0.0, ts = 3_600_000)) // ~60 nm north
+ repo.addPoint(pt(1.0, 1.0, ts = 7_200_000)) // ~60 nm east at 1° lat
+ val stats = repo.computeStats()!!
+ assertTrue("Expected ~120 nm, got ${stats.distanceNm}", stats.distanceNm > 100.0)
+ }
+
+ @Test
+ fun `computeStats duration is last minus first timestamp`() {
+ repo.addPoint(pt(0.0, 0.0, ts = 1_000))
+ repo.addPoint(pt(1.0, 0.0, ts = 3_601_000))
+ val stats = repo.computeStats()!!
+ assertEquals(3_600_000L, stats.durationMs)
+ }
+
+ @Test
+ fun `computeStats avgSog is mean of all point sogs`() {
+ repo.addPoint(pt(0.0, 0.0, sog = 4.0, ts = 0))
+ repo.addPoint(pt(0.0, 0.1, sog = 6.0, ts = 1000))
+ val stats = repo.computeStats()!!
+ assertEquals(5.0, stats.avgSogKnots, 0.001)
+ }
+
+ @Test
+ fun `durationFormatted shows m_ss below one hour`() {
+ val stats = TrackStats(1.0, 65_000L, 5.0) // 1 min 5 sec
+ assertEquals("1:05", stats.durationFormatted)
+ }
+
+ @Test
+ fun `durationFormatted shows h_mm_ss above one hour`() {
+ val stats = TrackStats(1.0, 3_661_000L, 5.0) // 1h 1m 1s
+ assertEquals("1:01:01", stats.durationFormatted)
+ }
+}