diff options
Diffstat (limited to 'android-app')
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" /> |
