summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt56
-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/ui/MainViewModel.kt18
-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/fragment_vessel_registry.xml177
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml4
10 files changed, 705 insertions, 1 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 7046b23..ac2272c 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
@@ -35,10 +35,13 @@ 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.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.*
@@ -140,6 +143,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
+ }
else -> false
}
}
@@ -267,7 +276,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()
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
@@ -347,6 +401,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onStart() { super.onStart(); mapView?.onStart() }
override fun onPause() { super.onPause(); mapView?.onPause() }
override fun onStop() { super.onStop(); mapView?.onStop() }
- override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
+ override fun onDestroy() { super.onDestroy(); mapView?.onDestroy(); stopThermalAlarm() }
override fun onLowMemory() { super.onLowMemory(); mapView?.onLowMemory() }
}
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/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 4c2c555..86bfe32 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
@@ -6,8 +6,13 @@ import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
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.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.WindArrow
import org.terst.nav.data.repository.WeatherRepository
@@ -50,6 +55,19 @@ class MainViewModel(
private val trackRepository = 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()
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..b00d899
--- /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 = "")).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 = "")).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/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 b29fb08..5f444eb 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
@@ -16,4 +16,8 @@
android:id="@+id/nav_safety"
android:icon="@drawable/ic_safety"
android:title="Safety" />
+ <item
+ android:id="@+id/nav_vessel"
+ android:icon="@drawable/ic_vessel"
+ android:title="Vessel" />
</menu>