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/LocationService.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt93
-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/thermal/ThermalMonitor.kt79
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt29
-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.kt83
-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.kt250
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt59
-rw-r--r--android-app/app/src/main/res/drawable/ic_vessel.xml26
-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.xml146
-rw-r--r--android-app/app/src/main/res/layout/fragment_vessel_registry.xml177
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml4
20 files changed, 1155 insertions, 8 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 41fb2ec..18e0907 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
@@ -240,7 +240,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 -> {
stopLocationUpdatesInternal()
@@ -263,6 +266,18 @@ class LocationService : Service() {
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
updateWatchCircleRadius(radius)
}
+ ACTION_TOGGLE_TIDAL_VISIBILITY -> {
+ 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
}
@@ -366,6 +381,10 @@ class LocationService : Service() {
const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
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_TIDAL_VISIBILITY = "extra_tidal_visibility"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
private const val NMEA_GATEWAY_IP = "192.168.1.1"
@@ -406,5 +425,8 @@ class LocationService : Service() {
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode
+
+ private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val tidalCurrentState: StateFlow<TidalCurrentState> get() = _tidalCurrentState
}
}
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 b7db246..eaab575 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
@@ -12,6 +12,7 @@ import android.view.HapticFeedbackConstants
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
import androidx.appcompat.app.AppCompatActivity
@@ -31,10 +32,17 @@ import java.util.Locale
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
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.*
import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.map.ParticleWindView
import org.terst.nav.ui.safety.SafetyFragment
+import org.terst.nav.ui.vessel.VesselRegistryFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
import org.terst.nav.data.model.MarineConditions
@@ -42,6 +50,8 @@ import org.terst.nav.safety.AnchorWatchState
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
@@ -107,6 +117,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
+ viewModel.featureFlags = featureFlags
unitPrefs = UnitPrefs(this)
checkForegroundPermissions()
initializeUI()
@@ -160,6 +171,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fabRecordTrack.setOnClickListener {
if (!viewModel.isRecording.value) viewModel.startTrack()
}
+ val tvTrackStats = findViewById<TextView>(R.id.tv_track_stats)
+
fabRecordTrack.setOnLongClickListener {
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (viewModel.isRecording.value) {
@@ -172,11 +185,25 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fabRecenter.setOnClickListener { mapHandler?.recenter() }
+ // 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
+ }
}
}
lifecycleScope.launch {
@@ -282,6 +309,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
true
}
+ R.id.nav_vessel -> {
+ showOverlay(VesselRegistryFragment())
+ bottomSheetBehavior.isHideable = true
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+ true
+ }
R.id.nav_learn -> {
showOverlay(org.terst.nav.ui.learn.LearnFragment())
bottomSheetBehavior.isHideable = true
@@ -338,6 +371,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler())
}
+ 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(
valueTws = findViewById(R.id.value_tws),
@@ -473,7 +514,52 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
+ private var thermalAlarmPlayer: MediaPlayer? = null
+
+ private fun startThermalMonitoring() {
+ lifecycleScope.launch {
+ thermalFlow(applicationContext).collect { reading ->
+ viewModel.onThermalReading(reading)
+ when (reading.state) {
+ ThermalState.HOT -> {
+ Toast.makeText(
+ this@MainActivity,
+ "⚠ Phone hot: %.1f°C — reduce screen brightness".format(reading.batteryTempC),
+ Toast.LENGTH_LONG
+ ).show()
+ soundThermalAlarm()
+ }
+ ThermalState.CRITICAL -> {
+ Toast.makeText(
+ this@MainActivity,
+ "🔥 Phone overheating: %.1f°C — dock device immediately!".format(reading.batteryTempC),
+ Toast.LENGTH_LONG
+ ).show()
+ soundThermalAlarm()
+ }
+ else -> stopThermalAlarm()
+ }
+ }
+ }
+ }
+
+ private fun soundThermalAlarm() {
+ if (thermalAlarmPlayer?.isPlaying == true) return
+ thermalAlarmPlayer?.release()
+ thermalAlarmPlayer = MediaPlayer.create(this, R.raw.mob_alarm)?.also {
+ it.isLooping = false
+ it.start()
+ }
+ }
+
+ private fun stopThermalAlarm() {
+ thermalAlarmPlayer?.stop()
+ thermalAlarmPlayer?.release()
+ thermalAlarmPlayer = null
+ }
+
private fun observeDataSources() {
+ startThermalMonitoring()
var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
@@ -562,6 +648,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 }
.combine(viewModel.pastTracks) { (style, active), past -> Triple(style, active, past) }
@@ -598,6 +689,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onStart() { super.onStart(); if (!NavApplication.isTesting) mapView?.onStart() }
override fun onPause() { super.onPause(); if (!NavApplication.isTesting) mapView?.onPause() }
override fun onStop() { super.onStop(); if (!NavApplication.isTesting) mapView?.onStop() }
- override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy() }
+ override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy(); stopThermalAlarm() }
override fun onLowMemory() { super.onLowMemory(); if (!NavApplication.isTesting) mapView?.onLowMemory() }
}
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 0f1e245..cf3103d 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,6 +12,9 @@ import java.util.Locale
class NavApplication : Application() {
+ lateinit var featureFlags: FeatureFlags
+ private set
+
companion object {
val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
lateinit var trackRepository: org.terst.nav.track.TrackRepository
@@ -29,6 +33,7 @@ class NavApplication : Application() {
override fun onCreate() {
super.onCreate()
+ featureFlags = FeatureFlags(this)
trackRepository = org.terst.nav.track.TrackRepository(this)
boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
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/thermal/ThermalMonitor.kt b/android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt
new file mode 100644
index 0000000..62d3557
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt
@@ -0,0 +1,79 @@
+package org.terst.nav.thermal
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.BatteryManager
+import android.os.Build
+import android.os.PowerManager
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+enum class ThermalState {
+ OK, // < 40 °C
+ WARM, // 40–45 °C
+ HOT, // 45–50 °C → alarm
+ CRITICAL // > 50 °C → urgent alarm
+}
+
+data class ThermalReading(
+ val batteryTempC: Float,
+ val state: ThermalState,
+ val powerManagerState: Int = -1 // PowerManager.THERMAL_STATUS_* on API 29+
+)
+
+object ThermalThresholds {
+ const val WARM_C = 40f
+ const val HOT_C = 45f
+ const val CRITICAL_C = 50f
+}
+
+fun batteryTempToState(tempC: Float): ThermalState = when {
+ tempC >= ThermalThresholds.CRITICAL_C -> ThermalState.CRITICAL
+ tempC >= ThermalThresholds.HOT_C -> ThermalState.HOT
+ tempC >= ThermalThresholds.WARM_C -> ThermalState.WARM
+ else -> ThermalState.OK
+}
+
+/**
+ * Emits [ThermalReading] whenever the battery temperature changes.
+ * On API 29+ also incorporates PowerManager thermal status.
+ * The flow is distinct — only emits when state changes.
+ */
+fun thermalFlow(context: Context): Flow<ThermalReading> = callbackFlow {
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
+
+ // PowerManager thermal status listener (API 29+)
+ var pmStatus = -1
+ val pmListener: Any? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val listener = PowerManager.OnThermalStatusChangedListener { status ->
+ pmStatus = status
+ }
+ powerManager?.addThermalStatusListener(listener)
+ listener
+ } else null
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ val rawTemp = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1)
+ if (rawTemp < 0) return
+ val tempC = rawTemp / 10f
+ val state = batteryTempToState(tempC)
+ trySend(ThermalReading(tempC, state, pmStatus))
+ }
+ }
+
+ context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+
+ awaitClose {
+ context.unregisterReceiver(receiver)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && pmListener != null) {
+ powerManager?.removeThermalStatusListener(
+ pmListener as PowerManager.OnThermalStatusChangedListener
+ )
+ }
+ }
+}.distinctUntilChanged { a, b -> a.state == b.state }
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 54bf8e6..4a28bb4 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
@@ -2,6 +2,10 @@ package org.terst.nav.track
import android.content.Context
import android.util.Log
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.terst.nav.ui.NavLogger
@@ -64,6 +68,19 @@ class TrackRepository(context: Context) {
fun getPoints(): List<TrackPoint> = activePoints.toList()
+ fun computeStats(): TrackStats? {
+ if (activePoints.size < 2) return null
+ var distNm = 0.0
+ for (i in 1 until activePoints.size) {
+ distNm += haversineNm(activePoints[i - 1].lat, activePoints[i - 1].lon, activePoints[i].lat, activePoints[i].lon)
+ }
+ val durationMs = activePoints.last().timestampMs - activePoints.first().timestampMs
+ val avgSog = if (durationMs > 0) {
+ activePoints.map { it.sogKnots }.average()
+ } else 0.0
+ return TrackStats(distNm, durationMs, avgSog)
+ }
+
/**
* Returns all completed tracks, loading from Documents/Nav/ on first call.
* Subsequent calls return the in-memory list (storage is source of truth
@@ -78,4 +95,16 @@ class TrackRepository(context: Context) {
}
_pastTracks.toList()
}
+
+ 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 868b828..f05da21 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,8 +5,21 @@ 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
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
@@ -35,7 +48,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)
@@ -64,16 +78,40 @@ 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 = org.terst.nav.NavApplication.trackRepository
+ val vesselRepository = VesselRepository()
+
+ private val _thermalState = MutableStateFlow(ThermalState.OK)
+ val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
+
+ private val _thermalReading = MutableStateFlow<ThermalReading?>(null)
+ val thermalReading: StateFlow<ThermalReading?> = _thermalReading.asStateFlow()
+
+ fun onThermalReading(reading: ThermalReading) {
+ _thermalReading.value = reading
+ _thermalState.value = reading.state
+ }
+
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
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()
+
private val _pastTracks = MutableStateFlow<List<List<TrackPoint>>>(emptyList())
val pastTracks: StateFlow<List<List<TrackPoint>>> = _pastTracks.asStateFlow()
@@ -95,6 +133,7 @@ class MainViewModel(
_pastTracks.value = trackRepository.getPastTracks()
_trackPoints.value = emptyList()
_isRecording.value = false
+ _trackStats.value = null
summary?.let { _trackSummary.emit(it) }
}
}
@@ -110,6 +149,7 @@ class MainViewModel(
_pastTracks.value = trackRepository.getPastTracks()
_trackPoints.value = emptyList()
_isRecording.value = false
+ _trackStats.value = null
summary?.let { _trackSummary.emit(it) }
}
}
@@ -142,6 +182,7 @@ class MainViewModel(
}
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg
val conditions = _marineConditions.value
val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
@@ -155,7 +196,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 {
@@ -277,9 +340,26 @@ class MainViewModel(
* Process a single NMEA sentence from the hardware AIS receiver.
*/
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
+ }
+ }
}
/**
@@ -289,6 +369,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 6dd7411..d906094 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)
fun onQuitRequested()
}
@@ -28,9 +36,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)
@@ -50,6 +56,22 @@ class SafetyFragment : Fragment() {
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)
+ }
+
view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener {
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment())
@@ -65,4 +87,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/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
new file mode 100644
index 0000000..d0d8515
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
@@ -0,0 +1,250 @@
+package org.terst.nav.ui.vessel
+
+import android.app.AlertDialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import org.terst.nav.R
+import org.terst.nav.vessel.CrewMember
+import org.terst.nav.vessel.CrewRole
+import org.terst.nav.vessel.Vessel
+import org.terst.nav.vessel.VesselRepository
+
+class VesselRegistryFragment : Fragment() {
+
+ private val repo = VesselRepository()
+
+ private lateinit var tvOwnVesselName: TextView
+ private lateinit var tvOwnVesselDetails: TextView
+ private lateinit var crewListContainer: LinearLayout
+ private lateinit var tvCrewEmpty: TextView
+ private lateinit var vesselListContainer: LinearLayout
+ private lateinit var tvVesselEmpty: TextView
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_vessel_registry, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvOwnVesselName = view.findViewById(R.id.tv_own_vessel_name)
+ tvOwnVesselDetails = view.findViewById(R.id.tv_own_vessel_details)
+ crewListContainer = view.findViewById(R.id.crew_list_container)
+ tvCrewEmpty = view.findViewById(R.id.tv_crew_empty)
+ vesselListContainer = view.findViewById(R.id.vessel_list_container)
+ tvVesselEmpty = view.findViewById(R.id.tv_vessel_empty)
+
+ view.findViewById<MaterialButton>(R.id.button_edit_own_vessel).setOnClickListener {
+ showEditVesselDialog(repo.getOwnVessel()) { vessel ->
+ repo.addVessel(vessel.copy(isOwnVessel = true))
+ refreshOwnVessel()
+ }
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_add_crew).setOnClickListener {
+ showEditCrewDialog(null) { member ->
+ repo.addCrewMember(member)
+ refreshCrew()
+ }
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_add_vessel).setOnClickListener {
+ showEditVesselDialog(null) { vessel ->
+ repo.addVessel(vessel.copy(isOwnVessel = false))
+ refreshVesselList()
+ }
+ }
+
+ refresh()
+ }
+
+ private fun refresh() {
+ refreshOwnVessel()
+ refreshCrew()
+ refreshVesselList()
+ }
+
+ private fun refreshOwnVessel() {
+ val own = repo.getOwnVessel()
+ if (own != null) {
+ tvOwnVesselName.text = own.name
+ tvOwnVesselDetails.text = buildVesselDetails(own)
+ } else {
+ tvOwnVesselName.text = "No vessel configured"
+ tvOwnVesselDetails.text = ""
+ }
+ }
+
+ private fun refreshCrew() {
+ crewListContainer.removeAllViews()
+ val crew = repo.getCrew()
+ tvCrewEmpty.isVisible = crew.isEmpty()
+ crew.forEach { member -> crewListContainer.addView(buildCrewRow(member)) }
+ }
+
+ private fun refreshVesselList() {
+ vesselListContainer.removeAllViews()
+ val others = repo.getVessels().filter { !it.isOwnVessel }
+ tvVesselEmpty.isVisible = others.isEmpty()
+ others.forEach { v -> vesselListContainer.addView(buildVesselRow(v)) }
+ }
+
+ private fun buildCrewRow(member: CrewMember): View {
+ val ctx = requireContext()
+ val card = layoutInflater.inflate(
+ android.R.layout.simple_list_item_2, crewListContainer, false
+ )
+ card.findViewById<TextView>(android.R.id.text1).text =
+ "${member.name} · ${member.role.name.replace('_', ' ')}"
+ card.findViewById<TextView>(android.R.id.text2).text =
+ if (member.emergencyContact.isNotBlank()) "ICE: ${member.emergencyContact}" else ""
+ card.setOnClickListener {
+ showEditCrewDialog(member) { updated ->
+ repo.updateCrewMember(updated)
+ refreshCrew()
+ }
+ }
+ card.setOnLongClickListener {
+ AlertDialog.Builder(ctx)
+ .setTitle("Remove ${member.name}?")
+ .setPositiveButton("Remove") { _, _ -> repo.removeCrewMember(member.id); refreshCrew() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ true
+ }
+ return card
+ }
+
+ private fun buildVesselRow(vessel: Vessel): View {
+ val ctx = requireContext()
+ val row = layoutInflater.inflate(
+ android.R.layout.simple_list_item_2, vesselListContainer, false
+ )
+ row.findViewById<TextView>(android.R.id.text1).text =
+ "${vessel.name}${if (vessel.mmsi.isNotBlank()) " · MMSI ${vessel.mmsi}" else ""}"
+ row.findViewById<TextView>(android.R.id.text2).text = buildVesselDetails(vessel)
+ row.setOnClickListener {
+ showEditVesselDialog(vessel) { updated ->
+ repo.updateVessel(updated)
+ refreshVesselList()
+ }
+ }
+ row.setOnLongClickListener {
+ AlertDialog.Builder(ctx)
+ .setTitle("Remove ${vessel.name}?")
+ .setPositiveButton("Remove") { _, _ -> repo.removeVessel(vessel.id); refreshVesselList() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ true
+ }
+ return row
+ }
+
+ private fun buildVesselDetails(v: Vessel): String = buildString {
+ if (v.hullType.name != "MONOHULL") append("${v.hullType.name.replace('_', ' ')} ")
+ if (v.loaMeters > 0) append("LOA %.1fm ".format(v.loaMeters))
+ if (v.callsign.isNotBlank()) append("Call: ${v.callsign} ")
+ if (v.homePort.isNotBlank()) append("Home: ${v.homePort}")
+ }.trim()
+
+ // --- Dialogs ---
+
+ private fun showEditVesselDialog(vessel: Vessel?, onSave: (Vessel) -> Unit) {
+ val ctx = requireContext()
+ val layout = LinearLayout(ctx).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 24, 48, 0)
+ }
+
+ fun field(hint: String, initial: String = ""): TextInputEditText {
+ val til = TextInputLayout(ctx).apply { this.hint = hint }
+ val et = TextInputEditText(ctx).apply { setText(initial) }
+ til.addView(et)
+ layout.addView(til)
+ return et
+ }
+
+ val etName = field("Vessel name *", vessel?.name ?: "")
+ val etMmsi = field("MMSI", vessel?.mmsi ?: "")
+ val etCallsign = field("Callsign", vessel?.callsign ?: "")
+ val etFlag = field("Flag (country code)", vessel?.flag ?: "")
+ val etHomePort = field("Home port", vessel?.homePort ?: "")
+ val etLoa = field("LOA (metres)", if ((vessel?.loaMeters ?: 0.0) > 0) "%.2f".format(vessel!!.loaMeters) else "")
+ val etNotes = field("Notes", vessel?.notes ?: "")
+
+ AlertDialog.Builder(ctx)
+ .setTitle(if (vessel == null) "Add Vessel" else "Edit Vessel")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val name = etName.text.toString().trim()
+ if (name.isBlank()) return@setPositiveButton
+ val v = (vessel ?: Vessel(id = "", name = "")).copy(
+ name = name,
+ mmsi = etMmsi.text.toString().trim(),
+ callsign = etCallsign.text.toString().trim(),
+ flag = etFlag.text.toString().trim(),
+ homePort = etHomePort.text.toString().trim(),
+ loaMeters = etLoa.text.toString().toDoubleOrNull() ?: vessel?.loaMeters ?: 0.0,
+ notes = etNotes.text.toString().trim()
+ )
+ onSave(v)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun showEditCrewDialog(member: CrewMember?, onSave: (CrewMember) -> Unit) {
+ val ctx = requireContext()
+ val layout = LinearLayout(ctx).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 24, 48, 0)
+ }
+
+ fun field(hint: String, initial: String = ""): TextInputEditText {
+ val til = TextInputLayout(ctx).apply { this.hint = hint }
+ val et = TextInputEditText(ctx).apply { setText(initial) }
+ til.addView(et)
+ layout.addView(til)
+ return et
+ }
+
+ val etName = field("Name *", member?.name ?: "")
+ val etRole = field("Role (SKIPPER/FIRST_MATE/WATCH_CAPTAIN/CREW/GUEST)",
+ member?.role?.name ?: CrewRole.CREW.name)
+ val etIceName = field("Emergency contact name", member?.emergencyContact ?: "")
+ val etIcePhone = field("Emergency contact phone", member?.emergencyPhone ?: "")
+ val etNotes = field("Notes", member?.notes ?: "")
+
+ AlertDialog.Builder(ctx)
+ .setTitle(if (member == null) "Add Crew Member" else "Edit Crew Member")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val name = etName.text.toString().trim()
+ if (name.isBlank()) return@setPositiveButton
+ val role = runCatching {
+ CrewRole.valueOf(etRole.text.toString().trim().uppercase())
+ }.getOrDefault(CrewRole.CREW)
+ val m = (member ?: CrewMember(id = "", name = "")).copy(
+ name = name,
+ role = role,
+ emergencyContact = etIceName.text.toString().trim(),
+ emergencyPhone = etIcePhone.text.toString().trim(),
+ notes = etNotes.text.toString().trim()
+ )
+ onSave(m)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt
new file mode 100644
index 0000000..0df8cb9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.vessel
+
+data class CrewMember(
+ val id: String,
+ val name: String,
+ val role: CrewRole = CrewRole.CREW,
+ val emergencyContact: String = "",
+ val emergencyPhone: String = "",
+ val certifications: List<String> = emptyList(),
+ val notes: String = ""
+)
+
+enum class CrewRole {
+ SKIPPER, FIRST_MATE, WATCH_CAPTAIN, CREW, GUEST
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt
new file mode 100644
index 0000000..b7c0eaf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt
@@ -0,0 +1,22 @@
+package org.terst.nav.vessel
+
+data class Vessel(
+ val id: String,
+ val name: String,
+ val mmsi: String = "",
+ val callsign: String = "",
+ val flag: String = "",
+ val homePort: String = "",
+ val hullType: HullType = HullType.MONOHULL,
+ val loaMeters: Double = 0.0,
+ val beamMeters: Double = 0.0,
+ val draftMeters: Double = 0.0,
+ val displacementKg: Double = 0.0,
+ val engineHp: Int = 0,
+ val notes: String = "",
+ val isOwnVessel: Boolean = false
+)
+
+enum class HullType {
+ MONOHULL, CATAMARAN, TRIMARAN, POWERBOAT, OTHER
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
new file mode 100644
index 0000000..b65a816
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
@@ -0,0 +1,59 @@
+package org.terst.nav.vessel
+
+import java.util.UUID
+
+class VesselRepository {
+
+ private val vessels = mutableListOf<Vessel>()
+ private val crew = mutableListOf<CrewMember>()
+
+ // --- Vessel CRUD ---
+
+ fun addVessel(vessel: Vessel): Vessel {
+ val v = if (vessel.id.isBlank()) vessel.copy(id = UUID.randomUUID().toString()) else vessel
+ vessels.removeAll { it.id == v.id }
+ vessels.add(v)
+ return v
+ }
+
+ fun updateVessel(vessel: Vessel): Boolean {
+ val idx = vessels.indexOfFirst { it.id == vessel.id }
+ if (idx < 0) return false
+ vessels[idx] = vessel
+ return true
+ }
+
+ fun removeVessel(id: String): Boolean = vessels.removeAll { it.id == id }
+
+ fun getVessel(id: String): Vessel? = vessels.find { it.id == id }
+
+ fun getVessels(): List<Vessel> = vessels.toList()
+
+ fun getOwnVessel(): Vessel? = vessels.find { it.isOwnVessel }
+
+ // --- Crew CRUD ---
+
+ fun addCrewMember(member: CrewMember): CrewMember {
+ val m = if (member.id.isBlank()) member.copy(id = UUID.randomUUID().toString()) else member
+ crew.removeAll { it.id == m.id }
+ crew.add(m)
+ return m
+ }
+
+ fun updateCrewMember(member: CrewMember): Boolean {
+ val idx = crew.indexOfFirst { it.id == member.id }
+ if (idx < 0) return false
+ crew[idx] = member
+ return true
+ }
+
+ fun removeCrewMember(id: String): Boolean = crew.removeAll { it.id == id }
+
+ fun getCrewMember(id: String): CrewMember? = crew.find { it.id == id }
+
+ fun getCrew(): List<CrewMember> = crew.toList()
+
+ fun getCrewByRole(role: CrewRole): List<CrewMember> = crew.filter { it.role == role }
+
+ fun getSkipper(): CrewMember? = crew.find { it.role == CrewRole.SKIPPER }
+}
diff --git a/android-app/app/src/main/res/drawable/ic_vessel.xml b/android-app/app/src/main/res/drawable/ic_vessel.xml
new file mode 100644
index 0000000..13a93ed
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_vessel.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Simple sailboat silhouette icon -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Hull -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M3,17 L5,20 L19,20 L21,17 Z" />
+ <!-- Mast -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,4 L12,17 M11.5,4 L11.5,17"
+ android:strokeColor="@android:color/white"
+ android:strokeWidth="1.5" />
+ <!-- Main sail -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,5 L18,15 L12,15 Z" />
+ <!-- Jib -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,7 L7,15 L12,15 Z" />
+</vector>
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 5f1c8c2..4150b2e 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -99,6 +99,25 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- 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" />
+
<!-- Record Track Button -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_record_track"
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 60ea1aa..1072abe 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,6 +139,113 @@
</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>
+
<com.google.android.material.button.MaterialButton
android:id="@+id/button_plan_trip"
style="@style/Widget.Material3.Button.TonalButton"
@@ -111,7 +253,7 @@
android:layout_height="60dp"
android:layout_marginTop="24dp"
android:text="PLAN TRIP (PRE-TRIP REPORT)"
- app:layout_constraintTop_toBottomOf="@id/card_anchor" />
+ app:layout_constraintTop_toBottomOf="@id/card_data_sources" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_quit"
diff --git a/android-app/app/src/main/res/layout/fragment_vessel_registry.xml b/android-app/app/src/main/res/layout/fragment_vessel_registry.xml
new file mode 100644
index 0000000..6b55fa6
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_vessel_registry.xml
@@ -0,0 +1,177 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.core.widget.NestedScrollView
+ 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="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <!-- Title row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Vessel Registry"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface" />
+
+ </LinearLayout>
+
+ <!-- OWN VESSEL card -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="20dp"
+ android:layout_marginBottom="8dp"
+ android:text="OWN VESSEL"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_own_vessel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_own_vessel_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No vessel configured"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:id="@+id/tv_own_vessel_details"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_edit_own_vessel"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:text="EDIT VESSEL" />
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- CREW section -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="24dp"
+ android:layout_marginBottom="8dp">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="CREW"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_add_crew"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ ADD" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/crew_list_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <TextView
+ android:id="@+id/tv_crew_empty"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No crew members added yet."
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="visible" />
+
+ <!-- FLEET section -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="24dp"
+ android:layout_marginBottom="8dp">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="KNOWN VESSELS"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_add_vessel"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ ADD" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/vessel_list_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <TextView
+ android:id="@+id/tv_vessel_empty"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No additional vessels recorded."
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="visible" />
+
+ </LinearLayout>
+
+</androidx.core.widget.NestedScrollView>
diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
index 3037b7e..5c6de7e 100644
--- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
@@ -17,6 +17,10 @@
android:icon="@drawable/ic_safety"
android:title="Safety" />
<item
+ android:id="@+id/nav_vessel"
+ android:icon="@drawable/ic_vessel"
+ android:title="Vessel" />
+ <item
android:id="@+id/nav_learn"
android:icon="@drawable/ic_learn"
android:title="Learn" />