summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt21
-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/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.kt41
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt20
-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.xml37
8 files changed, 205 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 ac2272c..3880671 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
@@ -98,12 +99,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
+ }
}
}
}
@@ -352,6 +368,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/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/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..bf57c1f 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,16 @@ 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.vessel.CrewMember
import org.terst.nav.vessel.Vessel
import org.terst.nav.vessel.VesselRepository
@@ -51,6 +56,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 +87,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 +99,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 +112,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 {
@@ -174,6 +214,7 @@ class MainViewModel(
aisRepository.processSentence(sentence)
aisRepository.evictStale()
_aisTargets.value = aisRepository.getTargets()
+ updateCpaAlerts()
}
/**
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..b248e8d 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,13 @@ 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 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.ui.doc.DocFragment
class SafetyFragment : Fragment() {
@@ -53,4 +57,20 @@ class SafetyFragment : Fragment() {
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/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..c270d1f 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"