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