summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-22 04:40:36 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-22 04:40:36 +0000
commit64d66c6ae4fde6aa3e66a5dba33950d447af1102 (patch)
tree612734b3803dd61f311e2a837c656832828481cd /android-app
parent4d637d284dce6fc674599c226dd063c442fd350f (diff)
refactor: cleanup, simplify, and modularize Android app logic
- Extracted MOB, Instruments, Map, and Anchor Watch logic from MainActivity into dedicated handlers. - Refactored LocationService to use a standalone MockTidalCurrentGenerator. - Removed legacy 'kotlin_old', 'res_old', and 'temp' directories. - Added KDoc documentation to core components and handlers. - Integrated JUnit 5 dependencies and configured the test runner. - Verified all changes with successful unit test execution.
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/build.gradle8
-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
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt108
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt22
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt254
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt670
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt168
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt270
-rw-r--r--android-app/app/src/main/res_old/drawable/ic_anchor.xml9
-rw-r--r--android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--android-app/app/src/main/res_old/raw/mob_alarm.mp31
-rwxr-xr-xandroid-app/app/src/main/temp/CompassRoseView.kt217
-rwxr-xr-xandroid-app/app/src/main/temp/HeadingDataProcessor.kt108
21 files changed, 672 insertions, 2612 deletions
diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle
index 0d5703d..264d0c5 100644
--- a/android-app/app/build.gradle
+++ b/android-app/app/build.gradle
@@ -41,6 +41,12 @@ android {
viewBinding true
}
+ testOptions {
+ unitTests.all {
+ useJUnitPlatform()
+ }
+ }
+
sourceSets {
main {
kotlin.srcDirs = ['src/main/kotlin', 'src/main/java']
@@ -92,6 +98,8 @@ dependencies {
// Testing
testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
testImplementation 'io.mockk:mockk:1.13.9'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'app.cash.turbine:turbine:1.1.0'
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
+}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt
deleted file mode 100644
index d4423db..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package org.terst.nav
-
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.content.Context
-import android.media.AudioAttributes
-import android.media.RingtoneManager
-import android.net.Uri
-import android.os.Build
-import android.os.VibrationEffect
-import android.os.Vibrator
-import android.os.VibratorManager // For API 31+
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-
-class AnchorAlarmManager(private val context: Context) {
-
- private val CHANNEL_ID = "anchor_alarm_channel"
- private val NOTIFICATION_ID = 1001
-
- private var isAlarming: Boolean = false
- private var ringtone: android.media.Ringtone? = null
-
- init {
- createNotificationChannel()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val name = "Anchor Alarm"
- val descriptionText = "Notifications for anchor drag events"
- val importance = NotificationManager.IMPORTANCE_HIGH
- val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
- description = descriptionText
- }
- val notificationManager: NotificationManager =
- context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- notificationManager.createNotificationChannel(channel)
- }
- }
-
- @Suppress("DEPRECATION")
- private fun getVibrator(): Vibrator? {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
- vibratorManager.defaultVibrator
- } else {
- context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
- }
- }
-
- fun startAlarm() {
- if (isAlarming) return
-
- isAlarming = true
- // Play sound
- try {
- val alarmUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
- ringtone = RingtoneManager.getRingtone(context, alarmUri)
- ringtone?.audioAttributes = AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_ALARM)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build()
- ringtone?.play()
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- // Vibrate
- val vibrator = getVibrator()
- if (vibrator?.hasVibrator() == true) {
- val pattern = longArrayOf(0, 1000, 1000) // Start immediately, vibrate for 1s, pause for 1s
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(pattern, 0)) // Repeat indefinitely
- } else {
- vibrator.vibrate(pattern, 0) // Repeat indefinitely
- }
- }
-
- // Show persistent notification
- showNotification("Anchor Drag Detected!", "Your boat is outside the watch circle.")
- }
-
- fun stopAlarm() {
- if (!isAlarming) return
-
- isAlarming = false
- ringtone?.stop()
- getVibrator()?.cancel()
- NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
- }
-
- private fun showNotification(title: String, message: String) {
- val builder = NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(android.R.drawable.ic_dialog_alert) // Replace with a proper icon
- .setContentTitle(title)
- .setContentText(message)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setCategory(NotificationCompat.CATEGORY_ALARM)
- .setOngoing(true) // Makes the notification persistent
- .setAutoCancel(false) // Does not disappear when tapped
- .setDefaults(NotificationCompat.DEFAULT_ALL) // Use default sound, vibrate, light (though we manually control sound/vibration)
-
- with(NotificationManagerCompat.from(context)) {
- notify(NOTIFICATION_ID, builder.build())
- }
- }
-}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt
deleted file mode 100644
index 03e6a2f..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.terst.nav
-
-import android.location.Location
-
-data class AnchorWatchState(
- val anchorLocation: Location? = null,
- val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
- val setTimeMillis: Long = 0L,
- val isActive: Boolean = false
-) {
- companion object {
- const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
- }
-
- fun isDragging(currentLocation: Location): Boolean {
- anchorLocation ?: return false // Cannot drag if anchor not set
- if (!isActive) return false // Not active, so not dragging
-
- val distance = anchorLocation.distanceTo(currentLocation)
- return distance > watchCircleRadiusMeters
- }
-}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt
deleted file mode 100644
index 4b59139..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-package org.terst.nav
-
-import android.annotation.SuppressLint
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.app.Service
-import android.content.Context
-import android.content.Intent
-import android.location.Location
-import android.os.IBinder
-import android.os.Looper
-import androidx.core.app.NotificationCompat
-import com.google.android.gms.location.*
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.update
-import android.util.Log
-import kotlinx.coroutines.tasks.await
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.launch
-
-data class GpsData(
- val latitude: Double,
- val longitude: Double,
- val speedOverGround: Float, // m/s
- val courseOverGround: Float // degrees
-) {
- fun toLocation(): Location {
- val location = Location("GpsData")
- location.latitude = latitude
- location.longitude = longitude
- location.speed = speedOverGround
- location.bearing = courseOverGround
- return location
- }
-}
-
-class LocationService : Service() {
-
- private lateinit var fusedLocationClient: FusedLocationProviderClient
- private lateinit var locationCallback: LocationCallback
- private lateinit var anchorAlarmManager: AnchorAlarmManager
- private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
- private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
- private val NOTIFICATION_ID = 123
-
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
-
- override fun onCreate() {
- super.onCreate()
- Log.d("LocationService", "Service created")
- fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
- anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
- createNotificationChannel()
-
- locationCallback = object : LocationCallback() {
- override fun onLocationResult(locationResult: LocationResult) {
- locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
- latitude = location.latitude,
- longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
- )
- serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow
- }
-
-
- // 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
- }
- }
- }
- }
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- when (intent?.action) {
- ACTION_START_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Starting foreground service")
- startForeground(NOTIFICATION_ID, createNotification())
- startLocationUpdatesInternal()
- }
- ACTION_STOP_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Stopping foreground service")
- stopLocationUpdatesInternal()
- stopSelf()
- }
- ACTION_START_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
- val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
- serviceScope.launch { startAnchorWatch(radius) }
- }
- ACTION_STOP_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
- stopAnchorWatch()
- }
- ACTION_UPDATE_WATCH_RADIUS -> {
- Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
- val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
- updateWatchCircleRadius(radius)
- }
- }
- return START_NOT_STICKY
- }
-
- override fun onBind(intent: Intent?): IBinder? {
- return null // Not a bound service
- }
-
- override fun onDestroy() {
- super.onDestroy()
- Log.d("LocationService", "Service destroyed")
- stopLocationUpdatesInternal()
- anchorAlarmManager.stopAlarm()
- _anchorWatchState.value = AnchorWatchState(isActive = false)
- isAlarmTriggered = false // Reset alarm trigger state
- serviceScope.cancel() // Cancel the coroutine scope
- }
-
- @SuppressLint("MissingPermission")
- private fun startLocationUpdatesInternal() {
- Log.d("LocationService", "Requesting location updates")
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
- .setMinUpdateIntervalMillis(500)
- .build()
- fusedLocationClient.requestLocationUpdates(
- locationRequest,
- locationCallback,
- Looper.getMainLooper()
- )
- }
-
- private fun stopLocationUpdatesInternal() {
- Log.d("LocationService", "Removing location updates")
- fusedLocationClient.removeLocationUpdates(locationCallback)
- }
-
- private fun createNotificationChannel() {
- val serviceChannel = NotificationChannel(
- NOTIFICATION_CHANNEL_ID,
- "Location Service Channel",
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService(NotificationManager::class.java) as NotificationManager
- manager.createNotificationChannel(serviceChannel)
- }
-
- private fun createNotification(): Notification {
- val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_IMMUTABLE
- )
-
- return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
- .setContentTitle("Sailing Companion")
- .setContentText("Tracking your location in the background...")
- .setSmallIcon(R.drawable.ic_anchor)
- .setContentIntent(pendingIntent)
- .build()
- }
-
- /**
- * Starts the anchor watch with the current location as the anchor point.
- * @param radiusMeters The watch circle radius in meters.
- */
- @SuppressLint("MissingPermission")
- suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
- val lastLocation = fusedLocationClient.lastLocation.await()
- lastLocation?.let { location ->
- _anchorWatchState.update { AnchorWatchState(
- anchorLocation = location,
- watchCircleRadiusMeters = radiusMeters,
- setTimeMillis = System.currentTimeMillis(),
- isActive = true
- ) }
- Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
- } ?: run {
- Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
- // Handle error, e.g., show a toast to the user
- }
- }
-
- /**
- * Stops the anchor watch.
- */
- fun stopAnchorWatch() {
- _anchorWatchState.update { AnchorWatchState(isActive = false) }
- Log.i("AnchorWatch", "Anchor watch stopped.")
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
-
- /**
- * Updates the watch circle radius.
- */
- fun updateWatchCircleRadius(radiusMeters: Double) {
- _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
- Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
- }
-
- companion object {
- const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
- const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
- const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
- const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
- const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
- const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
-
- // Publicly accessible flows
- val locationFlow: SharedFlow<GpsData>
- get() = _locationFlow
- val anchorWatchState: StateFlow<AnchorWatchState>
- get() = _anchorWatchState
-
- private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
- private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- }
-}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt
deleted file mode 100644
index a32fb18..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt
+++ /dev/null
@@ -1,670 +0,0 @@
-package org.terst.nav
-
-import android.Manifest
-import android.content.pm.PackageManager
-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.LinearLayout
-import android.widget.TextView
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-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 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.layers.PropertyFactory
-import org.maplibre.android.style.layers.SymbolLayer
-import org.maplibre.android.style.sources.GeoJsonSource
-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 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()
-)
-
-class MainActivity : AppCompatActivity() {
-
- private var mapView: MapView? = null
- private lateinit var instrumentDisplayContainer: ConstraintLayout
- private lateinit var fabToggleInstruments: FloatingActionButton
- private lateinit var fabMob: 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
-
- // 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 polarDiagramView: PolarDiagramView // Reference to the custom view
-
- // 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
-
- // 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
- } 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(
- 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)
- }
-
- // Check and request location permissions
- val allPermissionsGranted = permissionsToRequest.all {
- ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
- }
-
- if (!allPermissionsGranted) {
- requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
- } else {
- // Permissions already granted, start location service
- startLocationService()
- observeLocationUpdates() // Start observing location updates
- observeAnchorWatchState() // Start observing anchor watch state
- }
-
- mapView = findViewById<MapView>(R.id.mapView)
- mapView?.onCreate(savedInstanceState)
- mapView?.getMapAsync { maplibreMap ->
- this.maplibreMap = maplibreMap // Assign to class member
- maplibreMap.setStyle(Style.Builder().fromUri("https://tiles.openseamap.org/seamark/osm-bright/style.json")) { style ->
- setupAnchorMapLayers(style)
- }
- }
-
- instrumentDisplayContainer = findViewById(R.id.instrument_display_container)
- fabToggleInstruments = findViewById(R.id.fab_toggle_instruments)
- fabMob = findViewById(R.id.fab_mob)
-
- // 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)
-
- // 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))
- )
- 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
- }
- }
-
- // 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)
- )
-
- fabToggleInstruments.setOnClickListener {
- if (instrumentDisplayContainer.visibility == View.VISIBLE) {
- instrumentDisplayContainer.visibility = View.GONE
- mapView?.visibility = View.VISIBLE
- } else {
- instrumentDisplayContainer.visibility = View.VISIBLE
- mapView?.visibility = View.GONE
- }
- }
-
- fabMob.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()
- }
-
- mobRecoveredButton.setOnClickListener {
- recoverMob()
- }
- }
-
- 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)
- }
-
- 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))
- }
- }
- }
-
- // 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 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)
- }
- }
- }
- }
- }
-
- 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))
- }
- } 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))
- }
- }
- }
- }
- }
-
- private fun activateMob() {
- // Get last known location from the static flow
- lifecycleScope.launch {
- val lastGpsData: GpsData? = LocationService.locationFlow.firstOrNull()
- if (lastGpsData != null) {
- activeMobWaypoint = MobWaypoint(
- latitude = lastGpsData.latitude,
- longitude = lastGpsData.longitude,
- timestamp = System.currentTimeMillis()
- )
- 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.")
- }
- }
- }
-
- 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
- ) {
- 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
- }
-
- 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)
- }
-
- override fun onLowMemory() {
- super.onLowMemory()
- mapView?.onLowMemory()
- }
-
- override fun onDestroy() {
- super.onDestroy()
- mapView?.onDestroy()
- mobMediaPlayer?.release() // Ensure media player is released on destroy
- }
-}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt
deleted file mode 100644
index 88a8d0d..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt
+++ /dev/null
@@ -1,168 +0,0 @@
-package org.terst.nav
-
-import kotlin.math.abs
-import kotlin.math.cos
-import kotlin.math.max
-import kotlin.math.min
-
-// Represents a single point on a polar curve: True Wind Angle and target Boat Speed
-data class PolarPoint(val tWa: Double, val bSp: Double)
-
-// Represents a polar curve for a specific True Wind Speed
-data class PolarCurve(val twS: Double, val points: List<PolarPoint>) {
- init {
- require(points.isNotEmpty()) { "PolarCurve must have at least one point." }
- require(points.all { it.tWa in 0.0..180.0 }) {
- "TWA in PolarCurve must be between 0 and 180 degrees."
- }
- require(points.zipWithNext().all { it.first.tWa < it.second.tWa }) {
- "PolarPoints in a PolarCurve must be sorted by TWA."
- }
- }
-
- /**
- * Interpolates the target boat speed for a given True Wind Angle (TWA)
- * within this specific polar curve (constant TWS).
- */
- fun interpolateBsp(twa: Double): Double {
- val absoluteTwa = abs(twa)
- if (absoluteTwa < points.first().tWa) return points.first().bSp
- if (absoluteTwa > points.last().tWa) return points.last().bSp
-
- for (i in 0 until points.size - 1) {
- val p1 = points[i]
- val p2 = points[i + 1]
- if (absoluteTwa >= p1.tWa && absoluteTwa <= p2.tWa) {
- val ratio = (absoluteTwa - p1.tWa) / (p2.tWa - p1.tWa)
- return p1.bSp + ratio * (p2.bSp - p1.bSp)
- }
- }
- return 0.0
- }
-
- /**
- * Calculates the Velocity Made Good (VMG) for a given TWA and BSP.
- * VMG = BSP * cos(TWA)
- */
- fun calculateVmg(twa: Double, bsp: Double): Double {
- return bsp * cos(Math.toRadians(twa))
- }
-
- /**
- * Finds the TWA that yields the maximum upwind VMG for this polar curve.
- */
- fun findOptimalUpwindTwa(): Double {
- var maxVmg = -Double.MAX_VALUE
- var optimalTwa = 0.0
- // Search through TWA 0 to 90
- for (twa in 0..90) {
- val bsp = interpolateBsp(twa.toDouble())
- val vmg = calculateVmg(twa.toDouble(), bsp)
- if (vmg > maxVmg) {
- maxVmg = vmg
- optimalTwa = twa.toDouble()
- }
- }
- return optimalTwa
- }
-
- /**
- * Finds the TWA that yields the maximum downwind VMG for this polar curve.
- */
- fun findOptimalDownwindTwa(): Double {
- var maxVmg = -Double.MAX_VALUE // We want the most negative VMG for downwind
- var optimalTwa = 180.0
- // Search through TWA 90 to 180
- // For downwind, VMG is negative (moving away from wind)
- // We look for the minimum value (largest absolute negative)
- for (twa in 90..180) {
- val bsp = interpolateBsp(twa.toDouble())
- val vmg = calculateVmg(twa.toDouble(), bsp)
- if (vmg < maxVmg) {
- maxVmg = vmg
- optimalTwa = twa.toDouble()
- }
- }
- return optimalTwa
- }
-}
-
-// Represents the complete polar table for a boat, containing multiple PolarCurves for different TWS
-data class PolarTable(val curves: List<PolarCurve>) {
- init {
- require(curves.isNotEmpty()) { "PolarTable must have at least one curve." }
- require(curves.zipWithNext().all { it.first.twS < it.second.twS }) {
- "PolarCurves in a PolarTable must be sorted by TWS."
- }
- }
-
- /**
- * Interpolates the target boat speed for a given True Wind Speed (TWS) and True Wind Angle (TWA).
- */
- fun interpolateBsp(tws: Double, twa: Double): Double {
- if (tws <= curves.first().twS) return curves.first().interpolateBsp(twa)
- if (tws >= curves.last().twS) return curves.last().interpolateBsp(twa)
-
- for (i in 0 until curves.size - 1) {
- val c1 = curves[i]
- val c2 = curves[i + 1]
- if (tws >= c1.twS && tws <= c2.twS) {
- val ratio = (tws - c1.twS) / (c2.twS - c1.twS)
- val bsp1 = c1.interpolateBsp(twa)
- val bsp2 = c2.interpolateBsp(twa)
- return bsp1 + ratio * (bsp2 - bsp1)
- }
- }
- return 0.0
- }
-
- /**
- * Finds the optimal upwind TWA for a given TWS by interpolating between curves.
- */
- fun findOptimalUpwindTwa(tws: Double): Double {
- if (tws <= curves.first().twS) return curves.first().findOptimalUpwindTwa()
- if (tws >= curves.last().twS) return curves.last().findOptimalUpwindTwa()
-
- for (i in 0 until curves.size - 1) {
- val c1 = curves[i]
- val c2 = curves[i + 1]
- if (tws >= c1.twS && tws <= c2.twS) {
- val ratio = (tws - c1.twS) / (c2.twS - c1.twS)
- return c1.findOptimalUpwindTwa() + ratio * (c2.findOptimalUpwindTwa() - c1.findOptimalUpwindTwa())
- }
- }
- return 0.0
- }
-
- /**
- * Finds the optimal downwind TWA for a given TWS by interpolating between curves.
- */
- fun findOptimalDownwindTwa(tws: Double): Double {
- if (tws <= curves.first().twS) return curves.first().findOptimalDownwindTwa()
- if (tws >= curves.last().twS) return curves.last().findOptimalDownwindTwa()
-
- for (i in 0 until curves.size - 1) {
- val c1 = curves[i]
- val c2 = curves[i + 1]
- if (tws >= c1.twS && tws <= c2.twS) {
- val ratio = (tws - c1.twS) / (c2.twS - c1.twS)
- return c1.findOptimalDownwindTwa() + ratio * (c2.findOptimalDownwindTwa() - c1.findOptimalDownwindTwa())
- }
- }
- return 0.0
- }
-
- /**
- * Calculates the "Polar Percentage" for current boat performance.
- * Polar % = (Actual BSP / Target BSP) * 100
- * @return Polar percentage, or 0.0 if target BSP cannot be determined.
- */
- fun calculatePolarPercentage(currentTwS: Double, currentTwa: Double, currentBsp: Double): Double {
- val targetBsp = interpolateBsp(currentTwS, currentTwa)
- return if (targetBsp > 0) {
- (currentBsp / targetBsp) * 100.0
- } else {
- 0.0
- }
- }
-}
diff --git a/android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt b/android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt
deleted file mode 100644
index 4a678cc..0000000
--- a/android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt
+++ /dev/null
@@ -1,270 +0,0 @@
-package org.terst.nav
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.RectF
-import android.util.AttributeSet
-import android.view.View
-import kotlin.math.cos
-import kotlin.math.min
-import kotlin.math.sin
-
-class PolarDiagramView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : View(context, attrs, defStyleAttr) {
-
- private val gridPaint = Paint().apply {
- color = Color.parseColor("#404040") // Dark gray for grid lines
- style = Paint.Style.STROKE
- strokeWidth = 1f
- isAntiAlias = true
- }
-
- private val textPaint = Paint().apply {
- color = Color.WHITE
- textSize = 24f
- isAntiAlias = true
- textAlign = Paint.Align.CENTER
- }
-
- private val polarCurvePaint = Paint().apply {
- color = Color.CYAN // Bright color for the polar curve
- style = Paint.Style.STROKE
- strokeWidth = 3f
- isAntiAlias = true
- }
-
- private val currentPerformancePaint = Paint().apply {
- color = Color.RED // Red dot for current performance
- style = Paint.Style.FILL
- isAntiAlias = true
- }
-
- private val noSailZonePaint = Paint().apply {
- color = Color.parseColor("#80FF0000") // Semi-transparent red for no-sail zone
- style = Paint.Style.FILL
- isAntiAlias = true
- }
-
- private val optimalVmgPaint = Paint().apply {
- color = Color.GREEN // Green for optimal VMG angles
- style = Paint.Style.STROKE
- strokeWidth = 4f
- isAntiAlias = true
- }
-
- private var viewCenterX: Float = 0f
- private var viewCenterY: Float = 0f
- private var radius: Float = 0f
-
- // Data for rendering
- private var polarTable: PolarTable? = null
- private var currentTws: Double = 0.0
- private var currentTwa: Double = 0.0
- private var currentBsp: Double = 0.0
-
- // Configuration for the diagram
- private val maxSpeedKnots = 10.0 // Max speed for the outermost circle in knots
- private val speedCircleInterval = 2.0 // Interval between speed circles in knots
- private val twaInterval = 30 // Interval between TWA radial lines in degrees
- private val noSailZoneAngle = 20.0 // Angle +/- from 0 degrees for no-sail zone
-
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
- super.onSizeChanged(w, h, oldw, oldh)
- viewCenterX = w / 2f
- viewCenterY = h / 2f
- radius = min(w, h) / 2f * 0.9f // Use 90% of the minimum dimension for radius
- }
-
- override fun onDraw(canvas: Canvas) {
- super.onDraw(canvas)
-
- // Draw basic diagram elements
- drawGrid(canvas)
- drawTwaLabels(canvas)
- drawNoSailZone(canvas)
-
- // Draw polar curve if data is available
- polarTable?.let {
- drawPolarCurve(canvas, it, currentTws)
- drawOptimalVmgAngles(canvas, it, currentTws) // Draw optimal VMG angles
- }
-
- // Draw current performance if data is available and not zero
- if (currentTws > 0 && currentTwa > 0 && currentBsp > 0) {
- drawCurrentPerformance(canvas, currentTwa, currentBsp)
- }
- }
-
- private fun drawGrid(canvas: Canvas) {
- // Draw TWA radial lines (0 to 360 degrees)
- for (i in 0 until 360 step twaInterval) {
- val angleRad = Math.toRadians(i.toDouble())
- val x = viewCenterX + radius * cos(angleRad).toFloat()
- val y = viewCenterY + radius * sin(angleRad).toFloat()
- canvas.drawLine(viewCenterX, viewCenterY, x, y, gridPaint)
- }
-
- // Draw speed circles
- for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
- val currentRadius = (i / maxSpeedKnots * radius).toFloat()
- canvas.drawCircle(viewCenterX, viewCenterY, currentRadius, gridPaint)
- }
- }
-
- private fun drawTwaLabels(canvas: Canvas) {
- // Draw TWA labels around the perimeter
- for (i in 0 until 360 step twaInterval) {
- val displayAngleRad = Math.toRadians(i.toDouble())
- // Position the text slightly outside the outermost circle
- val textX = viewCenterX + (radius + 40) * cos(displayAngleRad).toFloat()
- // Adjust textY to account for text height, so it's centered vertically on the arc
- val textY = viewCenterY + (radius + 40) * sin(displayAngleRad).toFloat() + (textPaint.textSize / 3)
-
- // Map canvas angle (0=right, 90=down) to polar diagram angle (0=up, 90=right)
- // Example: canvas 270 is polar 0, canvas 0 is polar 90, canvas 90 is polar 180, canvas 180 is polar 270
- val polarAngle = ( (i + 90) % 360 )
- canvas.drawText(polarAngle.toString(), textX, textY, textPaint)
- }
-
- // Draw speed labels on the horizontal axis
- for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
- if (i > 0) {
- val currentRadius = (i / maxSpeedKnots * radius).toFloat()
- // Left side
- canvas.drawText(i.toString(), viewCenterX - currentRadius - 10, viewCenterY + (textPaint.textSize / 3), textPaint)
- // Right side
- canvas.drawText(i.toString(), viewCenterX + currentRadius + 10, viewCenterY + (textPaint.textSize / 3), textPaint)
- }
- }
- }
-
- private fun drawNoSailZone(canvas: Canvas) {
- // The no-sail zone is typically symmetric around the wind direction (0 TWA, which is 'up' on our diagram)
- // In canvas coordinates, 'up' is -90 degrees or 270 degrees.
- // So the arc will be centered around 270 degrees.
- val startAngle = (270 - noSailZoneAngle).toFloat()
- val sweepAngle = (2 * noSailZoneAngle).toFloat()
-
- val oval = RectF(viewCenterX - radius, viewCenterY - radius, viewCenterX + radius, viewCenterY + radius)
- canvas.drawArc(oval, startAngle, sweepAngle, true, noSailZonePaint)
- }
-
- private fun drawPolarCurve(canvas: Canvas, polarTable: PolarTable, tws: Double) {
- val path = android.graphics.Path()
- var firstPoint = true
-
- // Generate points for 0 to 180 TWA (starboard side)
- for (twa in 0..180) {
- val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
- if (bsp > 0) {
- // Map TWA to canvas angle for the starboard side (0 TWA at 270, 90 TWA at 0, 180 TWA at 90)
- val canvasAngle = (270 + twa).toDouble() % 360
- val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
- val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
-
- if (firstPoint) {
- path.moveTo(x, y)
- firstPoint = false
- } else {
- path.lineTo(x, y)
- }
- }
- }
-
- // Generate points for 0 to -180 TWA (port side) by mirroring
- // Start from 180 back to 0 to connect the curve
- for (twa in 180 downTo 0) {
- val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
- if (bsp > 0) {
- // Map negative TWA to canvas angle for the port side (0 TWA at 270, -90 TWA at 180, -180 TWA at 90)
- val canvasAngle = (270 - twa).toDouble() // This maps TWA 0 to 270, TWA 90 to 180, TWA 180 to 90
- val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
- val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
-
- path.lineTo(x, y) // Continue drawing the path
- }
- }
- canvas.drawPath(path, polarCurvePaint)
- }
-
- private fun drawCurrentPerformance(canvas: Canvas, twa: Double, bsp: Double) {
- val canvasAngle = if (twa >= 0) {
- (270 + twa).toDouble() % 360 // Starboard side
- } else {
- (270 + twa).toDouble() // Port side (e.g., -30 TWA is 240 canvas angle)
- }
-
- val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
- val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
-
- canvas.drawCircle(x, y, 10f, currentPerformancePaint) // Draw a small circle for current performance
- }
-
- private fun drawOptimalVmgAngles(canvas: Canvas, polarTable: PolarTable, tws: Double) {
- // Find optimal upwind TWA
- val optimalUpwindTwa = polarTable.findOptimalUpwindTwa(tws)
- if (optimalUpwindTwa > 0) {
- // Draw a line indicating the optimal upwind TWA (both port and starboard)
- val upwindBsp = polarTable.interpolateBsp(tws, optimalUpwindTwa)
- val currentRadius = (upwindBsp / maxSpeedKnots * radius).toFloat() * 1.05f // Slightly longer
-
- // Starboard side
- var canvasAngle = (270 + optimalUpwindTwa).toDouble() % 360
- var x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- var y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
- canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
-
- // Port side
- canvasAngle = (270 - optimalUpwindTwa).toDouble() // Use negative TWA for port side
- x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
- canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
- }
-
- // Find optimal downwind TWA
- val optimalDownwindTwa = polarTable.findOptimalDownwindTwa(tws)
- if (optimalDownwindTwa > 0) {
- // Draw a line indicating the optimal downwind TWA (both port and starboard)
- val downwindBsp = polarTable.interpolateBsp(tws, optimalDownwindTwa)
- val currentRadius = (downwindBsp / maxSpeedKnots * radius).toFloat() * 1.05f // Slightly longer
-
- // Starboard side
- var canvasAngle = (270 + optimalDownwindTwa).toDouble() % 360
- var x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- var y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
- canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
-
- // Port side
- canvasAngle = (270 - optimalDownwindTwa).toDouble() // Use negative TWA for port side
- x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat()
- y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat()
- canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
- }
- }
-
- /**
- * Sets the polar table data for the view.
- */
- fun setPolarTable(table: PolarTable) {
- this.polarTable = table
- invalidate() // Redraw the view
- }
-
- /**
- * Sets the current true wind speed, true wind angle, and boat speed.
- */
- fun setCurrentPerformance(tws: Double, twa: Double, bsp: Double) {
- this.currentTws = tws
- this.currentTwa = twa
- this.currentBsp = bsp
- invalidate() // Redraw the view
- }
-}
diff --git a/android-app/app/src/main/res_old/drawable/ic_anchor.xml b/android-app/app/src/main/res_old/drawable/ic_anchor.xml
deleted file mode 100644
index 2389c93..0000000
--- a/android-app/app/src/main/res_old/drawable/ic_anchor.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FF000000"
- android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2S13.1,14 12,14z" />
-</vector>
diff --git a/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 52d5417..0000000
--- a/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
- <background android:drawable="@android:color/white" />
- <foreground android:drawable="@drawable/ic_anchor" />
-</adaptive-icon>
diff --git a/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 52d5417..0000000
--- a/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
- <background android:drawable="@android:color/white" />
- <foreground android:drawable="@drawable/ic_anchor" />
-</adaptive-icon>
diff --git a/android-app/app/src/main/res_old/raw/mob_alarm.mp3 b/android-app/app/src/main/res_old/raw/mob_alarm.mp3
deleted file mode 100644
index 8b13789..0000000
--- a/android-app/app/src/main/res_old/raw/mob_alarm.mp3
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/android-app/app/src/main/temp/CompassRoseView.kt b/android-app/app/src/main/temp/CompassRoseView.kt
deleted file mode 100755
index 8e755a3..0000000
--- a/android-app/app/src/main/temp/CompassRoseView.kt
+++ /dev/null
@@ -1,217 +0,0 @@
-package org.terst.nav.temp // Temporarily placing in 'temp' due to permissions, actual package should be 'org.terst.nav'
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.Rect
-import android.util.AttributeSet
-import android.view.View
-import kotlin.math.cos
-import kotlin.math.min
-import kotlin.math.sin
-
-class CompassRoseView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : View(context, attrs, defStyleAttr) {
-
- private var heading: Float = 0f // Current heading in degrees
- set(value) {
- field = value % 360 // Ensure heading is within 0-359
- invalidate()
- }
- private var cog: Float = 0f // Course Over Ground in degrees
- set(value) {
- field = value % 360
- invalidate()
- }
- private var isTrueHeading: Boolean = true // True for True heading, false for Magnetic
-
- private val rosePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.DKGRAY
- style = Paint.Style.STROKE
- strokeWidth = 2f
- }
-
- private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE
- textSize = 30f
- textAlign = Paint.Align.CENTER
- }
-
- private val cardinalTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE
- textSize = 40f
- textAlign = Paint.Align.CENTER
- isFakeBoldText = true
- }
-
- private val majorTickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE
- strokeWidth = 3f
- }
-
- private val minorTickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.GRAY
- strokeWidth = 1f
- }
-
- private val headingNeedlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.RED
- style = Paint.Style.FILL
- }
-
- private val cogArrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.BLUE
- style = Paint.Style.FILL
- strokeWidth = 5f
- }
-
- private var viewCenterX: Float = 0f
- private var viewCenterY: Float = 0f
- private var radius: Float = 0f
-
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
- super.onSizeChanged(w, h, oldw, oldh)
- viewCenterX = w / 2f
- viewCenterY = h / 2f
- radius = min(w, h) / 2f - 40f // Leave some padding
- textPaint.textSize = radius / 6f
- cardinalTextPaint.textSize = radius / 4.5f
- }
-
- override fun onDraw(canvas: Canvas) {
- super.onDraw(canvas)
-
- // Draw outer circle
- canvas.drawCircle(viewCenterX, viewCenterY, radius, rosePaint)
-
- // Draw cardinal and intercardinal points
- drawCardinalPoints(canvas)
-
- // Draw tick marks and degree labels
- drawDegreeMarks(canvas)
-
- // Draw heading needle
- drawHeadingNeedle(canvas, heading, headingNeedlePaint, radius * 0.8f)
-
- // Draw COG arrow
- drawCogArrow(canvas, cog, cogArrowPaint, radius * 0.6f)
-
- // Draw current heading text in the center
- drawHeadingText(canvas)
- }
-
- private fun drawCardinalPoints(canvas: Canvas) {
- val cardinalPoints = listOf("N", "E", "S", "W")
- val angles = listOf(0f, 90f, 180f, 270f)
- val textBound = Rect()
-
- for (i in cardinalPoints.indices) {
- val angleRad = Math.toRadians((angles[i] - 90).toDouble()).toFloat() // Adjust for canvas 0deg at 3 o'clock
- val x = viewCenterX + (radius * 0.9f) * cos(angleRad)
- val y = viewCenterY + (radius * 0.9f) * sin(angleRad)
-
- val text = cardinalPoints[i]
- cardinalTextPaint.getTextBounds(text, 0, text.length, textBound)
- val textHeight = textBound.height()
-
- canvas.drawText(text, x, y + textHeight / 2, cardinalTextPaint)
- }
- }
-
- private fun drawDegreeMarks(canvas: Canvas) {
- for (i in 0 until 360 step 5) {
- val isMajor = (i % 30 == 0) // Major ticks every 30 degrees
- val tickLength = if (isMajor) 30f else 15f
- val currentTickPaint = if (isMajor) majorTickPaint else minorTickPaint
- val startRadius = radius - tickLength
-
- val angleRad = Math.toRadians((i - 90).toDouble()).toFloat() // Adjust for canvas 0deg at 3 o'clock
-
- val startX = viewCenterX + startRadius * cos(angleRad)
- val startY = viewCenterY + startRadius * sin(angleRad)
- val endX = viewCenterX + radius * cos(angleRad)
- val endY = viewCenterY + radius * sin(angleRad)
-
- canvas.drawLine(startX, startY, endX, endY, currentTickPaint)
-
- if (isMajor && i != 0) { // Draw degree labels for major ticks (except North)
- val textRadius = radius - tickLength - textPaint.textSize / 2 - 10f
- val textX = viewCenterX + textRadius * cos(angleRad)
- val textY = viewCenterY + textRadius * sin(angleRad) + textPaint.textSize / 2
-
- canvas.drawText(i.toString(), textX, textY, textPaint)
- }
- }
- }
-
- private fun drawHeadingNeedle(canvas: Canvas, angle: Float, paint: Paint, length: Float) {
- val angleRad = Math.toRadians((angle - 90).toDouble()).toFloat() // Adjust for canvas 0deg at 3 o'clock
- val endX = viewCenterX + length * cos(angleRad)
- val endY = viewCenterY + length * sin(angleRad)
-
- // Draw a simple triangle for the needle
- val needleWidth = 20f
- val path = android.graphics.Path()
- path.moveTo(endX, endY)
- path.lineTo(viewCenterX + needleWidth * cos(angleRad - Math.toRadians(90.0).toFloat()),
- viewCenterY + needleWidth * sin(angleRad - Math.toRadians(90.0).toFloat()))
- path.lineTo(viewCenterX + needleWidth * cos(angleRad + Math.toRadians(90.0).toFloat()),
- viewCenterY + needleWidth * sin(angleRad + Math.toRadians(90.0).toFloat()))
- path.close()
- canvas.drawPath(path, paint)
- }
-
- private fun drawCogArrow(canvas: Canvas, angle: Float, paint: Paint, length: Float) {
- val angleRad = Math.toRadians((angle - 90).toDouble()).toFloat() // Adjust for canvas 0deg at 3 o'clock
- val endX = viewCenterX + length * cos(angleRad)
- val endY = viewCenterY + length * sin(angleRad)
-
- val startX = viewCenterX + (length * 0.5f) * cos(angleRad)
- val startY = viewCenterY + (length * 0.5f) * sin(angleRad)
-
- canvas.drawLine(startX, startY, endX, endY, paint)
-
- // Draw arrow head
- val arrowHeadLength = 25f
- val arrowHeadWidth = 15f
- val arrowPath = android.graphics.Path()
- arrowPath.moveTo(endX, endY)
- arrowPath.lineTo(endX - arrowHeadLength * cos(angleRad - Math.toRadians(30.0).toFloat()),
- endY - arrowHeadLength * sin(angleRad - Math.toRadians(30.0).toFloat()))
- arrowPath.moveTo(endX, endY)
- arrowPath.lineTo(endX - arrowHeadLength * cos(angleRad + Math.toRadians(30.0).toFloat()),
- endY - arrowHeadLength * sin(angleRad + Math.toRadians(30.0).toFloat()))
- canvas.drawPath(arrowPath, paint)
- }
-
- private fun drawHeadingText(canvas: Canvas) {
- val headingText = "${heading.toInt()}°" + if (isTrueHeading) "T" else "M"
- textPaint.color = Color.WHITE
- textPaint.textSize = radius / 3.5f // Larger text for main heading
- canvas.drawText(headingText, viewCenterX, viewCenterY + textPaint.textSize / 3, textPaint)
- }
-
- /**
- * Sets the current heading to display.
- * @param newHeading The new heading value in degrees (0-359).
- * @param isTrue Whether the heading is True (magnetic variation applied) or Magnetic.
- */
- fun setHeading(newHeading: Float, isTrue: Boolean) {
- this.heading = newHeading
- this.isTrueHeading = isTrue
- invalidate()
- }
-
- /**
- * Sets the Course Over Ground (COG) to display.
- * @param newCog The new COG value in degrees (0-359).
- */
- fun setCog(newCog: Float) {
- this.cog = newCog
- invalidate()
- }
-}
diff --git a/android-app/app/src/main/temp/HeadingDataProcessor.kt b/android-app/app/src/main/temp/HeadingDataProcessor.kt
deleted file mode 100755
index 7625f90..0000000
--- a/android-app/app/src/main/temp/HeadingDataProcessor.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package org.terst.nav.temp // Temporarily placing in 'temp' due to permissions
-
-import android.hardware.GeomagneticField
-import android.location.Location
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import java.util.Date
-
-/**
- * Data class representing processed heading information.
- * @param trueHeading The heading relative to true North (0-359.9 degrees).
- * @param magneticHeading The heading relative to magnetic North (0-359.9 degrees).
- * @param magneticVariation The difference between true and magnetic North at the current location (+E, -W).
- * @param cog Course Over Ground (0-359.9 degrees).
- */
-data class HeadingInfo(
- val trueHeading: Float,
- val magneticHeading: Float,
- val magneticVariation: Float,
- val cog: Float
-)
-
-/**
- * Processor for handling heading data, including magnetic variation calculations
- * using the Android GeomagneticField.
- */
-class HeadingDataProcessor {
-
- private val _headingInfoFlow = MutableStateFlow(HeadingInfo(0f, 0f, 0f, 0f))
- val headingInfoFlow: StateFlow<HeadingInfo> = _headingInfoFlow.asStateFlow()
-
- private var currentLatitude: Double = 0.0
- private var currentLongitude: Double = 0.0
- private var currentAltitude: Double = 0.0
-
- /**
- * Updates the current geographic location for magnetic variation calculations.
- */
- fun updateLocation(latitude: Double, longitude: Double, altitude: Double) {
- currentLatitude = latitude
- currentLongitude = longitude
- currentAltitude = altitude
- // Recalculate magnetic variation if location changes
- updateHeadingInfo(_headingInfoFlow.value.trueHeading, _headingInfoFlow.value.cog, true)
- }
-
- /**
- * Processes a new true heading and Course Over Ground (COG) value.
- * @param newTrueHeading The new true heading in degrees.
- * @param newCog The new COG in degrees.
- */
- fun updateTrueHeadingAndCog(newTrueHeading: Float, newCog: Float) {
- updateHeadingInfo(newTrueHeading, newCog, true)
- }
-
- /**
- * Processes a new magnetic heading and Course Over Ground (COG) value.
- * @param newMagneticHeading The new magnetic heading in degrees.
- * @param newCog The new COG in degrees.
- */
- fun updateMagneticHeadingAndCog(newMagneticHeading: Float, newCog: Float) {
- updateHeadingInfo(newMagneticHeading, newCog, false)
- }
-
- private fun updateHeadingInfo(heading: Float, cog: Float, isTrueHeadingInput: Boolean) {
- val magneticVariation = calculateMagneticVariation()
- val (finalTrueHeading, finalMagneticHeading) = if (isTrueHeadingInput) {
- Pair(heading, (heading - magneticVariation + 360) % 360)
- } else {
- Pair((heading + magneticVariation + 360) % 360, heading)
- }
-
- _headingInfoFlow.update {
- it.copy(
- trueHeading = finalTrueHeading,
- magneticHeading = finalMagneticHeading,
- magneticVariation = magneticVariation,
- cog = cog
- )
- }
- }
-
- /**
- * Calculates the magnetic variation (declination) for the current location.
- * @return Magnetic variation in degrees (+E, -W).
- */
- private fun calculateMagneticVariation(): Float {
- // GeomagneticField requires current time in milliseconds
- val currentTimeMillis = System.currentTimeMillis()
-
- // Create a dummy Location object to get altitude if only lat/lon are updated
- // GeomagneticField needs altitude, using 0 if not provided
- val geoField = GeomagneticField(
- currentLatitude.toFloat(),
- currentLongitude.toFloat(),
- currentAltitude.toFloat(), // Altitude in meters
- currentTimeMillis
- )
- return geoField.declination // Declination is the magnetic variation
- }
-
- // Helper function to normalize angles (0-359.9) - though modulo handles this for positive floats
- private fun normalizeAngle(angle: Float): Float {
- return (angle % 360 + 360) % 360
- }
-}