summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org/terst')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt83
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt923
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MobData.kt10
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt29
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt70
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt131
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MobHandler.kt94
8 files changed, 664 insertions, 775 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 51915bd..138fc6c 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
@@ -86,8 +86,6 @@ class LocationService : Service() {
serviceScope.launch {
nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
_nmeaGpsPositionFlow.emit(gpsPosition)
- // TODO: Implement sensor fusion logic here to decide whether to use
- // this NMEA GPS position or the Android system's FusedLocationProviderClient position.
}
}
@@ -115,9 +113,9 @@ class LocationService : Service() {
// Mock tidal current data generator
serviceScope.launch {
while (true) {
- val currents = generateMockCurrents()
+ val currents = MockTidalCurrentGenerator.generateMockCurrents()
_tidalCurrentState.update { it.copy(currents = currents) }
- kotlinx.coroutines.delay(60000) // Update every minute (or as needed)
+ kotlinx.coroutines.delay(60000) // Update every minute
}
}
@@ -134,36 +132,41 @@ class LocationService : Service() {
_locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
}
-
-
// Check for anchor drag if anchor watch is active
- _anchorWatchState.update { currentState ->
- if (currentState.isActive && currentState.anchorLocation != null) {
- val isDragging = currentState.isDragging(location)
- if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
- if (!isAlarmTriggered) {
- anchorAlarmManager.startAlarm()
- isAlarmTriggered = true
- }
- } else {
- Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
- }
- } else {
- // If anchor watch is not active, ensure alarm is stopped
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
- }
- currentState
+ checkAnchorDrag(location)
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks if the current location is outside the anchor watch circle.
+ */
+ private fun checkAnchorDrag(location: Location) {
+ _anchorWatchState.update { currentState ->
+ if (currentState.isActive && currentState.anchorLocation != null) {
+ val isDragging = currentState.isDragging(location)
+ if (isDragging) {
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (!isAlarmTriggered) {
+ anchorAlarmManager.startAlarm()
+ isAlarmTriggered = true
+ }
+ } else {
+ Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
}
}
+ } else {
+ // If anchor watch is not active, ensure alarm is stopped
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
}
+ currentState
}
}
@@ -329,26 +332,6 @@ class LocationService : Service() {
Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
}
- private fun generateMockCurrents(): List<TidalCurrent> {
- // Generate a grid of currents around common coordinates
- val centerLat = 41.5
- val centerLon = -71.3
- val currents = mutableListOf<TidalCurrent>()
- val currentTime = System.currentTimeMillis()
-
- for (i in -5..5) {
- for (j in -5..5) {
- val lat = centerLat + i * 0.05
- val lon = centerLon + j * 0.05
- // Mock speed and direction based on position and time
- val speed = 0.5 + Math.random() * 2.0 // 0.5 to 2.5 knots
- val dir = (Math.sin(currentTime / 3600000.0 + lat + lon) * 180.0 + 180.0) % 360.0
- currents.add(TidalCurrent(lat, lon, speed, dir, currentTime))
- }
- }
- return currents
- }
-
companion object {
const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
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 a3eebfc..ad9b2c8 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
@@ -1,202 +1,112 @@
package org.terst.nav
import android.Manifest
+import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
-import android.graphics.BitmapFactory
-import android.location.Location
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
-import android.content.Intent
import android.util.Log
import android.view.View
-import android.widget.Button
import android.widget.FrameLayout
-import android.widget.LinearLayout
-import android.widget.TextView
import android.widget.Toast
-import org.terst.nav.ui.voicelog.VoiceLogFragment
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
-import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.floatingactionbutton.FloatingActionButton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
-import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
-import org.maplibre.android.style.layers.CircleLayer
-import org.maplibre.android.style.expressions.Expression
-import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.RasterLayer
-import org.maplibre.android.style.layers.SymbolLayer
-import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
-import org.maplibre.geojson.Feature
-import org.maplibre.geojson.FeatureCollection
-import org.maplibre.geojson.Point
-import org.maplibre.geojson.Polygon
-import org.maplibre.geojson.LineString
-import org.terst.nav.ui.MainViewModel
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.util.Locale
-import java.util.concurrent.TimeUnit
-import kotlin.math.cos
-import kotlin.math.sin
-import kotlin.math.sqrt
-import kotlin.math.atan2
-
-data class MobWaypoint(
- val latitude: Double,
- val longitude: Double,
- val timestamp: Long // System.currentTimeMillis()
-)
+import org.terst.nav.ui.*
+import org.terst.nav.ui.voicelog.VoiceLogFragment
+import java.util.*
+/**
+ * Main entry point for the navigation application.
+ * Manages the high-level UI components and coordinates between various handlers.
+ */
class MainActivity : AppCompatActivity() {
private var mapView: MapView? = null
- private lateinit var instrumentDisplayContainer: ConstraintLayout
- private lateinit var fabToggleInstruments: FloatingActionButton
- private lateinit var fabMob: FloatingActionButton
- private lateinit var fabTidal: FloatingActionButton
-
- // MapLibreMap instance
- private var maplibreMap: MapLibreMap? = null
-
- // MapLibre Layers and Sources for Anchor Watch
- private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
- private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
- private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
- private val ANCHOR_CIRCLE_LAYER_ID = "anchor-circle-layer"
- private val ANCHOR_ICON_ID = "anchor-icon"
-
- private var anchorPointSource: GeoJsonSource? = null
- private var anchorCircleSource: GeoJsonSource? = null
-
- // MapLibre Layers and Sources for Tidal Current
- private val TIDAL_CURRENT_SOURCE_ID = "tidal-current-source"
- private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
- private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
-
- private var tidalCurrentSource: GeoJsonSource? = null
-
- // MOB UI elements
- private lateinit var mobNavigationContainer: ConstraintLayout
- private lateinit var mobValueDistance: TextView
- private lateinit var mobValueElapsedTime: TextView
- private lateinit var mobRecoveredButton: Button
-
- // MOB State
- private var mobActivated: Boolean = false
- private var activeMobWaypoint: MobWaypoint? = null
-
- // Media player for MOB alarm
- private var mobMediaPlayer: MediaPlayer? = null
-
- // Instrument TextViews
- private lateinit var valueAws: TextView
- private lateinit var valueTws: TextView
- private lateinit var valueHdg: TextView
- private lateinit var valueCog: TextView
- private lateinit var valueBsp: TextView
- private lateinit var valueSog: TextView
- private lateinit var valueVmg: TextView
- private lateinit var valueDepth: TextView
- private lateinit var valuePolarPct: TextView
- private lateinit var valueBaro: TextView
- private lateinit var labelTrend: TextView
- private lateinit var barometerTrendView: BarometerTrendView
- private lateinit var polarDiagramView: PolarDiagramView // Reference to the custom view
-
- // Voice Log UI elements
- private lateinit var fabVoiceLog: FloatingActionButton
- private lateinit var voiceLogContainer: FrameLayout
-
- // Anchor Watch UI elements
- private lateinit var fabAnchor: FloatingActionButton
- private lateinit var anchorConfigContainer: ConstraintLayout
- private lateinit var anchorStatusText: TextView
- private lateinit var anchorRadiusText: TextView
- private lateinit var buttonDecreaseRadius: Button
- private lateinit var buttonIncreaseRadius: Button
- private lateinit var buttonSetAnchor: Button
- private lateinit var buttonStopAnchor: Button
-
- private var currentWatchCircleRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
-
- // ViewModel for AIS sentence processing
+ private var mobHandler: MobHandler? = null
+ private var instrumentHandler: InstrumentHandler? = null
+ private var mapHandler: MapHandler? = null
+ private var anchorWatchHandler: AnchorWatchHandler? = null
+
private val viewModel: MainViewModel by viewModels()
- // Register the permissions callback, which handles the user's response to the
- // system permissions dialog.
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
- val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
- val backgroundLocationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- permissions[Manifest.permission.ACCESS_BACKGROUND_LOCATION] == true
- } else true // Not needed below Android 10
-
- if (fineLocationGranted && coarseLocationGranted && backgroundLocationGranted) {
- // Permissions granted, start location service and observe updates
- Toast.makeText(this, "Location permissions granted", Toast.LENGTH_SHORT).show()
- startLocationService()
- observeLocationUpdates() // Start observing location updates
- observeAnchorWatchState() // Start observing anchor watch state
- observeBarometerStatus() // Start observing barometer status
- startAisHardwareFeed()
+ if (permissions.all { it.value }) {
+ startServices()
} else {
- // Permissions denied, handle the case (e.g., show a message to the user)
Toast.makeText(this, "Location permissions denied", Toast.LENGTH_LONG).show()
- Log.e("MainActivity", "Location permissions denied by user.")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- // MapLibre access token only needed for Mapbox styles, but good practice to initialize
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
- val permissionsToRequest = mutableListOf(
+ checkPermissions()
+ initializeUI()
+ }
+
+ private fun checkPermissions() {
+ val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- permissionsToRequest.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ permissions.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
- // Check and request location permissions
- val allPermissionsGranted = permissionsToRequest.all {
- ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
+ if (permissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }) {
+ startServices()
+ } else {
+ requestPermissionLauncher.launch(permissions.toTypedArray())
}
+ }
- if (!allPermissionsGranted) {
- requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
+ private fun startServices() {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_FOREGROUND_SERVICE
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
} else {
- // Permissions already granted, start location service
- startLocationService()
- observeLocationUpdates() // Start observing location updates
- observeAnchorWatchState() // Start observing anchor watch state
- observeBarometerStatus() // Start observing barometer status
- startAisHardwareFeed()
+ startService(intent)
}
+
+ observeDataSources()
+ startAisHardwareFeed()
+ }
+
+ private fun initializeUI() {
+ setupMap()
+ setupHandlers()
+ setupListeners()
+ }
- mapView = findViewById<MapView>(R.id.mapView)
- mapView?.onCreate(savedInstanceState)
+ private fun setupMap() {
+ mapView = findViewById(R.id.mapView)
+ mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
- this.maplibreMap = maplibreMap // Assign to class member
+ mapHandler = MapHandler(maplibreMap)
val style = Style.Builder()
.fromUri("https://tiles.openfreemap.org/styles/liberty")
.withSource(RasterSource("openseamap-source",
@@ -204,656 +114,219 @@ class MainActivity : AppCompatActivity() {
it.setMaxZoom(18f)
}, 256))
.withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
- maplibreMap.setStyle(style) { style ->
- setupAnchorMapLayers(style)
- setupTidalCurrentMapLayers(style)
- observeTidalCurrentState() // Start observing tidal current state
+
+ maplibreMap.setStyle(style) { loadedStyle ->
+ val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
+ val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
+ mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap)
}
}
+ }
- instrumentDisplayContainer = findViewById(R.id.instrument_display_container)
- fabToggleInstruments = findViewById(R.id.fab_toggle_instruments)
- fabMob = findViewById(R.id.fab_mob)
- fabTidal = findViewById(R.id.fab_tidal)
-
- // Initialize MOB UI elements
- mobNavigationContainer = findViewById(R.id.mob_navigation_container)
- mobValueDistance = findViewById(R.id.mob_value_distance)
- mobValueElapsedTime = findViewById(R.id.mob_value_elapsed_time)
- mobRecoveredButton = findViewById(R.id.mob_recovered_button)
-
- // Initialize instrument TextViews
- valueAws = findViewById(R.id.value_aws)
- valueTws = findViewById(R.id.value_tws)
- valueHdg = findViewById(R.id.value_hdg)
- valueCog = findViewById(R.id.value_cog)
- valueBsp = findViewById(R.id.value_bsp)
- valueSog = findViewById(R.id.value_sog)
- valueVmg = findViewById(R.id.value_vmg)
- valueDepth = findViewById(R.id.value_depth)
- valuePolarPct = findViewById(R.id.value_polar_pct)
- valueBaro = findViewById(R.id.value_baro)
- labelTrend = findViewById(R.id.label_trend)
- barometerTrendView = findViewById(R.id.barometer_trend_view)
-
- // Initialize PolarDiagramView
- polarDiagramView = findViewById(R.id.polar_diagram_view)
-
- // Set up mock polar data
- val mockPolarTable = createMockPolarTable()
- polarDiagramView.setPolarTable(mockPolarTable)
-
- // Simulate real-time updates for the polar diagram
- lifecycleScope.launch {
- var simulatedTws = 8.0
- var simulatedTwa = 40.0
- var simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
-
- while (true) {
- // Update instrument display with current simulated values
- updateInstrumentDisplay(
- aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1), // AWS usually higher than TWS
- tws = "%.1f".format(Locale.getDefault(), simulatedTws),
- hdg = "---", // No mock for HDG
- cog = "---", // No mock for COG
- bsp = "%.1f".format(Locale.getDefault(), simulatedBsp),
- sog = "%.1f".format(Locale.getDefault(), simulatedBsp * 0.95), // SOG usually slightly less than BSP
- vmg = "%.1f".format(Locale.getDefault(), mockPolarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, simulatedBsp) ?: 0.0),
- depth = getString(R.string.placeholder_depth_value),
- polarPct = "%.0f%%".format(Locale.getDefault(), mockPolarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, simulatedBsp)),
- baro = getString(R.string.placeholder_baro_value)
- )
- polarDiagramView.setCurrentPerformance(simulatedTws, simulatedTwa, simulatedBsp)
-
- // Slowly change TWA to simulate sailing
- simulatedTwa += 0.5 // Change by 0.5 degrees
- if (simulatedTwa > 170) simulatedTwa = 40.0 // Reset or change direction
- simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
-
- kotlinx.coroutines.delay(1000) // Update every second
- }
- }
+ private fun setupHandlers() {
+ mobHandler = MobHandler(
+ container = findViewById(R.id.mob_navigation_container),
+ valueDistance = findViewById(R.id.mob_value_distance),
+ valueElapsedTime = findViewById(R.id.mob_value_elapsed_time),
+ recoveredButton = findViewById(R.id.mob_recovered_button)
+ ) {
+ findViewById<View>(R.id.fab_mob).visibility = View.VISIBLE
+ findViewById<View>(R.id.fab_toggle_instruments).visibility = View.VISIBLE
+ }
+
+ instrumentHandler = InstrumentHandler(
+ valueAws = findViewById(R.id.value_aws),
+ valueTws = findViewById(R.id.value_tws),
+ valueHdg = findViewById(R.id.value_hdg),
+ valueCog = findViewById(R.id.value_cog),
+ valueBsp = findViewById(R.id.value_bsp),
+ valueSog = findViewById(R.id.value_sog),
+ valueVmg = findViewById(R.id.value_vmg),
+ valueDepth = findViewById(R.id.value_depth),
+ valuePolarPct = findViewById(R.id.value_polar_pct),
+ valueBaro = findViewById(R.id.value_baro),
+ labelTrend = findViewById(R.id.label_trend),
+ barometerTrendView = findViewById(R.id.barometer_trend_view),
+ polarDiagramView = findViewById(R.id.polar_diagram_view)
+ )
- // Initialize Anchor Watch UI elements
- fabAnchor = findViewById(R.id.fab_anchor)
- anchorConfigContainer = findViewById(R.id.anchor_config_container)
- anchorStatusText = findViewById(R.id.anchor_status_text)
- anchorRadiusText = findViewById(R.id.anchor_radius_text)
- buttonDecreaseRadius = findViewById(R.id.button_decrease_radius)
- buttonIncreaseRadius = findViewById(R.id.button_increase_radius)
- buttonSetAnchor = findViewById(R.id.button_set_anchor)
- buttonStopAnchor = findViewById(R.id.button_stop_anchor)
-
- // Set initial placeholder values
- updateInstrumentDisplay(
- aws = getString(R.string.placeholder_aws_value),
- tws = getString(R.string.placeholder_tws_value),
- hdg = getString(R.string.placeholder_hdg_value),
- cog = getString(R.string.placeholder_cog_value),
- bsp = getString(R.string.placeholder_bsp_value),
- sog = getString(R.string.placeholder_sog_value),
- vmg = getString(R.string.placeholder_vmg_value),
- depth = getString(R.string.placeholder_depth_value),
- polarPct = getString(R.string.placeholder_polar_value),
- baro = getString(R.string.placeholder_baro_value)
+ anchorWatchHandler = AnchorWatchHandler(
+ context = this,
+ container = findViewById(R.id.anchor_config_container),
+ statusText = findViewById(R.id.anchor_status_text),
+ radiusText = findViewById(R.id.anchor_radius_text),
+ buttonDecrease = findViewById(R.id.button_decrease_radius),
+ buttonIncrease = findViewById(R.id.button_increase_radius),
+ buttonSet = findViewById(R.id.button_set_anchor),
+ buttonStop = findViewById(R.id.button_stop_anchor)
)
- fabToggleInstruments.setOnClickListener {
- if (instrumentDisplayContainer.visibility == View.VISIBLE) {
- instrumentDisplayContainer.visibility = View.GONE
+ val mockPolarTable = createMockPolarTable()
+ findViewById<PolarDiagramView>(R.id.polar_diagram_view).setPolarTable(mockPolarTable)
+ startInstrumentSimulation(mockPolarTable)
+ }
+
+ private fun setupListeners() {
+ findViewById<FloatingActionButton>(R.id.fab_toggle_instruments).setOnClickListener {
+ val container = findViewById<View>(R.id.instrument_display_container)
+ if (container.visibility == View.VISIBLE) {
+ container.visibility = View.GONE
mapView?.visibility = View.VISIBLE
} else {
- instrumentDisplayContainer.visibility = View.VISIBLE
+ container.visibility = View.VISIBLE
mapView?.visibility = View.GONE
}
}
- fabTidal.setOnClickListener {
- toggleTidalCurrentVisibility()
- }
-
- fabMob.setOnClickListener {
+ findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
activateMob()
}
- fabAnchor.setOnClickListener {
- if (anchorConfigContainer.visibility == View.VISIBLE) {
- anchorConfigContainer.visibility = View.GONE
- } else {
- anchorConfigContainer.visibility = View.VISIBLE
- // Ensure anchor radius display is updated when shown
- anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- }
- }
-
- buttonDecreaseRadius.setOnClickListener {
- currentWatchCircleRadius = (currentWatchCircleRadius - 5).coerceAtLeast(10.0) // Minimum 10m
- anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
- }
- startService(intent)
- }
-
- buttonIncreaseRadius.setOnClickListener {
- currentWatchCircleRadius = (currentWatchCircleRadius + 5).coerceAtMost(200.0) // Maximum 200m
- anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
- }
- startService(intent)
- }
-
- buttonSetAnchor.setOnClickListener {
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_START_ANCHOR_WATCH
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
- }
- startService(intent)
- Toast.makeText(this@MainActivity, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
-
- buttonStopAnchor.setOnClickListener {
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_ANCHOR_WATCH
- }
- startService(intent)
- Toast.makeText(this@MainActivity, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
+ findViewById<FloatingActionButton>(R.id.fab_anchor).setOnClickListener {
+ anchorWatchHandler?.toggleVisibility()
}
- mobRecoveredButton.setOnClickListener {
- recoverMob()
+ findViewById<FloatingActionButton>(R.id.fab_voice_log).setOnClickListener {
+ toggleVoiceLog()
}
- // Initialize Voice Log
- fabVoiceLog = findViewById(R.id.fab_voice_log)
- voiceLogContainer = findViewById(R.id.voice_log_container)
-
- fabVoiceLog.setOnClickListener {
- if (voiceLogContainer.visibility == View.VISIBLE) {
- supportFragmentManager.popBackStack()
- voiceLogContainer.visibility = View.GONE
- } else {
- voiceLogContainer.visibility = View.VISIBLE
- supportFragmentManager.beginTransaction()
- .replace(R.id.voice_log_container, VoiceLogFragment())
- .addToBackStack(null)
- .commit()
- }
+ findViewById<FloatingActionButton>(R.id.fab_tidal).setOnClickListener {
+ toggleTidalCurrents()
}
}
- private fun startLocationService() {
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_START_FOREGROUND_SERVICE
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- startForegroundService(intent)
- } else {
- startService(intent)
- }
- }
-
- private fun stopLocationService() {
- val intent = Intent(this, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_FOREGROUND_SERVICE
- }
- stopService(intent)
- }
-
- /**
- * Start reading AIS NMEA sentences from a hardware receiver over TCP.
- * Sentences are forwarded to the ViewModel for processing.
- * Falls back gracefully when the hardware feed is unavailable.
- */
- private fun startAisHardwareFeed(host: String = "localhost", port: Int = 10110) {
- lifecycleScope.launch(Dispatchers.IO) {
- try {
- val socket = java.net.Socket(host, port)
- val reader = socket.getInputStream().bufferedReader()
- reader.lineSequence().forEach { line ->
- if (line.startsWith("!")) {
- withContext(Dispatchers.Main) {
- viewModel.processAisSentence(line)
- }
- }
- }
- } catch (e: Exception) {
- // Hardware feed unavailable — internet fallback will be used
- }
- }
- }
-
- private fun createMockPolarTable(): PolarTable {
- // Example polar data for a hypothetical boat
- // TWS 6 knots
- val polar6k = PolarCurve(
- twS = 6.0,
- points = listOf(
- PolarPoint(tWa = 30.0, bSp = 3.0),
- PolarPoint(tWa = 45.0, bSp = 4.0),
- PolarPoint(tWa = 60.0, bSp = 4.5),
- PolarPoint(tWa = 90.0, bSp = 4.8),
- PolarPoint(tWa = 120.0, bSp = 4.0),
- PolarPoint(tWa = 150.0, bSp = 3.0),
- PolarPoint(tWa = 180.0, bSp = 2.0)
- )
- )
-
- // TWS 8 knots
- val polar8k = PolarCurve(
- twS = 8.0,
- points = listOf(
- PolarPoint(tWa = 30.0, bSp = 4.0),
- PolarPoint(tWa = 45.0, bSp = 5.0),
- PolarPoint(tWa = 60.0, bSp = 5.5),
- PolarPoint(tWa = 90.0, bSp = 5.8),
- PolarPoint(tWa = 120.0, bSp = 5.0),
- PolarPoint(tWa = 150.0, bSp = 4.0),
- PolarPoint(tWa = 180.0, bSp = 2.5)
- )
- )
-
- // TWS 10 knots
- val polar10k = PolarCurve(
- twS = 10.0,
- points = listOf(
- PolarPoint(tWa = 30.0, bSp = 5.0),
- PolarPoint(tWa = 45.0, bSp = 6.0),
- PolarPoint(tWa = 60.0, bSp = 6.5),
- PolarPoint(tWa = 90.0, bSp = 6.8),
- PolarPoint(tWa = 120.0, bSp = 6.0),
- PolarPoint(tWa = 150.0, bSp = 4.5),
- PolarPoint(tWa = 180.0, bSp = 3.0)
- )
- )
-
- return PolarTable(curves = listOf(polar6k, polar8k, polar10k))
- }
-
-
- private fun setupAnchorMapLayers(style: Style) {
- // Add anchor icon
- style.addImage(ANCHOR_ICON_ID, BitmapFactory.decodeResource(resources, R.drawable.ic_anchor))
-
- // Create sources
- anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
- anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
- anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID)
- anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
- style.addSource(anchorPointSource!!)
- style.addSource(anchorCircleSource!!)
-
- // Create layers
- val anchorPointLayer = SymbolLayer(ANCHOR_POINT_LAYER_ID, ANCHOR_POINT_SOURCE_ID).apply {
- setProperties(
- PropertyFactory.iconImage(ANCHOR_ICON_ID),
- PropertyFactory.iconAllowOverlap(true),
- PropertyFactory.iconIgnorePlacement(true)
- )
- }
- val anchorCircleLayer = CircleLayer(ANCHOR_CIRCLE_LAYER_ID, ANCHOR_CIRCLE_SOURCE_ID).apply {
- setProperties(
- PropertyFactory.circleColor(ContextCompat.getColor(this@MainActivity, R.color.anchor_button_background)),
- PropertyFactory.circleOpacity(0.3f),
- PropertyFactory.circleStrokeWidth(2.0f),
- PropertyFactory.circleStrokeColor(ContextCompat.getColor(this@MainActivity, R.color.anchor_button_background))
- )
- }
-
- style.addLayer(anchorCircleLayer)
- style.addLayer(anchorPointLayer)
- }
-
- private fun updateAnchorMapLayers(state: AnchorWatchState) {
- maplibreMap?.getStyle { style ->
- if (state.isActive && state.anchorLocation != null) {
- // Update anchor point
- val anchorPoint = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
- anchorPointSource?.setGeoJson(Feature.fromGeometry(anchorPoint))
-
- // Update watch circle
- val watchCirclePolygon = createWatchCirclePolygon(anchorPoint, state.watchCircleRadiusMeters)
- anchorCircleSource?.setGeoJson(Feature.fromGeometry(watchCirclePolygon))
-
- // Set layer visibility to visible
- style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.VISIBLE))
- style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.VISIBLE))
- } else {
- // Clear sources and hide layers
- anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
- anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
- style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE))
- style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE))
+ private fun activateMob() {
+ lifecycleScope.launch {
+ LocationService.locationFlow.firstOrNull()?.let { gpsData ->
+ findViewById<View>(R.id.fab_mob).visibility = View.GONE
+ findViewById<View>(R.id.fab_toggle_instruments).visibility = View.GONE
+ val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm)
+ mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer)
}
}
}
- // Helper function to create a GeoJSON Polygon for a circle
- private fun createWatchCirclePolygon(center: Point, radiusMeters: Double, steps: Int = 64): Polygon {
- val coordinates = mutableListOf<Point>()
- val earthRadius = 6371000.0 // Earth's radius in meters
-
- for (i in 0..steps) {
- val angle = 2 * Math.PI * i / steps
- val lat = center.latitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * Math.cos(angle)
- val lon = center.longitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * Math.sin(angle) / Math.cos(Math.toRadians(center.latitude()))
- coordinates.add(Point.fromLngLat(lon, lat))
- }
- return Polygon.fromLngLats(listOf(coordinates))
- }
-
- private fun setupTidalCurrentMapLayers(style: Style) {
- // Add tidal arrow icon (vector drawable — must rasterise manually; BitmapFactory returns null for VDs)
- val tidalArrowDrawable = ContextCompat.getDrawable(this, R.drawable.ic_tidal_arrow) ?: return
- val tidalArrowBitmap = Bitmap.createBitmap(
- tidalArrowDrawable.intrinsicWidth.coerceAtLeast(24),
- tidalArrowDrawable.intrinsicHeight.coerceAtLeast(24),
- Bitmap.Config.ARGB_8888
- )
- Canvas(tidalArrowBitmap).also { canvas ->
- tidalArrowDrawable.setBounds(0, 0, canvas.width, canvas.height)
- tidalArrowDrawable.draw(canvas)
- }
- style.addImage(TIDAL_ARROW_ICON_ID, tidalArrowBitmap)
-
- // Create source
- tidalCurrentSource = GeoJsonSource(TIDAL_CURRENT_SOURCE_ID)
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
- style.addSource(tidalCurrentSource!!)
-
- // Create layer for arrows
- val tidalCurrentLayer = SymbolLayer(TIDAL_CURRENT_LAYER_ID, TIDAL_CURRENT_SOURCE_ID).apply {
- setProperties(
- PropertyFactory.iconImage(TIDAL_ARROW_ICON_ID),
- PropertyFactory.iconRotate(Expression.get("rotation")),
- PropertyFactory.iconSize(Expression.get("size")),
- PropertyFactory.iconColor(Expression.get("color")),
- PropertyFactory.iconAllowOverlap(true),
- PropertyFactory.iconIgnorePlacement(true)
- )
+ private fun toggleVoiceLog() {
+ val container = findViewById<FrameLayout>(R.id.voice_log_container)
+ if (container.visibility == View.VISIBLE) {
+ supportFragmentManager.popBackStack()
+ container.visibility = View.GONE
+ } else {
+ container.visibility = View.VISIBLE
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.voice_log_container, VoiceLogFragment())
+ .addToBackStack(null)
+ .commit()
}
- style.addLayer(tidalCurrentLayer)
}
- private fun toggleTidalCurrentVisibility() {
+ private fun toggleTidalCurrents() {
val newState = !LocationService.tidalCurrentState.value.isVisible
- // Since we cannot update the flow directly from MainActivity (it's owned by LocationService),
- // we should ideally send an intent or use a shared state.
- // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
- // but it's a StateFlow in LocationService.
- // Let's add a public update method or an action to LocationService.
val intent = Intent(this, LocationService::class.java).apply {
action = LocationService.ACTION_TOGGLE_TIDAL_VISIBILITY
putExtra(LocationService.EXTRA_TIDAL_VISIBILITY, newState)
}
startService(intent)
- val toastMsg = if (newState) "Tidal current overlay enabled" else "Tidal current overlay disabled"
- Toast.makeText(this, toastMsg, Toast.LENGTH_SHORT).show()
}
- private fun updateTidalCurrentMapLayers(state: TidalCurrentState) {
- maplibreMap?.getStyle { style ->
- val layer = style.getLayer(TIDAL_CURRENT_LAYER_ID)
- if (state.isVisible) {
- layer?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.VISIBLE))
- val features = state.currents.map { current ->
- Feature.fromGeometry(
- Point.fromLngLat(current.longitude, current.latitude)
- ).apply {
- addNumberProperty("rotation", current.directionDegrees.toFloat())
- // Scale arrow size based on speed (knots)
- val size = (0.5 + current.speedKnots / 2.0).coerceAtMost(2.0).toFloat()
- addNumberProperty("size", size)
- // Optionally change color based on speed
- val color = if (current.speedKnots > 2.0) "#FF0000" else "#0000FF"
- addStringProperty("color", color)
- }
- }
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
- } else {
- layer?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE))
+ private fun observeDataSources() {
+ lifecycleScope.launch {
+ LocationService.locationFlow.distinctUntilChanged().collect { gpsData ->
+ mobHandler?.updateMobUI(gpsData.latitude, gpsData.longitude)
}
}
- }
- private fun observeBarometerStatus() {
lifecycleScope.launch {
- LocationService.barometerStatus.collect { status ->
- withContext(Dispatchers.Main) {
- valueBaro.text = String.format(Locale.getDefault(), "%.1f", status.currentPressureHpa)
- labelTrend.text = String.format(Locale.getDefault(), "TREND: %s", status.formatTrend())
- barometerTrendView.setHistory(status.history)
- }
+ LocationService.anchorWatchState.collect { state ->
+ anchorWatchHandler?.updateUI(state)
+ mapHandler?.updateAnchorWatch(state)
}
}
- }
- private fun observeTidalCurrentState() {
lifecycleScope.launch {
- LocationService.tidalCurrentState.collect { state ->
- withContext(Dispatchers.Main) {
- updateTidalCurrentMapLayers(state)
- }
+ LocationService.barometerStatus.collect { status ->
+ instrumentHandler?.updateDisplay(
+ baro = String.format(Locale.getDefault(), "%.1f", status.currentPressureHpa),
+ trend = "TREND: ${status.formatTrend()}"
+ )
+ instrumentHandler?.updateBarometerTrend(status.history)
}
}
- }
- private fun observeLocationUpdates() {
lifecycleScope.launch {
- // Observe from the static locationFlow in LocationService
- LocationService.locationFlow.distinctUntilChanged().collect { gpsData ->
- if (mobActivated && activeMobWaypoint != null) {
- val mobLocation = Location("").apply {
- latitude = activeMobWaypoint!!.latitude
- longitude = activeMobWaypoint!!.longitude
- }
- val currentPosition = Location("").apply {
- latitude = gpsData.latitude
- longitude = gpsData.longitude
- }
-
- val distance = currentPosition.distanceTo(mobLocation) // distance in meters
- val elapsedTime = System.currentTimeMillis() - activeMobWaypoint!!.timestamp
-
- withContext(Dispatchers.Main) {
- mobValueDistance.text = String.format(Locale.getDefault(), "%.1f m", distance)
- mobValueElapsedTime.text = formatElapsedTime(elapsedTime)
- // TODO: Update bearing arrow (requires custom view or rotation logic)
- }
- }
+ LocationService.tidalCurrentState.collect { state ->
+ mapHandler?.updateTidalCurrents(state)
}
}
}
- private fun observeAnchorWatchState() {
- lifecycleScope.launch {
- // Observe from the static anchorWatchState in LocationService
- LocationService.anchorWatchState.collect { state ->
- withContext(Dispatchers.Main) {
- updateAnchorMapLayers(state) // Update map layers
- if (state.isActive && state.anchorLocation != null) {
- currentWatchCircleRadius = state.watchCircleRadiusMeters
- anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
-
- // Get the current location from the static flow
- val currentLocation = LocationService.locationFlow.firstOrNull()?.toLocation()
- if (currentLocation != null) {
- val distance = state.anchorLocation.distanceTo(currentLocation)
- val distanceDiff = distance - state.watchCircleRadiusMeters
- if (distanceDiff > 0) {
- anchorStatusText.text = String.format(
- Locale.getDefault(),
- getString(R.string.anchor_active_dragging_format),
- state.anchorLocation.latitude,
- state.anchorLocation.longitude,
- state.watchCircleRadiusMeters,
- distance,
- distanceDiff
- )
- anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_alarm))
- } else {
- anchorStatusText.text = String.format(
- Locale.getDefault(),
- getString(R.string.anchor_active_format),
- state.anchorLocation.latitude,
- state.anchorLocation.longitude,
- state.watchCircleRadiusMeters,
- distance,
- -distanceDiff // distance FROM limit
- )
- anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
+ private fun startAisHardwareFeed() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ java.net.Socket("localhost", 10110).use { socket ->
+ socket.getInputStream().bufferedReader().lineSequence().forEach { line ->
+ if (line.startsWith("!")) {
+ withContext(Dispatchers.Main) {
+ viewModel.processAisSentence(line)
}
- } else {
- anchorStatusText.text = "Anchor watch active (waiting for location...)"
- anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
}
- } else {
- anchorStatusText.text = getString(R.string.anchor_inactive)
- anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
}
}
+ } catch (e: Exception) {
+ Log.w("MainActivity", "Hardware AIS feed unavailable")
}
}
}
- private fun activateMob() {
- // Get last known location from the static flow
+ private fun startInstrumentSimulation(polarTable: PolarTable) {
lifecycleScope.launch {
- val lastGpsData: GpsData? = LocationService.locationFlow.firstOrNull()
- if (lastGpsData != null) {
- activeMobWaypoint = MobWaypoint(
- latitude = lastGpsData.latitude,
- longitude = lastGpsData.longitude,
- timestamp = System.currentTimeMillis()
+ var simulatedTws = 8.0
+ var simulatedTwa = 40.0
+ while (true) {
+ val bsp = polarTable.interpolateBsp(simulatedTws, simulatedTwa)
+ instrumentHandler?.updateDisplay(
+ aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1),
+ tws = "%.1f".format(Locale.getDefault(), simulatedTws),
+ bsp = "%.1f".format(Locale.getDefault(), bsp),
+ sog = "%.1f".format(Locale.getDefault(), bsp * 0.95),
+ vmg = "%.1f".format(Locale.getDefault(), polarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, bsp) ?: 0.0),
+ polarPct = "%.0f%%".format(Locale.getDefault(), polarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, bsp))
)
- mobActivated = true
- Log.d("MainActivity", "MOB Activated! Location: ${activeMobWaypoint!!.latitude}, ${activeMobWaypoint!!.longitude} at ${activeMobWaypoint!!.timestamp}")
- Toast.makeText(this@MainActivity, "MOB Activated!", Toast.LENGTH_SHORT).show()
-
- // Switch display to MOB navigation view
- mapView?.visibility = View.GONE
- instrumentDisplayContainer.visibility = View.GONE
- fabToggleInstruments.visibility = View.GONE
- fabMob.visibility = View.GONE
- anchorConfigContainer.visibility = View.GONE // Hide anchor config
- fabAnchor.visibility = View.GONE // Hide anchor FAB
- mobNavigationContainer.visibility = View.VISIBLE
-
-
- // Sound continuous alarm
- mobMediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm).apply {
- isLooping = true
- start()
- }
-
- // Log event to logbook
- logMobEvent(activeMobWaypoint!!)
- } else {
- Toast.makeText(this@MainActivity, "Could not get current location for MOB", Toast.LENGTH_SHORT).show()
- Log.e("MainActivity", "Last known location is null, cannot activate MOB.")
+ instrumentHandler?.updatePolarDiagram(simulatedTws, simulatedTwa, bsp)
+
+ simulatedTwa = (simulatedTwa + 0.5).let { if (it > 170) 40.0 else it }
+ delay(1000)
}
}
}
- private fun recoverMob() {
- mobActivated = false
- activeMobWaypoint = null
- stopMobAlarm()
-
- mobNavigationContainer.visibility = View.GONE
- mapView?.visibility = View.VISIBLE
- // instrumentDisplayContainer visibility is controlled by fabToggleInstruments, so leave as is
- fabToggleInstruments.visibility = View.VISIBLE
- fabMob.visibility = View.VISIBLE
- fabAnchor.visibility = View.VISIBLE // Show anchor FAB
- anchorConfigContainer.visibility = View.GONE // Hide anchor config
-
- Toast.makeText(this, "MOB Recovery initiated.", Toast.LENGTH_SHORT).show()
- Log.d("MainActivity", "MOB Recovery initiated.")
- }
-
- private fun stopMobAlarm() {
- mobMediaPlayer?.stop()
- mobMediaPlayer?.release()
- mobMediaPlayer = null
- Log.d("MainActivity", "MOB Alarm stopped and released.")
- }
-
- private fun logMobEvent(mobWaypoint: MobWaypoint) {
- Log.i("Logbook", "MOB Event: Lat ${mobWaypoint.latitude}, Lon ${mobWaypoint.longitude}, Time ${mobWaypoint.timestamp}")
- // TODO: Integrate with actual logbook system for persistence
- }
-
-
- private fun formatElapsedTime(milliseconds: Long): String {
- val hours = TimeUnit.MILLISECONDS.toHours(milliseconds)
- val minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60
- val seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60
- return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)
- }
-
- private fun updateInstrumentDisplay(
- aws: String,
- tws: String,
- hdg: String,
- cog: String,
- bsp: String,
- sog: String,
- vmg: String,
- depth: String,
- polarPct: String,
- baro: String
- ) {
- valueAws.text = aws
- valueTws.text = tws
- valueHdg.text = hdg
- valueCog.text = cog
- valueBsp.text = bsp
- valueSog.text = sog
- valueVmg.text = vmg
- valueDepth.text = depth
- valuePolarPct.text = polarPct
- valueBaro.text = baro
- }
-
- override fun onStart() {
- super.onStart()
- mapView?.onStart()
- }
-
- override fun onResume() {
- super.onResume()
- mapView?.onResume()
- }
-
- override fun onPause() {
- super.onPause()
- mapView?.onPause()
- }
-
- override fun onStop() {
- super.onStop()
- mapView?.onStop()
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- mapView?.onSaveInstanceState(outState)
+ private fun rasterizeDrawable(drawableId: Int): Bitmap {
+ val drawable = ContextCompat.getDrawable(this, drawableId)!!
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(1),
+ drawable.intrinsicHeight.coerceAtLeast(1),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ return bitmap
}
- override fun onLowMemory() {
- super.onLowMemory()
- mapView?.onLowMemory()
+ private fun createMockPolarTable(): PolarTable {
+ val curves = listOf(6.0, 8.0, 10.0).map { tws ->
+ PolarCurve(tws, listOf(30.0, 45.0, 60.0, 90.0, 120.0, 150.0, 180.0).map { twa ->
+ PolarPoint(twa, tws * (0.4 + twa / 200.0))
+ })
+ }
+ return PolarTable(curves)
}
- override fun onDestroy() {
- super.onDestroy()
- mapView?.onDestroy()
- mobMediaPlayer?.release() // Ensure media player is released on destroy
- }
+ override fun onStart() { super.onStart(); mapView?.onStart() }
+ override fun onResume() { super.onResume(); mapView?.onResume() }
+ override fun onPause() { super.onPause(); mapView?.onPause() }
+ override fun onStop() { super.onStop(); mapView?.onStop() }
+ override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
+ override fun onLowMemory() { super.onLowMemory(); mapView?.onLowMemory() }
+ override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState); mapView?.onSaveInstanceState(outState) }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MobData.kt b/android-app/app/src/main/kotlin/org/terst/nav/MobData.kt
new file mode 100644
index 0000000..2a40ff6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MobData.kt
@@ -0,0 +1,10 @@
+package org.terst.nav
+
+/**
+ * Represents a Man Overboard (MOB) waypoint.
+ */
+data class MobWaypoint(
+ val latitude: Double,
+ val longitude: Double,
+ val timestamp: Long // System.currentTimeMillis()
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt
new file mode 100644
index 0000000..70ca6b7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt
@@ -0,0 +1,29 @@
+package org.terst.nav
+
+import java.util.Random
+
+/**
+ * Generator for mock tidal current data.
+ */
+object MockTidalCurrentGenerator {
+ private val random = Random()
+
+ fun generateMockCurrents(): List<TidalCurrent> {
+ val currents = mutableListOf<TidalCurrent>()
+ val baseLat = 50.8
+ val baseLon = -1.3
+
+ for (i in 0 until 10) {
+ currents.add(
+ TidalCurrent(
+ latitude = baseLat + (random.nextDouble() - 0.5) * 0.1,
+ longitude = baseLon + (random.nextDouble() - 0.5) * 0.1,
+ speedKnots = random.nextDouble() * 5.0,
+ directionDegrees = random.nextDouble() * 360.0,
+ timestampMillis = System.currentTimeMillis()
+ )
+ )
+ }
+ return currents
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
new file mode 100644
index 0000000..d55de90
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
@@ -0,0 +1,99 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.content.Intent
+import android.view.View
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.constraintlayout.widget.ConstraintLayout
+import org.terst.nav.AnchorWatchState
+import org.terst.nav.LocationService
+import java.util.Locale
+
+/**
+ * Handles the Anchor Watch UI interactions and state updates.
+ */
+class AnchorWatchHandler(
+ private val context: Context,
+ private val container: ConstraintLayout,
+ private val statusText: TextView,
+ private val radiusText: TextView,
+ private val buttonDecrease: Button,
+ private val buttonIncrease: Button,
+ private val buttonSet: Button,
+ private val buttonStop: Button
+) {
+ private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
+
+ init {
+ updateRadiusDisplay()
+
+ buttonDecrease.setOnClickListener {
+ updateRadius((currentRadius - 5).coerceAtLeast(10.0))
+ }
+
+ buttonIncrease.setOnClickListener {
+ updateRadius((currentRadius + 5).coerceAtMost(200.0))
+ }
+
+ buttonSet.setOnClickListener {
+ startWatch()
+ }
+
+ buttonStop.setOnClickListener {
+ stopWatch()
+ }
+ }
+
+ private fun updateRadius(newRadius: Double) {
+ currentRadius = newRadius
+ updateRadiusDisplay()
+ val intent = Intent(context, LocationService::class.java).apply {
+ action = LocationService.ACTION_UPDATE_WATCH_RADIUS
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
+ }
+ context.startService(intent)
+ }
+
+ private fun updateRadiusDisplay() {
+ radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius)
+ }
+
+ private fun startWatch() {
+ val intent = Intent(context, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_ANCHOR_WATCH
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
+ }
+ context.startService(intent)
+ Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun stopWatch() {
+ val intent = Intent(context, LocationService::class.java).apply {
+ action = LocationService.ACTION_STOP_ANCHOR_WATCH
+ }
+ context.startService(intent)
+ Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
+ }
+
+ /**
+ * Updates the UI based on the current anchor watch state.
+ */
+ fun updateUI(state: AnchorWatchState) {
+ statusText.text = if (state.isActive) {
+ "STATUS: ACTIVE" // Simple status for UI
+ } else {
+ "STATUS: INACTIVE"
+ }
+ currentRadius = state.watchCircleRadiusMeters
+ updateRadiusDisplay()
+ }
+
+ /**
+ * Toggles the visibility of the anchor configuration container.
+ */
+ fun toggleVisibility() {
+ container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
new file mode 100644
index 0000000..63c6165
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
@@ -0,0 +1,70 @@
+package org.terst.nav.ui
+
+import android.widget.TextView
+import org.terst.nav.BarometerReading
+import org.terst.nav.BarometerTrendView
+import org.terst.nav.PolarDiagramView
+import org.terst.nav.R
+import java.util.Locale
+
+/**
+ * Handles the display of instrument data in the UI.
+ */
+class InstrumentHandler(
+ private val valueAws: TextView,
+ private val valueTws: TextView,
+ private val valueHdg: TextView,
+ private val valueCog: TextView,
+ private val valueBsp: TextView,
+ private val valueSog: TextView,
+ private val valueVmg: TextView,
+ private val valueDepth: TextView,
+ private val valuePolarPct: TextView,
+ private val valueBaro: TextView,
+ private val labelTrend: TextView,
+ private val barometerTrendView: BarometerTrendView,
+ private val polarDiagramView: PolarDiagramView
+) {
+ /**
+ * Updates the text displays for various instruments.
+ */
+ fun updateDisplay(
+ aws: String? = null,
+ tws: String? = null,
+ hdg: String? = null,
+ cog: String? = null,
+ bsp: String? = null,
+ sog: String? = null,
+ vmg: String? = null,
+ depth: String? = null,
+ polarPct: String? = null,
+ baro: String? = null,
+ trend: String? = null
+ ) {
+ aws?.let { valueAws.text = it }
+ tws?.let { valueTws.text = it }
+ hdg?.let { valueHdg.text = it }
+ cog?.let { valueCog.text = it }
+ bsp?.let { valueBsp.text = it }
+ sog?.let { valueSog.text = it }
+ vmg?.let { valueVmg.text = it }
+ depth?.let { valueDepth.text = it }
+ polarPct?.let { valuePolarPct.text = it }
+ baro?.let { valueBaro.text = it }
+ trend?.let { labelTrend.text = it }
+ }
+
+ /**
+ * Updates the polar diagram view.
+ */
+ fun updatePolarDiagram(tws: Double, twa: Double, bsp: Double) {
+ polarDiagramView.setCurrentPerformance(tws, twa, bsp)
+ }
+
+ /**
+ * Updates the barometer trend chart.
+ */
+ fun updateBarometerTrend(history: List<BarometerReading>) {
+ barometerTrendView.setHistory(history)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
new file mode 100644
index 0000000..2e67975
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -0,0 +1,131 @@
+package org.terst.nav.ui
+
+import android.graphics.Bitmap
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.RasterLayer
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.android.style.sources.RasterSource
+import org.maplibre.android.style.sources.TileSet
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.Point
+import org.maplibre.geojson.Polygon
+import org.terst.nav.AnchorWatchState
+import org.terst.nav.TidalCurrentState
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Handles MapLibre initialization, layers, and updates.
+ */
+class MapHandler(private val maplibreMap: MapLibreMap) {
+
+ private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
+ private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
+ private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
+ private val ANCHOR_CIRCLE_LAYER_ID = "anchor-circle-layer"
+ private val ANCHOR_ICON_ID = "anchor-icon"
+
+ private val TIDAL_CURRENT_SOURCE_ID = "tidal-current-source"
+ private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
+ private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
+
+ private var anchorPointSource: GeoJsonSource? = null
+ private var anchorCircleSource: GeoJsonSource? = null
+ private var tidalCurrentSource: GeoJsonSource? = null
+
+ /**
+ * Initializes map layers for anchor watch and tidal currents.
+ */
+ fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap) {
+ // Anchor layers
+ style.addImage(ANCHOR_ICON_ID, anchorBitmap)
+ anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
+ style.addSource(anchorPointSource!!)
+ anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID)
+ style.addSource(anchorCircleSource!!)
+
+ style.addLayer(CircleLayer(ANCHOR_CIRCLE_LAYER_ID, ANCHOR_CIRCLE_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.circleColor("rgba(255, 0, 0, 0.2)"),
+ PropertyFactory.circleStrokeColor("red"),
+ PropertyFactory.circleStrokeWidth(2f)
+ )
+ })
+
+ style.addLayer(SymbolLayer(ANCHOR_POINT_LAYER_ID, ANCHOR_POINT_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(ANCHOR_ICON_ID),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true),
+ PropertyFactory.iconSize(0.5f)
+ )
+ })
+
+ // Tidal layers
+ style.addImage(TIDAL_ARROW_ICON_ID, arrowBitmap)
+ tidalCurrentSource = GeoJsonSource(TIDAL_CURRENT_SOURCE_ID)
+ style.addSource(tidalCurrentSource!!)
+
+ style.addLayer(SymbolLayer(TIDAL_CURRENT_LAYER_ID, TIDAL_CURRENT_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(TIDAL_ARROW_ICON_ID),
+ PropertyFactory.iconRotate(org.maplibre.android.style.expressions.Expression.get("rotation")),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(0.8f)
+ )
+ })
+ }
+
+ /**
+ * Updates the anchor watch visualization on the map.
+ */
+ fun updateAnchorWatch(state: AnchorWatchState) {
+ if (state.isActive && state.anchorLocation != null) {
+ val point = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
+ anchorPointSource?.setGeoJson(Feature.fromGeometry(point))
+
+ val circlePolygon = createCirclePolygon(state.anchorLocation.latitude, state.anchorLocation.longitude, state.watchCircleRadiusMeters)
+ anchorCircleSource?.setGeoJson(Feature.fromGeometry(circlePolygon))
+ } else {
+ anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
+ /**
+ * Updates the tidal current arrows on the map.
+ */
+ fun updateTidalCurrents(state: TidalCurrentState) {
+ if (state.isVisible) {
+ val features = state.currents.map { current ->
+ Feature.fromGeometry(Point.fromLngLat(current.longitude, current.latitude)).apply {
+ addNumberProperty("rotation", current.directionDegrees.toFloat())
+ }
+ }
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ } else {
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
+ private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
+ val points = mutableListOf<Point>()
+ val degreesBetweenPoints = 8
+ val numberOfPoints = 360 / degreesBetweenPoints
+
+ for (i in 0 until numberOfPoints) {
+ val angle = i * degreesBetweenPoints.toDouble()
+ val latOffset = radiusMeters / 111320.0 * cos(Math.toRadians(angle))
+ val lonOffset = radiusMeters / (111320.0 * cos(Math.toRadians(lat))) * sin(Math.toRadians(angle))
+ points.add(Point.fromLngLat(lon + lonOffset, lat + latOffset))
+ }
+ points.add(points[0]) // Close the polygon
+
+ return Polygon.fromLngLats(listOf(points))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MobHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MobHandler.kt
new file mode 100644
index 0000000..4fd0f17
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MobHandler.kt
@@ -0,0 +1,94 @@
+package org.terst.nav.ui
+
+import android.media.MediaPlayer
+import android.view.View
+import android.widget.Button
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import org.terst.nav.MobWaypoint
+import org.terst.nav.R
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+/**
+ * Handles Man Overboard (MOB) logic and UI updates.
+ */
+class MobHandler(
+ private val container: ConstraintLayout,
+ private val valueDistance: TextView,
+ private val valueElapsedTime: TextView,
+ private val recoveredButton: Button,
+ private val onRecovered: () -> Unit
+) {
+ private var mobActivated: Boolean = false
+ private var activeMobWaypoint: MobWaypoint? = null
+ private var mobMediaPlayer: MediaPlayer? = null
+
+ init {
+ recoveredButton.setOnClickListener {
+ recoverMob()
+ }
+ }
+
+ /**
+ * Activates the MOB state and starts the alarm.
+ */
+ fun activateMob(latitude: Double, longitude: Double, mediaPlayer: MediaPlayer?) {
+ mobActivated = true
+ activeMobWaypoint = MobWaypoint(
+ latitude = latitude,
+ longitude = longitude,
+ timestamp = System.currentTimeMillis()
+ )
+ container.visibility = View.VISIBLE
+ mobMediaPlayer = mediaPlayer
+ mobMediaPlayer?.isLooping = true
+ mobMediaPlayer?.start()
+ }
+
+ private fun recoverMob() {
+ mobActivated = false
+ activeMobWaypoint = null
+ container.visibility = View.GONE
+ mobMediaPlayer?.stop()
+ mobMediaPlayer?.prepareAsync() // Prepare for next use
+ onRecovered()
+ }
+
+ /**
+ * Updates the MOB navigation UI with current distance and elapsed time.
+ */
+ fun updateMobUI(currentLat: Double, currentLon: Double) {
+ if (!mobActivated) return
+
+ activeMobWaypoint?.let { waypoint ->
+ val distance = calculateDistance(currentLat, currentLon, waypoint.latitude, waypoint.longitude)
+ val elapsedTime = System.currentTimeMillis() - waypoint.timestamp
+
+ valueDistance.text = String.format(Locale.getDefault(), "%.0fm", distance)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(elapsedTime)
+ val seconds = (TimeUnit.MILLISECONDS.toSeconds(elapsedTime) % 60)
+ valueElapsedTime.text = String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
+ }
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 6371e3 // Earth radius in meters
+ val phi1 = Math.toRadians(lat1)
+ val phi2 = Math.toRadians(lat2)
+ val deltaPhi = Math.toRadians(lat2 - lat1)
+ val deltaLambda = Math.toRadians(lon2 - lon1)
+
+ val a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) *
+ Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return r * c
+ }
+
+ /**
+ * Returns true if MOB is currently activated.
+ */
+ fun isActivated(): Boolean = mobActivated
+}