From eeafe2f0f3a3718bd52e9c333894e1abbc0611ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 05:35:49 +0000 Subject: feat: vessel registry, crew management, and thermal alarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vessel/CrewMember data classes with full profile fields (MMSI, callsign, flag, home port, hull type, dimensions, roles) - VesselRepository: in-memory CRUD for own vessel, fleet, and crew; getOwnVessel(), getSkipper(), getCrewByRole() helpers - VesselRegistryFragment: own-vessel card + crew list + known-vessels list; AlertDialog editors for add/edit; long-press to delete - ic_vessel.xml sailboat icon; 5th bottom-nav tab "Vessel" - ThermalMonitor: thermalFlow() using Intent.ACTION_BATTERY_CHANGED (battery °C) + PowerManager.OnThermalStatusChangedListener (API 29+); ThermalState OK/WARM/HOT/CRITICAL with distinct-until-changed filter - MainActivity: startThermalMonitoring() sounds mob_alarm + Toast on HOT (≥45°C) and CRITICAL (≥50°C); auto-silences on OK/WARM - MainViewModel: thermalState and thermalReading StateFlows - 18 VesselRepositoryTest cases — all GREEN (test-runner) - design.md: updated competitive matrix + feature implementation status https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW --- .agent/design.md | 30 +++ .agent/worklog.md | 19 ++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 56 ++++- .../kotlin/org/terst/nav/thermal/ThermalMonitor.kt | 79 +++++++ .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 18 ++ .../terst/nav/ui/vessel/VesselRegistryFragment.kt | 250 +++++++++++++++++++++ .../main/kotlin/org/terst/nav/vessel/CrewMember.kt | 15 ++ .../src/main/kotlin/org/terst/nav/vessel/Vessel.kt | 22 ++ .../org/terst/nav/vessel/VesselRepository.kt | 59 +++++ .../app/src/main/res/drawable/ic_vessel.xml | 26 +++ .../main/res/layout/fragment_vessel_registry.xml | 177 +++++++++++++++ .../app/src/main/res/menu/bottom_nav_menu.xml | 4 + .../main/kotlin/org/terst/nav/vessel/CrewMember.kt | 15 ++ .../src/main/kotlin/org/terst/nav/vessel/Vessel.kt | 22 ++ .../org/terst/nav/vessel/VesselRepository.kt | 55 +++++ .../org/terst/nav/vessel/VesselRepositoryTest.kt | 154 +++++++++++++ 16 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt create mode 100644 android-app/app/src/main/res/drawable/ic_vessel.xml create mode 100644 android-app/app/src/main/res/layout/fragment_vessel_registry.xml create mode 100644 test-runner/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/vessel/Vessel.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt diff --git a/.agent/design.md b/.agent/design.md index ba82612..545fe65 100644 --- a/.agent/design.md +++ b/.agent/design.md @@ -72,9 +72,37 @@ Existing solutions force sailors to switch between a charting app, a weather app | Offline charts/weather | - | ✓* | ✓* | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | | Trip logbook | ✓ | - | - | ✓* | - | - | - | ✓✓ | | Mobile-native UI | ✓✓ | ✓✓ | ✓✓ | - | ✓ | ✓✓ | ✓ | ✓✓ | +| Vessel registry / boat profile | ✓* | - | ✓* | ✓ | ✓* | - | ✓ | ✓✓ | +| Crew management | - | - | - | - | - | - | - | ✓✓ | +| Device thermal alarm | - | - | - | - | - | - | - | ✓✓ | ✓✓ = strong, ✓ = present, ✓* = via plugin/limited, - = absent +### Feature Implementation Status (as of 2026-04-22) + +| Feature | Status | Location | +|--------------------------------------|---------------|-----------------------------------------------| +| GPS navigation (position, SOG, COG) | ✅ Complete | `gps/GpsProvider`, `gps/DeviceGpsProvider` | +| NMEA 0183 parsing | ✅ Complete | `nmea/NmeaParser`, `nmea/AisVdmParser` | +| AIS target tracking + CPA/TCPA | ✅ Complete | `ais/AisRepository`, `ais/CpaCalculator` | +| AIS internet feed (AISHub) | ✅ Complete | `ais/AisHubApiService`, `ais/AisHubSource` | +| MOB alarm | ✅ Complete | `ui/MobHandler`, `ui/safety/SafetyFragment` | +| Anchor alarm + watch circle | ✅ Complete | `AnchorAlarmManager`, `ui/AnchorWatchHandler` | +| Trip track recording | ✅ Complete | `track/TrackRepository`, `ui/MapHandler` | +| Weather overlay (particle wind) | ✅ Complete | `ui/map/ParticleWindView` | +| Barometric pressure trend | ✅ Complete | `BarometerSensorManager`, `BarometerTrendView`| +| Performance / polar tracking | ✅ Complete | `PerformanceViewModel`, `PolarData` | +| Tidal harmonic prediction | ✅ Complete | `tide/HarmonicTideCalculator` | +| Isochrone weather routing | ✅ Complete | `routing/IsochroneRouter` | +| Trip logbook (voice + manual) | ✅ Complete | `logbook/`, `ui/voicelog/` | +| GRIB weather file management | ✅ Complete | `data/storage/GribFileManager` | +| Vessel registry + crew management | ✅ Complete | `vessel/VesselRepository`, `ui/vessel/` | +| Device thermal alarm | ✅ Complete | `thermal/ThermalMonitor` | +| Night mode | 🔲 Roadmap | Phase 1 (partial — theme exists) | +| Cloud sync | 🔲 Roadmap | Phase 4 | +| Satellite GRIB (Iridium) | 🔲 Roadmap | Phase 5 | +| Wearable companion (Wear OS) | 🔲 Roadmap | Phase 5 | + ### Gaps in Existing Solutions | Gap | Who suffers | Our answer | @@ -86,6 +114,8 @@ Existing solutions force sailors to switch between a charting app, a weather app | Tide apps are standalone, not integrated with charts | Coastal cruisers | Tide/current overlays on the chart | | Anchor alarms require separate apps | All anchoring sailors| Built-in anchor watch with configurable alerts| | No good offline weather routing on mobile | Offshore sailors | Offline GRIB processing + isochrone routing | +| No app tracks crew roster with roles + contacts | All skippers | Built-in crew registry, no third-party needed | +| No app warns about phone overheating at the helm | All mobile users | Battery temp + PowerManager thermal alarm | --- diff --git a/.agent/worklog.md b/.agent/worklog.md index 7a4467f..4818a94 100644 --- a/.agent/worklog.md +++ b/.agent/worklog.md @@ -140,6 +140,25 @@ Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel - `activity_main.xml`: `fab_record_track` FAB anchored top|end of bottom nav - `drawable/ic_track_record.xml`: red dot record icon +### [APPROVED] Vessel Registry + Crew Management (2026-04-22) +- `android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt` — data class: name, MMSI, callsign, flag, homePort, hullType, LOA, beam, draft, displacement, engineHp, notes, isOwnVessel +- `android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt` — data class: name, role (SKIPPER/FIRST_MATE/WATCH_CAPTAIN/CREW/GUEST), emergencyContact, emergencyPhone, certifications, notes +- `android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt` — in-memory CRUD for vessels and crew; addVessel, updateVessel, removeVessel, getOwnVessel, addCrewMember, updateCrewMember, removeCrewMember, getSkipper, getCrewByRole +- `android-app/app/src/main/res/layout/fragment_vessel_registry.xml` — NestedScrollView with own vessel card, crew list, and known vessels list; + ADD buttons for crew and fleet +- `android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt` — fragment with AlertDialog editors for vessels and crew; long-press to delete +- `android-app/app/src/main/res/menu/bottom_nav_menu.xml` — added `nav_vessel` 5th tab with `ic_vessel` icon +- `android-app/app/src/main/res/drawable/ic_vessel.xml` — sailboat silhouette vector icon +- `MainViewModel.kt` — added `vesselRepository` property (public, for fragment access) +- `MainActivity.kt` — `R.id.nav_vessel` case loads `VesselRegistryFragment` +- Mirror classes in `test-runner/src/main/kotlin/org/terst/nav/vessel/` +- 18 tests in `test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt` — all GREEN + +### [APPROVED] Thermal Alarm (2026-04-22) +- `android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt` — `thermalFlow(context)` as Kotlin Flow using `Intent.ACTION_BATTERY_CHANGED` for battery temperature; `PowerManager.addThermalStatusListener` on API 29+ for supplemental state; emits distinct `ThermalReading` only on state change +- `ThermalState` enum: OK (<40°C), WARM (40–45°C), HOT (45–50°C), CRITICAL (>50°C) +- `MainViewModel.kt` — `thermalState: StateFlow`, `thermalReading: StateFlow`, `onThermalReading()` entry point +- `MainActivity.kt` — `startThermalMonitoring()` called from `observeDataSources()`; HOT triggers Toast + alarm sound (reuses `R.raw.mob_alarm`); CRITICAL triggers urgent Toast + alarm; OK/WARM stops alarm; alarm released in `onDestroy()` + ## Next 3 Specific Steps 1. **Persist track to GPX/Room** — export recorded track as GPX file or store in Room DB 2. **Track stats in Log tab** — show elapsed time, distance, avg SOG while recording 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 = 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.asStateFlow() + + private val _thermalReading = MutableStateFlow(null) + val thermalReading: StateFlow = _thermalReading.asStateFlow() + + fun onThermalReading(reading: ThermalReading) { + _thermalReading.value = reading + _thermalState.value = reading.state + } + private val _isRecording = MutableStateFlow(false) val isRecording: StateFlow = _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(R.id.button_edit_own_vessel).setOnClickListener { + showEditVesselDialog(repo.getOwnVessel()) { vessel -> + repo.addVessel(vessel.copy(isOwnVessel = true)) + refreshOwnVessel() + } + } + + view.findViewById(R.id.button_add_crew).setOnClickListener { + showEditCrewDialog(null) { member -> + repo.addCrewMember(member) + refreshCrew() + } + } + + view.findViewById(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(android.R.id.text1).text = + "${member.name} · ${member.role.name.replace('_', ' ')}" + card.findViewById(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(android.R.id.text1).text = + "${vessel.name}${if (vessel.mmsi.isNotBlank()) " · MMSI ${vessel.mmsi}" else ""}" + row.findViewById(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 = 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() + private val crew = mutableListOf() + + // --- 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 = 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 = crew.toList() + + fun getCrewByRole(role: CrewRole): List = 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 @@ + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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" /> + diff --git a/test-runner/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt b/test-runner/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt new file mode 100644 index 0000000..0df8cb9 --- /dev/null +++ b/test-runner/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 = emptyList(), + val notes: String = "" +) + +enum class CrewRole { + SKIPPER, FIRST_MATE, WATCH_CAPTAIN, CREW, GUEST +} diff --git a/test-runner/src/main/kotlin/org/terst/nav/vessel/Vessel.kt b/test-runner/src/main/kotlin/org/terst/nav/vessel/Vessel.kt new file mode 100644 index 0000000..b7c0eaf --- /dev/null +++ b/test-runner/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/test-runner/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt new file mode 100644 index 0000000..fe4c44d --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt @@ -0,0 +1,55 @@ +package org.terst.nav.vessel + +import java.util.UUID + +class VesselRepository { + + private val vessels = mutableListOf() + private val crew = mutableListOf() + + 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 = vessels.toList() + + fun getOwnVessel(): Vessel? = vessels.find { it.isOwnVessel } + + 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 = crew.toList() + + fun getCrewByRole(role: CrewRole): List = crew.filter { it.role == role } + + fun getSkipper(): CrewMember? = crew.find { it.role == CrewRole.SKIPPER } +} diff --git a/test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt b/test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt new file mode 100644 index 0000000..cec2a54 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/vessel/VesselRepositoryTest.kt @@ -0,0 +1,154 @@ +package org.terst.nav.vessel + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class VesselRepositoryTest { + + private lateinit var repo: VesselRepository + + @Before + fun setup() { + repo = VesselRepository() + } + + // --- Vessel tests --- + + @Test + fun `addVessel with blank id assigns a generated id`() { + val v = repo.addVessel(Vessel(id = "", name = "Perseverance")) + assertTrue(v.id.isNotBlank()) + } + + @Test + fun `addVessel with explicit id preserves id`() { + val v = repo.addVessel(Vessel(id = "abc123", name = "Perseverance")) + assertEquals("abc123", v.id) + } + + @Test + fun `getVessels returns all added vessels`() { + repo.addVessel(Vessel(id = "1", name = "Alpha")) + repo.addVessel(Vessel(id = "2", name = "Beta")) + assertEquals(2, repo.getVessels().size) + } + + @Test + fun `getVessel returns correct vessel by id`() { + repo.addVessel(Vessel(id = "1", name = "Alpha")) + repo.addVessel(Vessel(id = "2", name = "Beta")) + assertEquals("Beta", repo.getVessel("2")?.name) + } + + @Test + fun `updateVessel replaces vessel data`() { + repo.addVessel(Vessel(id = "1", name = "Alpha")) + val updated = repo.updateVessel(Vessel(id = "1", name = "Alpha II", loaMeters = 12.5)) + assertTrue(updated) + assertEquals("Alpha II", repo.getVessel("1")?.name) + assertEquals(12.5, repo.getVessel("1")?.loaMeters ?: 0.0, 0.001) + } + + @Test + fun `updateVessel returns false for unknown id`() { + assertFalse(repo.updateVessel(Vessel(id = "nope", name = "Ghost"))) + } + + @Test + fun `removeVessel removes by id`() { + repo.addVessel(Vessel(id = "1", name = "Alpha")) + repo.addVessel(Vessel(id = "2", name = "Beta")) + repo.removeVessel("1") + assertNull(repo.getVessel("1")) + assertEquals(1, repo.getVessels().size) + } + + @Test + fun `getOwnVessel returns only own vessel`() { + repo.addVessel(Vessel(id = "1", name = "Alpha", isOwnVessel = true)) + repo.addVessel(Vessel(id = "2", name = "Beta", isOwnVessel = false)) + assertEquals("Alpha", repo.getOwnVessel()?.name) + } + + @Test + fun `getOwnVessel returns null when none set`() { + repo.addVessel(Vessel(id = "1", name = "Alpha", isOwnVessel = false)) + assertNull(repo.getOwnVessel()) + } + + @Test + fun `addVessel upserts on duplicate id`() { + repo.addVessel(Vessel(id = "1", name = "Alpha")) + repo.addVessel(Vessel(id = "1", name = "Alpha Updated")) + assertEquals(1, repo.getVessels().size) + assertEquals("Alpha Updated", repo.getVessel("1")?.name) + } + + // --- Crew tests --- + + @Test + fun `addCrewMember with blank id assigns generated id`() { + val m = repo.addCrewMember(CrewMember(id = "", name = "Jane Doe")) + assertTrue(m.id.isNotBlank()) + } + + @Test + fun `getCrew returns all crew members`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice")) + repo.addCrewMember(CrewMember(id = "c2", name = "Bob")) + assertEquals(2, repo.getCrew().size) + } + + @Test + fun `getSkipper returns skipper crew member`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice", role = CrewRole.CREW)) + repo.addCrewMember(CrewMember(id = "c2", name = "Bob", role = CrewRole.SKIPPER)) + assertEquals("Bob", repo.getSkipper()?.name) + } + + @Test + fun `getSkipper returns null when no skipper`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice", role = CrewRole.CREW)) + assertNull(repo.getSkipper()) + } + + @Test + fun `updateCrewMember updates role and details`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice", role = CrewRole.CREW)) + val ok = repo.updateCrewMember( + CrewMember(id = "c1", name = "Alice", role = CrewRole.FIRST_MATE, emergencyContact = "Bob") + ) + assertTrue(ok) + assertEquals(CrewRole.FIRST_MATE, repo.getCrewMember("c1")?.role) + assertEquals("Bob", repo.getCrewMember("c1")?.emergencyContact) + } + + @Test + fun `removeCrewMember removes by id`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice")) + repo.addCrewMember(CrewMember(id = "c2", name = "Bob")) + repo.removeCrewMember("c1") + assertNull(repo.getCrewMember("c1")) + assertEquals(1, repo.getCrew().size) + } + + @Test + fun `getCrewByRole filters correctly`() { + repo.addCrewMember(CrewMember(id = "c1", name = "Alice", role = CrewRole.CREW)) + repo.addCrewMember(CrewMember(id = "c2", name = "Bob", role = CrewRole.WATCH_CAPTAIN)) + repo.addCrewMember(CrewMember(id = "c3", name = "Carol", role = CrewRole.CREW)) + val result = repo.getCrewByRole(CrewRole.CREW) + assertEquals(2, result.size) + assertTrue(result.all { it.role == CrewRole.CREW }) + } + + @Test + fun `vessel registry and crew are independent`() { + repo.addVessel(Vessel(id = "v1", name = "Alpha")) + repo.addCrewMember(CrewMember(id = "c1", name = "Alice")) + repo.removeVessel("v1") + assertEquals(1, repo.getCrew().size) + assertEquals(0, repo.getVessels().size) + } +} -- cgit v1.2.3