summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/BarometerData.kt42
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/BarometerSensorManager.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/BarometerTrendView.kt72
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt30
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/AnchorAlarmManager.kt108
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/AnchorWatchData.kt22
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/LocationService.kt254
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/MainActivity.kt670
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/PolarData.kt168
-rw-r--r--android-app/app/src/main/kotlin_old/org/terst/nav/PolarDiagramView.kt270
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml47
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/colors.xml1
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/dimens.xml0
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/strings.xml3
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/themes.xml0
-rw-r--r--android-app/app/src/main/res_old/drawable/ic_anchor.xml9
-rw-r--r--android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--android-app/app/src/main/res_old/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--android-app/app/src/main/res_old/raw/mob_alarm.mp31
-rwxr-xr-xandroid-app/app/src/main/temp/CompassRoseView.kt217
-rwxr-xr-xandroid-app/app/src/main/temp/HeadingDataProcessor.kt108
22 files changed, 2142 insertions, 4 deletions
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<BarometerReading> = 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> = _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<BarometerReading>, 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<BarometerReading> = 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<BarometerReading>) {
+ 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<TidalCurrentState>
get() = _tidalCurrentState
+ val barometerStatus: StateFlow<BarometerStatus>
+ get() = _barometerStatus
private val _locationFlow = MutableSharedFlow<GpsData>(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<MapView>(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<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
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<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
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<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
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" />
+ <!-- Barometer Instrument -->
+ <TextView
+ android:id="@+id/label_barometer"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_barometer"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/value_vmg"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_baro"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="1013.2"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/label_barometer"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_baro_unit"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="hPa"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/value_baro"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- Barometer Trend -->
+ <TextView
+ android:id="@+id/label_trend"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_trend"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/value_depth"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <org.terst.nav.BarometerTrendView
+ android:id="@+id/barometer_trend_view"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/label_trend"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="@+id/label_baro_unit" />
+
<!-- Polar Diagram View -->
<org.terst.nav.PolarDiagramView
android:id="@+id/polar_diagram_view"
@@ -236,7 +281,7 @@
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/label_vmg"
+ app:layout_constraintTop_toBottomOf="@+id/label_baro_unit"
app:layout_constraintBottom_toBottomOf="parent"
/>
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index 7ccb28f..32f5036 100644..100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -9,6 +9,7 @@
<!-- Colors for instrument display -->
<color name="instrument_text_normal">#FFFFFFFF</color> <!-- White for normal text on dark background -->
+ <color name="instrument_text_secondary">#B3FFFFFF</color> <!-- 70% white -->
<color name="instrument_text_alarm">#FFFF0000</color> <!-- Red for alarm -->
<color name="instrument_text_stale">#FFFFFF00</color> <!-- Yellow for stale data -->
<color name="instrument_background">#E61E1E1E</color> <!-- Slightly transparent dark grey -->
diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml
index 1b65ea9..1b65ea9 100644..100755
--- a/android-app/app/src/main/res/values/dimens.xml
+++ b/android-app/app/src/main/res/values/dimens.xml
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
index b6d3601..cec4850 100644..100755
--- a/android-app/app/src/main/res/values/strings.xml
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -14,6 +14,8 @@
<string name="instrument_label_vmg">VMG</string>
<string name="instrument_label_depth">DEPTH</string>
<string name="instrument_label_polar_pct">POLAR %</string>
+ <string name="instrument_label_barometer">BAROMETER</string>
+ <string name="instrument_label_trend">TREND</string>
<!-- Placeholder values for initial display -->
<string name="placeholder_aws_value">--.-</string>
@@ -25,6 +27,7 @@
<string name="placeholder_vmg_value">--.-</string>
<string name="placeholder_depth_value">--.-</string>
<string name="placeholder_polar_value">---</string>
+ <string name="placeholder_baro_value">----.-</string>
<string name="fab_mob_content_description">Activate Man Overboard (MOB) alarm</string>
<string name="fab_anchor_content_description">Toggle Anchor Watch Configuration</string>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
index 612bba1..612bba1 100644..100755
--- a/android-app/app/src/main/res/values/themes.xml
+++ b/android-app/app/src/main/res/values/themes.xml
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 @@
+<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
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 @@
+<?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
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 @@
+<?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
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<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
+ }
+}