From 3f18f770e9d33c5e5d0657c6160fa8f30b21831f Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Sat, 14 Mar 2026 00:50:39 +0000 Subject: Implement barometric pressure trend monitoring and visualization --- .../src/main/kotlin/org/terst/nav/BarometerData.kt | 42 ++ .../kotlin/org/terst/nav/BarometerSensorManager.kt | 99 +++ .../kotlin/org/terst/nav/BarometerTrendView.kt | 72 +++ .../main/kotlin/org/terst/nav/LocationService.kt | 15 + .../src/main/kotlin/org/terst/nav/MainActivity.kt | 30 +- .../kotlin_old/org/terst/nav/AnchorAlarmManager.kt | 108 ++++ .../kotlin_old/org/terst/nav/AnchorWatchData.kt | 22 + .../kotlin_old/org/terst/nav/LocationService.kt | 254 ++++++++ .../main/kotlin_old/org/terst/nav/MainActivity.kt | 670 +++++++++++++++++++++ .../src/main/kotlin_old/org/terst/nav/PolarData.kt | 168 ++++++ .../kotlin_old/org/terst/nav/PolarDiagramView.kt | 270 +++++++++ .../app/src/main/res/layout/activity_main.xml | 47 +- android-app/app/src/main/res/values/colors.xml | 1 + android-app/app/src/main/res/values/dimens.xml | 0 android-app/app/src/main/res/values/strings.xml | 3 + android-app/app/src/main/res/values/themes.xml | 0 .../app/src/main/res_old/drawable/ic_anchor.xml | 9 + .../main/res_old/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + android-app/app/src/main/res_old/raw/mob_alarm.mp3 | 1 + android-app/app/src/main/temp/CompassRoseView.kt | 217 +++++++ .../app/src/main/temp/HeadingDataProcessor.kt | 108 ++++ android-app/app/src/test/kotlin/org/test.txt | 0 23 files changed, 2142 insertions(+), 4 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/BarometerData.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/BarometerSensorManager.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/BarometerTrendView.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt create mode 100644 android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt mode change 100644 => 100755 android-app/app/src/main/res/values/colors.xml mode change 100644 => 100755 android-app/app/src/main/res/values/dimens.xml mode change 100644 => 100755 android-app/app/src/main/res/values/strings.xml mode change 100644 => 100755 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/app/src/main/res_old/drawable/ic_anchor.xml create mode 100644 android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android-app/app/src/main/res_old/raw/mob_alarm.mp3 create mode 100755 android-app/app/src/main/temp/CompassRoseView.kt create mode 100755 android-app/app/src/main/temp/HeadingDataProcessor.kt create mode 100755 android-app/app/src/test/kotlin/org/test.txt (limited to 'android-app/app/src') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/BarometerData.kt b/android-app/app/src/main/kotlin/org/terst/nav/BarometerData.kt new file mode 100644 index 0000000..5a8ccce --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/BarometerData.kt @@ -0,0 +1,42 @@ +package org.terst.nav + +import java.util.Locale + +data class BarometerReading( + val pressureHpa: Float, + val timestamp: Long = System.currentTimeMillis() +) + +enum class PressureTrend { + RISING_FAST, + RISING, + STEADY, + FALLING, + FALLING_FAST; + + override fun toString(): String { + return when (this) { + RISING_FAST -> "Rising Fast" + RISING -> "Rising" + STEADY -> "Steady" + FALLING -> "Falling" + FALLING_FAST -> "Falling Fast" + } + } +} + +data class BarometerStatus( + val currentPressureHpa: Float = 1013.25f, + val trend: PressureTrend = PressureTrend.STEADY, + val pressureChange3h: Float = 0f, + val history: List = emptyList() +) { + fun formatPressure(): String { + return String.format(Locale.getDefault(), "%.1f hPa", currentPressureHpa) + } + + fun formatTrend(): String { + val sign = if (pressureChange3h >= 0) "+" else "" + return String.format(Locale.getDefault(), "%s (%s%.1f hPa/3h)", trend.toString(), sign, pressureChange3h) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/BarometerSensorManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/BarometerSensorManager.kt new file mode 100644 index 0000000..cdd7f76 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/BarometerSensorManager.kt @@ -0,0 +1,99 @@ +package org.terst.nav + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.TimeUnit +import android.util.Log + +class BarometerSensorManager(context: Context) : SensorEventListener { + + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val pressureSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE) + + private val _barometerStatus = MutableStateFlow(BarometerStatus()) + val barometerStatus: StateFlow = _barometerStatus.asStateFlow() + + private val historyMaxDurationMs = TimeUnit.HOURS.toMillis(24) // Keep 24h history + private val historySampleIntervalMs = TimeUnit.MINUTES.toMillis(15) // Sample every 15 min for history + private var lastHistorySampleTime = 0L + + fun start() { + if (pressureSensor != null) { + sensorManager.registerListener(this, pressureSensor, SensorManager.SENSOR_DELAY_NORMAL) + Log.d("BarometerManager", "Pressure sensor registered") + } else { + Log.w("BarometerManager", "No pressure sensor found on this device") + } + } + + fun stop() { + sensorManager.unregisterListener(this) + Log.d("BarometerManager", "Pressure sensor unregistered") + } + + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type == Sensor.TYPE_PRESSURE) { + val pressure = event.values[0] + updateCurrentPressure(pressure) + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + // Not used + } + + private fun updateCurrentPressure(pressure: Float) { + val now = System.currentTimeMillis() + + _barometerStatus.update { currentStatus -> + val isFirstSample = currentStatus.history.isEmpty() + val newHistory = if (isFirstSample || now - lastHistorySampleTime >= historySampleIntervalMs) { + lastHistorySampleTime = now + val updatedHistory = currentStatus.history + BarometerReading(pressure, now) + // Trim history to 24h + updatedHistory.filter { now - it.timestamp <= historyMaxDurationMs } + } else { + currentStatus.history + } + + val change3h = calculatePressureChange(newHistory, now, TimeUnit.HOURS.toMillis(3)) + val trend = determineTrend(change3h) + + currentStatus.copy( + currentPressureHpa = pressure, + trend = trend, + pressureChange3h = change3h, + history = newHistory + ) + } + } + + private fun calculatePressureChange(history: List, now: Long, durationMs: Long): Float { + if (history.isEmpty()) return 0f + + val targetTime = now - durationMs + val oldReading = history.find { it.timestamp >= targetTime } ?: history.first() + val currentReading = history.last() + + // If we don't have enough history, we might not be able to calculate a meaningful 3h change + // but we'll return the difference between the oldest available and current. + return currentReading.pressureHpa - oldReading.pressureHpa + } + + private fun determineTrend(change3h: Float): PressureTrend { + return when { + change3h >= 2.0f -> PressureTrend.RISING_FAST + change3h >= 0.5f -> PressureTrend.RISING + change3h <= -2.0f -> PressureTrend.FALLING_FAST + change3h <= -0.5f -> PressureTrend.FALLING + else -> PressureTrend.STEADY + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/BarometerTrendView.kt b/android-app/app/src/main/kotlin/org/terst/nav/BarometerTrendView.kt new file mode 100644 index 0000000..944d198 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/BarometerTrendView.kt @@ -0,0 +1,72 @@ +package org.terst.nav + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat + +class BarometerTrendView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private var history: List = emptyList() + + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.instrument_text_normal) + strokeWidth = 4f + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.instrument_text_secondary) + strokeWidth = 1f + style = Paint.Style.STROKE + } + + fun setHistory(newHistory: List) { + history = newHistory + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (history.size < 2) return + + val padding = 20f + val w = width.toFloat() - 2 * padding + val h = height.toFloat() - 2 * padding + + val minP = history.minOf { it.pressureHpa } + val maxP = history.maxOf { it.pressureHpa } + val rangeP = (maxP - minP).coerceAtLeast(1.0f) // Show at least 1 hPa range + + val minT = history.first().timestamp + val maxT = history.last().timestamp + val rangeT = (maxT - minT).coerceAtLeast(1L) + + // Draw simple grid + canvas.drawLine(padding, padding, padding, h + padding, gridPaint) + canvas.drawLine(padding, h + padding, w + padding, h + padding, gridPaint) + + val path = Path() + history.forEachIndexed { index, reading -> + val x = padding + (reading.timestamp - minT).toFloat() / rangeT * w + val y = padding + h - (reading.pressureHpa - minP) / rangeP * h + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + canvas.drawPath(path, linePaint) + } +} 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 22290a5..24eb498 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 @@ -47,6 +47,7 @@ class LocationService : Service() { private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var locationCallback: LocationCallback private lateinit var anchorAlarmManager: AnchorAlarmManager + private lateinit var barometerSensorManager: BarometerSensorManager private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val NOTIFICATION_CHANNEL_ID = "location_service_channel" @@ -59,8 +60,16 @@ class LocationService : Service() { Log.d("LocationService", "Service created") fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context + barometerSensorManager = BarometerSensorManager(this) createNotificationChannel() + // Observe barometer status and update our public state + serviceScope.launch { + barometerSensorManager.barometerStatus.collect { status -> + _barometerStatus.value = status + } + } + // Mock tidal current data generator serviceScope.launch { while (true) { @@ -121,10 +130,12 @@ class LocationService : Service() { Log.d("LocationService", "Starting foreground service") startForeground(NOTIFICATION_ID, createNotification()) startLocationUpdatesInternal() + barometerSensorManager.start() } ACTION_STOP_FOREGROUND_SERVICE -> { Log.d("LocationService", "Stopping foreground service") stopLocationUpdatesInternal() + barometerSensorManager.stop() stopSelf() } ACTION_START_ANCHOR_WATCH -> { @@ -158,6 +169,7 @@ class LocationService : Service() { Log.d("LocationService", "Service destroyed") stopLocationUpdatesInternal() anchorAlarmManager.stopAlarm() + barometerSensorManager.stop() _anchorWatchState.value = AnchorWatchState(isActive = false) isAlarmTriggered = false // Reset alarm trigger state serviceScope.cancel() // Cancel the coroutine scope @@ -284,9 +296,12 @@ class LocationService : Service() { get() = _anchorWatchState val tidalCurrentState: StateFlow get() = _tidalCurrentState + val barometerStatus: StateFlow + get() = _barometerStatus private val _locationFlow = MutableSharedFlow(replay = 1) private val _anchorWatchState = MutableStateFlow(AnchorWatchState()) private val _tidalCurrentState = MutableStateFlow(TidalCurrentState()) + private val _barometerStatus = MutableStateFlow(BarometerStatus()) } } 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 b638136..e208892 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 @@ -104,6 +104,9 @@ class MainActivity : AppCompatActivity() { 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 // Anchor Watch UI elements @@ -134,6 +137,7 @@ class MainActivity : AppCompatActivity() { startLocationService() observeLocationUpdates() // Start observing location updates observeAnchorWatchState() // Start observing anchor watch state + observeBarometerStatus() // Start observing barometer status } else { // Permissions denied, handle the case (e.g., show a message to the user) Toast.makeText(this, "Location permissions denied", Toast.LENGTH_LONG).show() @@ -167,6 +171,7 @@ class MainActivity : AppCompatActivity() { startLocationService() observeLocationUpdates() // Start observing location updates observeAnchorWatchState() // Start observing anchor watch state + observeBarometerStatus() // Start observing barometer status } mapView = findViewById(R.id.mapView) @@ -201,6 +206,9 @@ class MainActivity : AppCompatActivity() { 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) @@ -226,7 +234,8 @@ class MainActivity : AppCompatActivity() { 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)) + polarPct = "%.0f%%".format(Locale.getDefault(), mockPolarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, simulatedBsp)), + baro = getString(R.string.placeholder_baro_value) ) polarDiagramView.setCurrentPerformance(simulatedTws, simulatedTwa, simulatedBsp) @@ -259,7 +268,8 @@ class MainActivity : AppCompatActivity() { 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) + polarPct = getString(R.string.placeholder_polar_value), + baro = getString(R.string.placeholder_baro_value) ) fabToggleInstruments.setOnClickListener { @@ -535,6 +545,18 @@ class MainActivity : AppCompatActivity() { } } + 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) + } + } + } + } + private fun observeTidalCurrentState() { lifecycleScope.launch { LocationService.tidalCurrentState.collect { state -> @@ -708,7 +730,8 @@ class MainActivity : AppCompatActivity() { sog: String, vmg: String, depth: String, - polarPct: String + polarPct: String, + baro: String ) { valueAws.text = aws valueTws.text = tws @@ -719,6 +742,7 @@ class MainActivity : AppCompatActivity() { valueVmg.text = vmg valueDepth.text = depth valuePolarPct.text = polarPct + valueBaro.text = baro } override fun onStart() { 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 new file mode 100644 index 0000000..d4423db --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..03e6a2f --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..4b59139 --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt @@ -0,0 +1,254 @@ +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 + get() = _locationFlow + val anchorWatchState: StateFlow + get() = _anchorWatchState + + private val _locationFlow = MutableSharedFlow(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 new file mode 100644 index 0000000..a32fb18 --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt @@ -0,0 +1,670 @@ +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(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())) + + anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID) + anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) + + 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())) + anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) + 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() + 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 new file mode 100644 index 0000000..88a8d0d --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt @@ -0,0 +1,168 @@ +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) { + 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) { + 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 new file mode 100644 index 0000000..4a678cc --- /dev/null +++ b/android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt @@ -0,0 +1,270 @@ +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/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index cfeea6c..8c734d5 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -227,6 +227,51 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" /> + + + + + + + + + + diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml old mode 100644 new mode 100755 index 7ccb28f..32f5036 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -9,6 +9,7 @@ #FFFFFFFF + #B3FFFFFF #FFFF0000 #FFFFFF00 #E61E1E1E diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml old mode 100644 new mode 100755 diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml old mode 100644 new mode 100755 index b6d3601..cec4850 --- a/android-app/app/src/main/res/values/strings.xml +++ b/android-app/app/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ VMG DEPTH POLAR % + BAROMETER + TREND --.- @@ -25,6 +27,7 @@ --.- --.- --- + ----.- Activate Man Overboard (MOB) alarm Toggle Anchor Watch Configuration diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml old mode 100644 new mode 100755 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 new file mode 100644 index 0000000..2389c93 --- /dev/null +++ b/android-app/app/src/main/res_old/drawable/ic_anchor.xml @@ -0,0 +1,9 @@ + + + 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 new file mode 100644 index 0000000..52d5417 --- /dev/null +++ b/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 0000000..52d5417 --- /dev/null +++ b/android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android-app/app/src/main/res_old/raw/mob_alarm.mp3 @@ -0,0 +1 @@ + diff --git a/android-app/app/src/main/temp/CompassRoseView.kt b/android-app/app/src/main/temp/CompassRoseView.kt new file mode 100755 index 0000000..8e755a3 --- /dev/null +++ b/android-app/app/src/main/temp/CompassRoseView.kt @@ -0,0 +1,217 @@ +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 new file mode 100755 index 0000000..7625f90 --- /dev/null +++ b/android-app/app/src/main/temp/HeadingDataProcessor.kt @@ -0,0 +1,108 @@ +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 = _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 + } +} diff --git a/android-app/app/src/test/kotlin/org/test.txt b/android-app/app/src/test/kotlin/org/test.txt new file mode 100755 index 0000000..e69de29 -- cgit v1.2.3