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/AndroidManifest.xml19
-rw-r--r--android-app/app/src/main/cpp/CMakeLists.txt59
-rw-r--r--android-app/app/src/main/cpp/native-lib.cpp14
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt108
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt22
-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.kt410
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt817
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt168
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt270
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/TidalCurrentData.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt87
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt37
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt255
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt125
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt8
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt8
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt169
-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/drawable/ic_anchor.xml9
-rw-r--r--android-app/app/src/main/res/drawable/ic_tidal_arrow.xml9
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml546
-rw-r--r--android-app/app/src/main/res/layout/activity_weather.xml20
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml66
-rw-r--r--android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--android-app/app/src/main/res/raw/mob_alarm.mp31
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/colors.xml29
-rwxr-xr-xandroid-app/app/src/main/res/values/dimens.xml9
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/strings.xml50
-rwxr-xr-x[-rw-r--r--]android-app/app/src/main/res/values/themes.xml57
-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
52 files changed, 5504 insertions, 30 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 86b9c75..0b9fc05 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -1,27 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
- <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"
- android:icon="@android:drawable/ic_dialog_map"
+ android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
- android:roundIcon="@android:drawable/ic_dialog_map"
+ android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
- android:theme="@style/Theme.NavApp">
-
+ android:theme="@style/Theme.Nav">
+ <service
+ android:name=".LocationService"
+ android:foregroundServiceType="location" />
<activity
- android:name=".ui.MainActivity"
+ android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
-
</application>
-
</manifest>
diff --git a/android-app/app/src/main/cpp/CMakeLists.txt b/android-app/app/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000..9147ce6
--- /dev/null
+++ b/android-app/app/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,59 @@
+# Sets the minimum version of CMake required to build your native library.
+# This ensures that a certain set of CMake features is available to
+# your build.
+
+cmake_minimum_required(VERSION 3.4.1)
+
+# Declares and names the project.
+project("wind_visualization_native")
+
+# Creates and names a library, sets it as either STATIC or SHARED, and
+# specifies the source files.
+# The wind_visualization_native library will be built as a shared library
+# and will include the C++ source file `native-lib.cpp`.
+add_library( # Sets the name of the library.
+ wind_visualization_native
+
+ # Sets the library as a shared library.
+ SHARED
+
+ # Provides a relative path to your source file(s).
+ native-lib.cpp ) # Corrected path
+
+# Searches for a prebuilt static library called 'maplibre' which contains the
+# MapLibre GL Native custom layer host implementation. This library is usually
+# provided by the MapLibre Android SDK.
+find_library( # Sets the name of the path variable.
+ maplibre-gl-lib
+
+ # Specifies the name of the NDK library that
+ # CMake should locate.
+ maplibre ) # Searching for libmaplibre.so
+
+# Searches for the Android log library.
+find_library( # Sets the name of the path variable.
+ log-lib
+
+ # Specifies the name of the NDK library that
+ # CMake should locate.
+ log )
+
+# Specifies paths to the header files of a library.
+# For example, the MapLibre GL Native SDK headers might be in a
+# directory like 'src/main/cpp/maplibre-gl-native-headers'.
+# You would add that path here.
+# For now, let's just assume we will need JNI headers.
+target_include_directories(wind_visualization_native PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ # Add JNI specific include directories if needed, typically handled by Android Gradle Plugin
+ )
+
+# Specifies libraries that CMake should link to your target library.
+# This includes the MapLibre GL Native library and the logging library.
+target_link_libraries( # Specifies the target library.
+ wind_visualization_native
+
+ # Links the target library to the log library
+ # included in the NDK.
+ ${maplibre-gl-lib}
+ ${log-lib} )
diff --git a/android-app/app/src/main/cpp/native-lib.cpp b/android-app/app/src/main/cpp/native-lib.cpp
new file mode 100644
index 0000000..f606a41
--- /dev/null
+++ b/android-app/app/src/main/cpp/native-lib.cpp
@@ -0,0 +1,14 @@
+#include <jni.h>
+#include <string>
+#include <android/log.h>
+
+#define TAG "WindVisualizationNative"
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_org_terst_nav_MainActivity_stringFromJNI(
+ JNIEnv* env,
+ jobject /* this */) {
+ std::string hello = "Hello from C++";
+ __android_log_print(ANDROID_LOG_INFO, TAG, "stringFromJNI called!");
+ return env->NewStringUTF(hello.c_str());
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt
index 17a636f..b29aefa 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.ui
+package org.terst.nav.ui
import android.Manifest
import android.content.pm.PackageManager
@@ -8,16 +8,16 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
-import com.example.androidapp.R
-import com.example.androidapp.databinding.ActivityMainBinding
-import com.example.androidapp.ui.forecast.ForecastFragment
-import com.example.androidapp.ui.map.MapFragment
+import org.terst.nav.R
+import org.terst.nav.databinding.ActivityWeatherBinding
+import org.terst.nav.ui.forecast.ForecastFragment
+import org.terst.nav.ui.map.MapFragment
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
class MainActivity : AppCompatActivity() {
- private lateinit var binding: ActivityMainBinding
+ private lateinit var binding: ActivityWeatherBinding
private val viewModel: MainViewModel by viewModels()
// Default position (San Francisco Bay) used when location is unavailable
@@ -52,7 +52,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ActivityMainBinding.inflate(layoutInflater)
+ binding = ActivityWeatherBinding.inflate(layoutInflater)
setContentView(binding.root)
setupBottomNav()
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt
new file mode 100644
index 0000000..d4423db
--- /dev/null
+++ b/android-app/app/src/main/kotlin/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/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
new file mode 100644
index 0000000..03e6a2f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/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/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
new file mode 100644
index 0000000..51915bd
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -0,0 +1,410 @@
+package org.terst.nav
+
+import android.util.Log
+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.channels.BufferOverflow
+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 org.terst.nav.nmea.NmeaParser
+import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
+import org.terst.nav.gps.GpsPosition
+import kotlinx.coroutines.flow.collectLatest
+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 lateinit var barometerSensorManager: BarometerSensorManager
+ private lateinit var nmeaParser: NmeaParser
+ private lateinit var nmeaStreamManager: NmeaStreamManager
+ 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
+ barometerSensorManager = BarometerSensorManager(this)
+ nmeaParser = NmeaParser()
+ nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
+ createNotificationChannel()
+
+ // Observe barometer status and update our public state
+ serviceScope.launch {
+ barometerSensorManager.barometerStatus.collect { status ->
+ _barometerStatus.value = status
+ }
+ }
+
+ // Collect NMEA GPS positions
+ serviceScope.launch {
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
+ _nmeaGpsPositionFlow.emit(gpsPosition)
+ // TODO: Implement sensor fusion logic here to decide whether to use
+ // this NMEA GPS position or the Android system's FusedLocationProviderClient position.
+ }
+ }
+
+ // Collect NMEA Wind Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ _nmeaWindDataFlow.emit(windData)
+ }
+ }
+
+ // Collect NMEA Depth Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaDepthData.collectLatest { depthData ->
+ _nmeaDepthDataFlow.emit(depthData)
+ }
+ }
+
+ // Collect NMEA Heading Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaHeadingData.collectLatest { headingData ->
+ _nmeaHeadingDataFlow.emit(headingData)
+ }
+ }
+
+ // Mock tidal current data generator
+ serviceScope.launch {
+ while (true) {
+ val currents = generateMockCurrents()
+ _tidalCurrentState.update { it.copy(currents = currents) }
+ kotlinx.coroutines.delay(60000) // Update every minute (or as needed)
+ }
+ }
+
+ 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 (Android system GPS)
+ }
+
+
+
+ // Check for anchor drag if anchor watch is active
+ _anchorWatchState.update { currentState ->
+ if (currentState.isActive && currentState.anchorLocation != null) {
+ val isDragging = currentState.isDragging(location)
+ if (isDragging) {
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (!isAlarmTriggered) {
+ anchorAlarmManager.startAlarm()
+ isAlarmTriggered = true
+ }
+ } else {
+ Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ } else {
+ // If anchor watch is not active, ensure alarm is stopped
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ currentState
+ }
+ }
+ }
+ }
+ }
+
+ 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())
+ serviceScope.launch {
+ _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL
+ startLocationUpdatesInternal(PowerMode.FULL)
+ }
+ barometerSensorManager.start()
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
+ ACTION_STOP_FOREGROUND_SERVICE -> {
+ Log.d("LocationService", "Stopping foreground service")
+ stopLocationUpdatesInternal()
+ barometerSensorManager.stop()
+ nmeaStreamManager.stop()
+ 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)
+ setPowerMode(PowerMode.ANCHOR_WATCH)
+ }
+ }
+ ACTION_STOP_ANCHOR_WATCH -> {
+ Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
+ stopAnchorWatch()
+ setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch
+ }
+ 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)
+ }
+ ACTION_TOGGLE_TIDAL_VISIBILITY -> {
+ val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
+ _tidalCurrentState.update { it.copy(isVisible = isVisible) }
+ }
+ }
+ 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()
+ barometerSensorManager.stop()
+ nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
+ _anchorWatchState.value = AnchorWatchState(isActive = false)
+ isAlarmTriggered = false // Reset alarm trigger state
+ serviceScope.cancel() // Cancel the coroutine scope
+ }
+
+
+ @SuppressLint("MissingPermission")
+ private fun startLocationUpdatesInternal(powerMode: PowerMode) {
+ Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms")
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval
+ .build()
+ fusedLocationClient.requestLocationUpdates(
+ locationRequest,
+ locationCallback,
+ Looper.getMainLooper()
+ )
+ }
+
+ private fun stopLocationUpdatesInternal() {
+ Log.d("LocationService", "Removing location updates")
+ fusedLocationClient.removeLocationUpdates(locationCallback)
+ }
+
+ fun setPowerMode(powerMode: PowerMode) {
+ serviceScope.launch {
+ if (_currentPowerMode.value != powerMode) {
+ // Emit the new power mode first
+ _currentPowerMode.emit(powerMode)
+ Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.")
+ // Stop current updates if running
+ stopLocationUpdatesInternal()
+ // Start new updates with the new power mode's interval
+ startLocationUpdatesInternal(powerMode)
+ } else {
+ Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.")
+ }
+ }
+ }
+
+ 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.")
+ }
+
+ private fun generateMockCurrents(): List<TidalCurrent> {
+ // Generate a grid of currents around common coordinates
+ val centerLat = 41.5
+ val centerLon = -71.3
+ val currents = mutableListOf<TidalCurrent>()
+ val currentTime = System.currentTimeMillis()
+
+ for (i in -5..5) {
+ for (j in -5..5) {
+ val lat = centerLat + i * 0.05
+ val lon = centerLon + j * 0.05
+ // Mock speed and direction based on position and time
+ val speed = 0.5 + Math.random() * 2.0 // 0.5 to 2.5 knots
+ val dir = (Math.sin(currentTime / 3600000.0 + lat + lon) * 180.0 + 180.0) % 360.0
+ currents.add(TidalCurrent(lat, lon, speed, dir, currentTime))
+ }
+ }
+ return currents
+ }
+
+ companion object {
+ const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
+ const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
+ 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 ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
+ const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
+ const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
+
+ // NMEA Gateway configuration (example values - these should ideally be configurable by the user)
+ private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address
+ private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port
+
+ // Publicly accessible flows
+ val locationFlow: SharedFlow<GpsData>
+ get() = _locationFlow
+ val anchorWatchState: StateFlow<AnchorWatchState>
+ get() = _anchorWatchState
+ val tidalCurrentState: StateFlow<TidalCurrentState>
+ get() = _tidalCurrentState
+ val barometerStatus: StateFlow<BarometerStatus>
+ get() = _barometerStatus
+
+ // NMEA Data Flows
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition>
+ get() = _nmeaGpsPositionFlow
+ val nmeaWindDataFlow: SharedFlow<WindData>
+ get() = _nmeaWindDataFlow
+ val nmeaDepthDataFlow: SharedFlow<DepthData>
+ get() = _nmeaDepthDataFlow
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData>
+ get() = _nmeaHeadingDataFlow
+
+ private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
+ private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
+ private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ private val _barometerStatus = MutableStateFlow(BarometerStatus())
+
+ // Private NMEA Data Flows
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
+ val currentPowerMode: StateFlow<PowerMode>
+ get() = _currentPowerMode
+ }
+}
+
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
new file mode 100644
index 0000000..a6c063b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -0,0 +1,817 @@
+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.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import org.terst.nav.ui.voicelog.VoiceLogFragment
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.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.expressions.Expression
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.RasterLayer
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.android.style.sources.RasterSource
+import org.maplibre.android.style.sources.TileSet
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.Point
+import org.maplibre.geojson.Polygon
+import org.maplibre.geojson.LineString
+import 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
+ private lateinit var fabTidal: FloatingActionButton
+
+ // MapLibreMap instance
+ private var maplibreMap: MapLibreMap? = null
+
+ // MapLibre Layers and Sources for Anchor Watch
+ private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
+ private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
+ private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
+ private val ANCHOR_CIRCLE_LAYER_ID = "anchor-circle-layer"
+ private val ANCHOR_ICON_ID = "anchor-icon"
+
+ private var anchorPointSource: GeoJsonSource? = null
+ private var anchorCircleSource: GeoJsonSource? = null
+
+ // MapLibre Layers and Sources for Tidal Current
+ private val TIDAL_CURRENT_SOURCE_ID = "tidal-current-source"
+ private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
+ private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
+
+ private var tidalCurrentSource: GeoJsonSource? = null
+
+ // MOB UI elements
+ private lateinit var mobNavigationContainer: ConstraintLayout
+ private lateinit var mobValueDistance: TextView
+ private lateinit var mobValueElapsedTime: TextView
+ private lateinit var mobRecoveredButton: Button
+
+ // MOB State
+ private var mobActivated: Boolean = false
+ private var activeMobWaypoint: MobWaypoint? = null
+
+ // Media player for MOB alarm
+ private var mobMediaPlayer: MediaPlayer? = null
+
+ // Instrument TextViews
+ private lateinit var valueAws: TextView
+ private lateinit var valueTws: TextView
+ private lateinit var valueHdg: TextView
+ private lateinit var valueCog: TextView
+ private lateinit var valueBsp: TextView
+ private lateinit var valueSog: TextView
+ private lateinit var valueVmg: TextView
+ private lateinit var valueDepth: TextView
+ private lateinit var valuePolarPct: TextView
+ private lateinit var valueBaro: TextView
+ private lateinit var labelTrend: TextView
+ private lateinit var barometerTrendView: BarometerTrendView
+ private lateinit var polarDiagramView: PolarDiagramView // Reference to the custom view
+
+ // Voice Log UI elements
+ private lateinit var fabVoiceLog: FloatingActionButton
+ private lateinit var voiceLogContainer: FrameLayout
+
+ // Anchor Watch UI elements
+ private lateinit var fabAnchor: FloatingActionButton
+ private lateinit var anchorConfigContainer: ConstraintLayout
+ private lateinit var anchorStatusText: TextView
+ private lateinit var anchorRadiusText: TextView
+ private lateinit var buttonDecreaseRadius: Button
+ private lateinit var buttonIncreaseRadius: Button
+ private lateinit var buttonSetAnchor: Button
+ private lateinit var buttonStopAnchor: Button
+
+ private var currentWatchCircleRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
+
+ // Register the permissions callback, which handles the user's response to the
+ // system permissions dialog.
+ private val requestPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
+ val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
+ val backgroundLocationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions[Manifest.permission.ACCESS_BACKGROUND_LOCATION] == true
+ } else true // Not needed below Android 10
+
+ if (fineLocationGranted && coarseLocationGranted && backgroundLocationGranted) {
+ // Permissions granted, start location service and observe updates
+ Toast.makeText(this, "Location permissions granted", Toast.LENGTH_SHORT).show()
+ startLocationService()
+ observeLocationUpdates() // Start observing location updates
+ observeAnchorWatchState() // Start observing anchor watch state
+ observeBarometerStatus() // Start observing barometer status
+ } 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
+ observeBarometerStatus() // Start observing barometer status
+ }
+
+ mapView = findViewById<MapView>(R.id.mapView)
+ mapView?.onCreate(savedInstanceState)
+ mapView?.getMapAsync { maplibreMap ->
+ this.maplibreMap = maplibreMap // Assign to class member
+ val style = Style.Builder()
+ .fromUri("https://tiles.openfreemap.org/styles/liberty")
+ .withSource(RasterSource("openseamap-source",
+ TileSet("2.2.0", "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png").also {
+ it.setMaxZoom(18f)
+ }, 256))
+ .withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
+ maplibreMap.setStyle(style) { style ->
+ setupAnchorMapLayers(style)
+ setupTidalCurrentMapLayers(style)
+ observeTidalCurrentState() // Start observing tidal current state
+ }
+ }
+
+ instrumentDisplayContainer = findViewById(R.id.instrument_display_container)
+ fabToggleInstruments = findViewById(R.id.fab_toggle_instruments)
+ fabMob = findViewById(R.id.fab_mob)
+ fabTidal = findViewById(R.id.fab_tidal)
+
+ // Initialize MOB UI elements
+ mobNavigationContainer = findViewById(R.id.mob_navigation_container)
+ mobValueDistance = findViewById(R.id.mob_value_distance)
+ mobValueElapsedTime = findViewById(R.id.mob_value_elapsed_time)
+ mobRecoveredButton = findViewById(R.id.mob_recovered_button)
+
+ // Initialize instrument TextViews
+ valueAws = findViewById(R.id.value_aws)
+ valueTws = findViewById(R.id.value_tws)
+ valueHdg = findViewById(R.id.value_hdg)
+ valueCog = findViewById(R.id.value_cog)
+ valueBsp = findViewById(R.id.value_bsp)
+ valueSog = findViewById(R.id.value_sog)
+ valueVmg = findViewById(R.id.value_vmg)
+ valueDepth = findViewById(R.id.value_depth)
+ valuePolarPct = findViewById(R.id.value_polar_pct)
+ valueBaro = findViewById(R.id.value_baro)
+ labelTrend = findViewById(R.id.label_trend)
+ barometerTrendView = findViewById(R.id.barometer_trend_view)
+
+ // Initialize PolarDiagramView
+ polarDiagramView = findViewById(R.id.polar_diagram_view)
+
+ // Set up mock polar data
+ val mockPolarTable = createMockPolarTable()
+ polarDiagramView.setPolarTable(mockPolarTable)
+
+ // Simulate real-time updates for the polar diagram
+ lifecycleScope.launch {
+ var simulatedTws = 8.0
+ var simulatedTwa = 40.0
+ var simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
+
+ while (true) {
+ // Update instrument display with current simulated values
+ updateInstrumentDisplay(
+ aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1), // AWS usually higher than TWS
+ tws = "%.1f".format(Locale.getDefault(), simulatedTws),
+ hdg = "---", // No mock for HDG
+ cog = "---", // No mock for COG
+ bsp = "%.1f".format(Locale.getDefault(), simulatedBsp),
+ sog = "%.1f".format(Locale.getDefault(), simulatedBsp * 0.95), // SOG usually slightly less than BSP
+ vmg = "%.1f".format(Locale.getDefault(), mockPolarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, simulatedBsp) ?: 0.0),
+ depth = getString(R.string.placeholder_depth_value),
+ polarPct = "%.0f%%".format(Locale.getDefault(), mockPolarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, simulatedBsp)),
+ baro = getString(R.string.placeholder_baro_value)
+ )
+ polarDiagramView.setCurrentPerformance(simulatedTws, simulatedTwa, simulatedBsp)
+
+ // Slowly change TWA to simulate sailing
+ simulatedTwa += 0.5 // Change by 0.5 degrees
+ if (simulatedTwa > 170) simulatedTwa = 40.0 // Reset or change direction
+ simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
+
+ kotlinx.coroutines.delay(1000) // Update every second
+ }
+ }
+
+ // Initialize Anchor Watch UI elements
+ fabAnchor = findViewById(R.id.fab_anchor)
+ anchorConfigContainer = findViewById(R.id.anchor_config_container)
+ anchorStatusText = findViewById(R.id.anchor_status_text)
+ anchorRadiusText = findViewById(R.id.anchor_radius_text)
+ buttonDecreaseRadius = findViewById(R.id.button_decrease_radius)
+ buttonIncreaseRadius = findViewById(R.id.button_increase_radius)
+ buttonSetAnchor = findViewById(R.id.button_set_anchor)
+ buttonStopAnchor = findViewById(R.id.button_stop_anchor)
+
+ // Set initial placeholder values
+ updateInstrumentDisplay(
+ aws = getString(R.string.placeholder_aws_value),
+ tws = getString(R.string.placeholder_tws_value),
+ hdg = getString(R.string.placeholder_hdg_value),
+ cog = getString(R.string.placeholder_cog_value),
+ bsp = getString(R.string.placeholder_bsp_value),
+ sog = getString(R.string.placeholder_sog_value),
+ vmg = getString(R.string.placeholder_vmg_value),
+ depth = getString(R.string.placeholder_depth_value),
+ polarPct = getString(R.string.placeholder_polar_value),
+ baro = getString(R.string.placeholder_baro_value)
+ )
+
+ fabToggleInstruments.setOnClickListener {
+ if (instrumentDisplayContainer.visibility == View.VISIBLE) {
+ instrumentDisplayContainer.visibility = View.GONE
+ mapView?.visibility = View.VISIBLE
+ } else {
+ instrumentDisplayContainer.visibility = View.VISIBLE
+ mapView?.visibility = View.GONE
+ }
+ }
+
+ fabTidal.setOnClickListener {
+ toggleTidalCurrentVisibility()
+ }
+
+ 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()
+ }
+
+ // Initialize Voice Log
+ fabVoiceLog = findViewById(R.id.fab_voice_log)
+ voiceLogContainer = findViewById(R.id.voice_log_container)
+
+ fabVoiceLog.setOnClickListener {
+ if (voiceLogContainer.visibility == View.VISIBLE) {
+ supportFragmentManager.popBackStack()
+ voiceLogContainer.visibility = View.GONE
+ } else {
+ voiceLogContainer.visibility = View.VISIBLE
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.voice_log_container, VoiceLogFragment())
+ .addToBackStack(null)
+ .commit()
+ }
+ }
+ }
+
+ 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 setupTidalCurrentMapLayers(style: Style) {
+ // Add tidal arrow icon
+ style.addImage(TIDAL_ARROW_ICON_ID, BitmapFactory.decodeResource(resources, R.drawable.ic_tidal_arrow))
+
+ // Create source
+ tidalCurrentSource = GeoJsonSource(TIDAL_CURRENT_SOURCE_ID)
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
+ style.addSource(tidalCurrentSource!!)
+
+ // Create layer for arrows
+ val tidalCurrentLayer = SymbolLayer(TIDAL_CURRENT_LAYER_ID, TIDAL_CURRENT_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(TIDAL_ARROW_ICON_ID),
+ PropertyFactory.iconRotate(Expression.get("rotation")),
+ PropertyFactory.iconSize(Expression.get("size")),
+ PropertyFactory.iconColor(Expression.get("color")),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true)
+ )
+ }
+ style.addLayer(tidalCurrentLayer)
+ }
+
+ private fun toggleTidalCurrentVisibility() {
+ val newState = !LocationService.tidalCurrentState.value.isVisible
+ // Since we cannot update the flow directly from MainActivity (it's owned by LocationService),
+ // we should ideally send an intent or use a shared state.
+ // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
+ // but it's a StateFlow in LocationService.
+ // Let's add a public update method or an action to LocationService.
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_TOGGLE_TIDAL_VISIBILITY
+ putExtra(LocationService.EXTRA_TIDAL_VISIBILITY, newState)
+ }
+ startService(intent)
+ val toastMsg = if (newState) "Tidal current overlay enabled" else "Tidal current overlay disabled"
+ Toast.makeText(this, toastMsg, Toast.LENGTH_SHORT).show()
+ }
+
+ private fun updateTidalCurrentMapLayers(state: TidalCurrentState) {
+ maplibreMap?.getStyle { style ->
+ val layer = style.getLayer(TIDAL_CURRENT_LAYER_ID)
+ if (state.isVisible) {
+ layer?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.VISIBLE))
+ val features = state.currents.map { current ->
+ Feature.fromGeometry(
+ Point.fromLngLat(current.longitude, current.latitude)
+ ).apply {
+ addNumberProperty("rotation", current.directionDegrees.toFloat())
+ // Scale arrow size based on speed (knots)
+ val size = (0.5 + current.speedKnots / 2.0).coerceAtMost(2.0).toFloat()
+ addNumberProperty("size", size)
+ // Optionally change color based on speed
+ val color = if (current.speedKnots > 2.0) "#FF0000" else "#0000FF"
+ addStringProperty("color", color)
+ }
+ }
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ } else {
+ layer?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE))
+ }
+ }
+ }
+
+ private fun 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 ->
+ withContext(Dispatchers.Main) {
+ updateTidalCurrentMapLayers(state)
+ }
+ }
+ }
+ }
+
+ 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,
+ baro: String
+ ) {
+ valueAws.text = aws
+ valueTws.text = tws
+ valueHdg.text = hdg
+ valueCog.text = cog
+ valueBsp.text = bsp
+ valueSog.text = sog
+ valueVmg.text = vmg
+ valueDepth.text = depth
+ valuePolarPct.text = polarPct
+ valueBaro.text = baro
+ }
+
+ override fun onStart() {
+ super.onStart()
+ mapView?.onStart()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ mapView?.onResume()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ mapView?.onPause()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ mapView?.onStop()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ mapView?.onSaveInstanceState(outState)
+ }
+
+ 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/org/terst/nav/PolarData.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt
new file mode 100644
index 0000000..88a8d0d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/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/org/terst/nav/PolarDiagramView.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt
new file mode 100644
index 0000000..4a678cc
--- /dev/null
+++ b/android-app/app/src/main/kotlin/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/kotlin/org/terst/nav/PowerMode.kt b/android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt
new file mode 100644
index 0000000..22e1b77
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt
@@ -0,0 +1,7 @@
+package org.terst.nav
+
+enum class PowerMode(val gpsUpdateIntervalMillis: Long) {
+ FULL(1000L), // 1 Hz
+ ECONOMY(5000L), // 0.2 Hz
+ ANCHOR_WATCH(10000L) // 0.1 Hz
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/TidalCurrentData.kt b/android-app/app/src/main/kotlin/org/terst/nav/TidalCurrentData.kt
new file mode 100644
index 0000000..9ddd5e8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/TidalCurrentData.kt
@@ -0,0 +1,17 @@
+package org.terst.nav
+
+import android.location.Location
+
+data class TidalCurrent(
+ val latitude: Double,
+ val longitude: Double,
+ val speedKnots: Double,
+ val directionDegrees: Double, // Direction the current is flowing TOWARDS
+ val timestampMillis: Long
+)
+
+data class TidalCurrentState(
+ val currents: List<TidalCurrent> = emptyList(),
+ val isVisible: Boolean = false,
+ val selectedTimeMillis: Long = System.currentTimeMillis()
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
new file mode 100644
index 0000000..f2a4e59
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
@@ -0,0 +1,87 @@
+package org.terst.nav.gps
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Handler
+import android.os.Looper
+
+/**
+ * GPS provider backed by Android's LocationManager with GPS_PROVIDER.
+ *
+ * @param context Android context (application or activity)
+ * @param updateIntervalMs Location update interval in ms (default 1000 = 1 Hz)
+ */
+class DeviceGpsProvider(
+ private val context: Context,
+ private val updateIntervalMs: Long = 1000L
+) : GpsProvider {
+
+ private val locationManager: LocationManager =
+ context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
+ private val listeners = mutableListOf<GpsListener>()
+ private val lock = Any()
+
+ @Volatile override var position: GpsPosition? = null
+ private set
+
+ private val fixLostHandler = Handler(Looper.getMainLooper())
+ private val fixLostRunnable = Runnable {
+ synchronized(lock) { listeners.toList() }.forEach { it.onFixLost() }
+ }
+
+ private val locationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ val pos = GpsPosition(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ sog = location.speed * 1.94384, // m/s → knots
+ cog = location.bearing.toDouble(), // degrees true
+ timestampMs = location.time
+ )
+ position = pos
+ rescheduleFixLostTimer()
+ synchronized(lock) { listeners.toList() }.forEach { it.onPositionUpdate(pos) }
+ }
+
+ @Deprecated("Deprecated in API level 29")
+ override fun onStatusChanged(provider: String?, status: Int, extras: android.os.Bundle?) = Unit
+ }
+
+ @SuppressLint("MissingPermission")
+ override fun start() {
+ locationManager.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ updateIntervalMs,
+ 0f,
+ locationListener,
+ Looper.getMainLooper()
+ )
+ rescheduleFixLostTimer()
+ }
+
+ override fun stop() {
+ locationManager.removeUpdates(locationListener)
+ fixLostHandler.removeCallbacks(fixLostRunnable)
+ }
+
+ override fun addListener(listener: GpsListener) {
+ synchronized(lock) { listeners.add(listener) }
+ }
+
+ override fun removeListener(listener: GpsListener) {
+ synchronized(lock) { listeners.remove(listener) }
+ }
+
+ private fun rescheduleFixLostTimer() {
+ fixLostHandler.removeCallbacks(fixLostRunnable)
+ fixLostHandler.postDelayed(fixLostRunnable, FIX_LOST_TIMEOUT_MS)
+ }
+
+ companion object {
+ private const val FIX_LOST_TIMEOUT_MS = 10_000L
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
new file mode 100644
index 0000000..5faf30c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -0,0 +1,9 @@
+package org.terst.nav.gps
+
+data class GpsPosition(
+ val latitude: Double,
+ val longitude: Double,
+ val sog: Double, // knots
+ val cog: Double, // degrees true
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
new file mode 100644
index 0000000..3c3d634
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.gps
+
+interface GpsProvider {
+ fun start()
+ fun stop()
+ val position: GpsPosition?
+ fun addListener(listener: GpsListener)
+ fun removeListener(listener: GpsListener)
+}
+
+interface GpsListener {
+ fun onPositionUpdate(position: GpsPosition)
+ fun onFixLost()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt
new file mode 100644
index 0000000..25dd303
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/InMemoryLogbookRepository.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.logbook
+
+class InMemoryLogbookRepository {
+ private val entries = mutableListOf<LogEntry>()
+ private var nextId = 1L
+
+ fun save(entry: LogEntry): LogEntry {
+ val saved = entry.copy(id = nextId++)
+ entries.add(saved)
+ return saved
+ }
+
+ fun getAll(): List<LogEntry> = entries.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt
new file mode 100644
index 0000000..17cebfb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.logbook
+
+enum class EntryType { SAIL_CHANGE, ENGINE, WEATHER_OBS, NAV_EVENT, GENERAL }
+
+data class LogEntry(
+ val id: Long = 0L,
+ val timestampMs: Long,
+ val text: String,
+ val entryType: EntryType,
+ val lat: Double? = null,
+ val lon: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt
new file mode 100644
index 0000000..fe51cf8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogState.kt
@@ -0,0 +1,9 @@
+package org.terst.nav.logbook
+
+sealed class VoiceLogState {
+ object Idle : VoiceLogState()
+ object Listening : VoiceLogState()
+ data class Result(val recognized: String) : VoiceLogState()
+ data class Saved(val entry: LogEntry) : VoiceLogState()
+ data class Error(val message: String) : VoiceLogState()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
new file mode 100644
index 0000000..067cbaf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
@@ -0,0 +1,37 @@
+package org.terst.nav.logbook
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
+
+ private val _state = MutableStateFlow<VoiceLogState>(VoiceLogState.Idle)
+ val state: StateFlow<VoiceLogState> = _state
+
+ fun onListeningStarted() {
+ _state.value = VoiceLogState.Listening
+ }
+
+ fun onSpeechRecognized(text: String) {
+ _state.value = VoiceLogState.Result(text)
+ }
+
+ fun onRecognitionError(message: String) {
+ _state.value = VoiceLogState.Error(message)
+ }
+
+ fun confirmAndSave() {
+ val current = _state.value as? VoiceLogState.Result ?: return
+ val entry = LogEntry(
+ timestampMs = System.currentTimeMillis(),
+ text = current.recognized,
+ entryType = EntryType.GENERAL
+ )
+ val saved = repository.save(entry)
+ _state.value = VoiceLogState.Saved(saved)
+ }
+
+ fun retry() {
+ _state.value = VoiceLogState.Idle
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
new file mode 100644
index 0000000..27d9c2c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -0,0 +1,255 @@
+package org.terst.nav.nmea
+
+import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
+import java.util.Calendar
+import java.util.TimeZone
+
+class NmeaParser {
+
+ /**
+ * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the
+ * sentence is void (status=V), malformed, or cannot be parsed.
+ *
+ * Supported talker IDs: GP, GN, and any other standard prefix.
+ * SOG and COG default to 0.0 when the fields are absent.
+ */
+ fun parseRmc(sentence: String): GpsPosition? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null // Status must be Active
+
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
+
+ val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
+ val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
+
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
+
+ // Date field is fields[9], time is fields[1]
+ val timestampMs = parseTimestamp(timeStr = fields.getOrNull(1) ?: "", dateStr = fields.getOrNull(9) ?: "")
+ if (timestampMs == 0L) return null // If timestamp parsing fails, consider the sentence invalid
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Parses an NMEA MWV sentence (Wind Speed and Angle) and returns a [WindData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIMWV,314.0,R,04.8,N,A*22
+ * Fields:
+ * 1: Wind Angle, 0.0 to 359.9 degrees
+ * 2: Reference (R = Relative, T = True)
+ * 3: Wind Speed
+ * 4: Wind Speed Units (N = Knots, M = Meters/sec, K = Km/hr)
+ * 5: Status (A = Data Valid, V = Data Invalid)
+ * (Checksum)
+ */
+ fun parseMwv(sentence: String): WindData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 6) return null
+
+ if (!fields[0].endsWith("MWV")) return null
+ if (fields.getOrNull(5) != "A") return null // Status must be A (Valid)
+
+ val windAngle = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ val reference = fields.getOrNull(2) ?: return null
+ var windSpeed = fields.getOrNull(3)?.toDoubleOrNull() ?: return null
+ val speedUnits = fields.getOrNull(4) ?: return null
+
+ val isTrueWind = (reference == "T")
+
+ // Convert speed to knots if necessary
+ when (speedUnits) {
+ "M" -> windSpeed *= 1.94384 // m/s to knots
+ "K" -> windSpeed *= 0.539957 // km/h to knots
+ "N" -> { /* already in knots */ }
+ else -> return null // Unknown units
+ }
+
+ // MWV sentences don't typically include date. Use current time.
+ // In a real application, timestamp should be managed more carefully, possibly from a common system clock
+ // or a timestamp field if available in the NMEA stream.
+ val timestampMs = System.currentTimeMillis()
+
+ return WindData(windAngle, windSpeed, isTrueWind, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ * Works for both latitude (DDMM.MM) and longitude (DDDMM.MM) formats.
+ */
+ private fun parseNmeaDegrees(value: String): Double {
+ val raw = value.toDoubleOrNull() ?: return 0.0
+ val degrees = (raw / 100.0).toInt()
+ val minutes = raw - degrees * 100.0
+ return degrees + minutes / 60.0
+ }
+
+ /**
+ * Parses an NMEA DBT sentence (Depth Below Transducer) and returns a [DepthData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIDBT,005.6,f,01.7,M,009.2,F*21 (Depth: 1.7m)
+ * Fields:
+ * 1: Depth, feet
+ * 2: F = feet
+ * 3: Depth, meters
+ * 4: M = meters
+ * 5: Depth, fathoms
+ * 6: F = fathoms
+ * (Checksum)
+ */
+ fun parseDbt(sentence: String): DepthData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 5) return null // Minimum fields for depth in meters
+
+ if (!fields[0].endsWith("DBT")) return null
+
+ val depthMeters = fields.getOrNull(3)?.toDoubleOrNull() ?: return null
+ if (fields.getOrNull(4) != "M") return null // Ensure units are meters
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return DepthData(depthMeters, timestampMs)
+ }
+
+ /**
+ * Parses NMEA HDG (Heading, Deviation & Variation) or HDM (Heading - Magnetic)
+ * sentences and returns a [HeadingData], or null if malformed.
+ *
+ * HDG Example: $IIHDG,225.0,,,11.0,W*00
+ * Fields:
+ * 1: Magnetic Sensor Heading in degrees
+ * 2: Magnetic Deviation, degrees
+ * 3: Magnetic Variation, degrees
+ * 4: Magnetic Variation Direction (E/W)
+ *
+ * HDM Example: $IIHDM,225.0,M*30
+ * Fields:
+ * 1: Heading, Magnetic
+ * 2: M = Magnetic
+ */
+ fun parseHdg(sentence: String): HeadingData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 2) return null
+
+ val talkerId = fields[0].substring(1,3)
+ val sentenceId = fields[0].substring(3)
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return when (sentenceId) {
+ "HDG" -> {
+ if (fields.size < 5) return null
+ val magneticHeading = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ // fields[2] (deviation) and fields[3] (variation) can be empty
+ val variation = fields.getOrNull(4)?.toDoubleOrNull()
+ val varDirection = fields.getOrNull(5)
+
+ val magneticVariation = if (variation != null && varDirection != null) {
+ if (varDirection == "W") -variation else variation
+ } else null
+
+ val trueHeading = if (magneticHeading != null && magneticVariation != null) {
+ (magneticHeading + magneticVariation + 360) % 360
+ } else magneticHeading // If variation is null, magneticHeading can be treated as true for display, or better to leave true as null
+
+ HeadingData(
+ headingDegreesTrue = trueHeading ?: magneticHeading, // Fallback to magnetic if true can't be calculated
+ headingDegreesMagnetic = magneticHeading,
+ magneticVariation = magneticVariation,
+ timestampMs = timestampMs
+ )
+ }
+ "HDM" -> {
+ if (fields.size < 2) return null
+ val magneticHeading = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ HeadingData(
+ headingDegreesTrue = magneticHeading, // Assuming HDM is only magnetic, true cannot be derived without variation
+ headingDegreesMagnetic = magneticHeading,
+ magneticVariation = null,
+ timestampMs = timestampMs
+ )
+ }
+ else -> null
+ }
+ }
+
+ /**
+ * Parses a generic NMEA sentence and returns the corresponding data object,
+ * or null if the sentence type is not supported or malformed.
+ */
+ fun parse(sentence: String): Any? {
+ if (sentence.isBlank() || sentence.length < 6) return null // Minimum valid sentence length
+
+ val sentenceId = sentence.substring(3, 6) // e.g., "RMC", "MWV", "DBT", "HDG", "HDM"
+
+ return when (sentenceId) {
+ "RMC" -> parseRmc(sentence)
+ "MWV" -> parseMwv(sentence)
+ "DBT" -> parseDbt(sentence)
+ "HDG", "HDM" -> parseHdg(sentence)
+ else -> null
+ }
+ }
+
+ /**
+ * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value.
+ * Returns 0 on any parse failure.
+ */
+ private fun parseTimestamp(timeStr: String, dateStr: String): Long {
+ return try {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.isLenient = false
+
+ if (dateStr.length >= 6) {
+ val day = dateStr.substring(0, 2).toInt()
+ val month = dateStr.substring(2, 4).toInt() - 1 // Calendar is 0-based
+ val yy = dateStr.substring(4, 6).toInt()
+ val year = if (yy < 70) 2000 + yy else 1900 + yy
+ cal.set(Calendar.YEAR, year)
+ cal.set(Calendar.MONTH, month)
+ cal.set(Calendar.DAY_OF_MONTH, day)
+ }
+
+ if (timeStr.length >= 6) {
+ val hours = timeStr.substring(0, 2).toInt()
+ val minutes = timeStr.substring(2, 4).toInt()
+ val seconds = timeStr.substring(4, 6).toInt()
+ val millis = if (timeStr.length > 7) {
+ (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ } else 0
+ cal.set(Calendar.HOUR_OF_DAY, hours)
+ cal.set(Calendar.MINUTE, minutes)
+ cal.set(Calendar.SECOND, seconds)
+ cal.set(Calendar.MILLISECOND, millis)
+ }
+
+ cal.timeInMillis
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
new file mode 100644
index 0000000..4298f0d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
@@ -0,0 +1,125 @@
+package org.terst.nav.nmea
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+
+class NmeaStreamManager(
+ private val parser: NmeaParser,
+ private val connectionScope: CoroutineScope
+) {
+ private var connectionJob: Job? = null
+ private val isConnected = AtomicBoolean(false)
+
+ // Flows to emit parsed data
+ private val _nmeaGpsPosition = MutableSharedFlow<GpsPosition>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaGpsPosition: SharedFlow<GpsPosition> = _nmeaGpsPosition.asSharedFlow()
+
+ private val _nmeaWindData = MutableSharedFlow<WindData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaWindData: SharedFlow<WindData> = _nmeaWindData.asSharedFlow()
+
+ private val _nmeaDepthData = MutableSharedFlow<DepthData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaDepthData: SharedFlow<DepthData> = _nmeaDepthData.asSharedFlow()
+
+ private val _nmeaHeadingData = MutableSharedFlow<HeadingData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+
+ fun start(address: String, port: Int) {
+ if (connectionJob?.isActive == true) {
+ Log.d(TAG, "NMEA stream already running.")
+ return
+ }
+
+ connectionJob = connectionScope.launch(Dispatchers.IO) {
+ while (isActive) {
+ if (!isConnected.get()) {
+ Log.d(TAG, "Attempting to connect to NMEA source: $address:$port")
+ try {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress(address, port), CONNECTION_TIMEOUT_MS)
+ isConnected.set(true)
+ Log.i(TAG, "Connected to NMEA source: $address:$port")
+
+ BufferedReader(InputStreamReader(socket.getInputStream())).use { reader ->
+ var line: String?
+ while (isActive && isConnected.get()) {
+ line = reader.readLine()
+ if (line != null) {
+ // Log.v(TAG, "NMEA: $line") // Too verbose for regular logging
+ parser.parse(line)?.let { parsedData ->
+ when (parsedData) {
+ is GpsPosition -> _nmeaGpsPosition.emit(parsedData)
+ is WindData -> _nmeaWindData.emit(parsedData)
+ is DepthData -> _nmeaDepthData.emit(parsedData)
+ is HeadingData -> _nmeaHeadingData.emit(parsedData)
+ else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}")
+ }
+ }
+ } else {
+ // End of stream, connection closed by server
+ Log.w(TAG, "NMEA stream ended, reconnecting...")
+ isConnected.set(false)
+ break
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "NMEA connection error: ${e.message}", e)
+ isConnected.set(false)
+ }
+ }
+ if (!isConnected.get()) {
+ delay(RETRY_DELAY_MS)
+ }
+ }
+ Log.d(TAG, "NMEA connection job finished.")
+ }
+ }
+
+ fun stop() {
+ connectionJob?.cancel()
+ connectionJob = null
+ isConnected.set(false)
+ Log.i(TAG, "NMEA stream stopped.")
+ }
+
+ companion object {
+ private const val TAG = "NmeaStreamManager"
+ private const val CONNECTION_TIMEOUT_MS = 5000
+ private const val RETRY_DELAY_MS = 5000L
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt
new file mode 100644
index 0000000..df31b40
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt
@@ -0,0 +1,6 @@
+package org.terst.nav.sensors
+
+data class DepthData(
+ val depthMeters: Double,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt
new file mode 100644
index 0000000..8f7532a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt
@@ -0,0 +1,8 @@
+package org.terst.nav.sensors
+
+data class HeadingData(
+ val headingDegreesTrue: Double,
+ val headingDegreesMagnetic: Double?, // Nullable if not available
+ val magneticVariation: Double?, // Nullable if not available
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
new file mode 100644
index 0000000..4f640ef
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
@@ -0,0 +1,8 @@
+package org.terst.nav.sensors
+
+data class WindData(
+ val windAngle: Double, // degrees (0-359), relative or true
+ val windSpeed: Double, // knots
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
new file mode 100644
index 0000000..ef48d37
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
@@ -0,0 +1,169 @@
+package org.terst.nav.ui.voicelog
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import kotlinx.coroutines.launch
+import org.terst.nav.R
+import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.logbook.VoiceLogState
+import org.terst.nav.logbook.VoiceLogViewModel
+import java.util.Locale
+
+class VoiceLogFragment : Fragment() {
+
+ private lateinit var speechRecognizer: SpeechRecognizer
+ private val viewModel by lazy {
+ VoiceLogViewModel(repository = InMemoryLogbookRepository())
+ }
+
+ private lateinit var tvStatus: TextView
+ private lateinit var tvRecognized: TextView
+ private lateinit var fabMic: FloatingActionButton
+ private lateinit var llConfirm: LinearLayout
+ private lateinit var btnSave: Button
+ private lateinit var btnRetry: Button
+ private lateinit var tvSavedConfirmation: TextView
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = inflater.inflate(R.layout.fragment_voice_log, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvStatus = view.findViewById(R.id.tv_status)
+ tvRecognized = view.findViewById(R.id.tv_recognized)
+ fabMic = view.findViewById(R.id.fab_mic)
+ llConfirm = view.findViewById(R.id.ll_confirm_buttons)
+ btnSave = view.findViewById(R.id.btn_save)
+ btnRetry = view.findViewById(R.id.btn_retry)
+ tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
+
+ setupSpeechRecognizer()
+
+ fabMic.setOnClickListener { startListening() }
+ btnSave.setOnClickListener { viewModel.confirmAndSave() }
+ btnRetry.setOnClickListener { viewModel.retry() }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { state -> renderState(state) }
+ }
+ }
+
+ private fun setupSpeechRecognizer() {
+ if (!SpeechRecognizer.isRecognitionAvailable(requireContext())) {
+ tvStatus.text = "Speech recognition not available"
+ fabMic.isEnabled = false
+ return
+ }
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext())
+ speechRecognizer.setRecognitionListener(object : RecognitionListener {
+ override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() }
+ override fun onResults(results: Bundle?) {
+ val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ val text = matches?.firstOrNull() ?: ""
+ if (text.isNotBlank()) viewModel.onSpeechRecognized(text)
+ else viewModel.onRecognitionError("Could not understand speech")
+ }
+ override fun onError(error: Int) {
+ viewModel.onRecognitionError("Recognition error: $error")
+ }
+ override fun onBeginningOfSpeech() {}
+ override fun onBufferReceived(buffer: ByteArray?) {}
+ override fun onEndOfSpeech() {}
+ override fun onEvent(eventType: Int, params: Bundle?) {}
+ override fun onPartialResults(partialResults: Bundle?) {}
+ override fun onRmsChanged(rmsdB: Float) {}
+ })
+ }
+
+ private fun startListening() {
+ if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ @Suppress("DEPRECATION")
+ requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RC_AUDIO)
+ return
+ }
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
+ }
+ speechRecognizer.startListening(intent)
+ }
+
+ private fun renderState(state: VoiceLogState) {
+ when (state) {
+ is VoiceLogState.Idle -> {
+ tvStatus.text = "Tap microphone to log"
+ tvRecognized.text = ""
+ llConfirm.visibility = View.GONE
+ tvSavedConfirmation.text = ""
+ fabMic.isEnabled = true
+ }
+ is VoiceLogState.Listening -> {
+ tvStatus.text = "Listening…"
+ tvRecognized.text = ""
+ llConfirm.visibility = View.GONE
+ fabMic.isEnabled = false
+ }
+ is VoiceLogState.Result -> {
+ tvStatus.text = "Recognized:"
+ tvRecognized.text = state.recognized
+ llConfirm.visibility = View.VISIBLE
+ fabMic.isEnabled = false
+ }
+ is VoiceLogState.Saved -> {
+ tvStatus.text = "Saved!"
+ tvRecognized.text = state.entry.text
+ tvSavedConfirmation.text = "[${state.entry.entryType}] entry saved"
+ llConfirm.visibility = View.GONE
+ fabMic.isEnabled = true
+ }
+ is VoiceLogState.Error -> {
+ tvStatus.text = "Error: ${state.message}"
+ tvRecognized.text = ""
+ llConfirm.visibility = View.GONE
+ fabMic.isEnabled = true
+ }
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array<out String>,
+ grantResults: IntArray
+ ) {
+ if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ startListening()
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ if (::speechRecognizer.isInitialized) speechRecognizer.destroy()
+ }
+
+ companion object {
+ private const val RC_AUDIO = 1001
+ }
+}
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/drawable/ic_anchor.xml b/android-app/app/src/main/res/drawable/ic_anchor.xml
new file mode 100644
index 0000000..2389c93
--- /dev/null
+++ b/android-app/app/src/main/res/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/drawable/ic_tidal_arrow.xml b/android-app/app/src/main/res/drawable/ic_tidal_arrow.xml
new file mode 100644
index 0000000..973b3ea
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_tidal_arrow.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="#0000FF"
+ android:pathData="M12,2L4.5,20.29L5.21,21L12,18L18.79,21L19.5,20.29L12,2Z" />
+</vector>
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 757dbdb..54ad0cd 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -1,22 +1,542 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:layout_height="match_parent">
+ android:layout_height="match_parent"
+ tools:context=".MainActivity">
- <androidx.fragment.app.FragmentContainerView
- android:id="@+id/fragment_container"
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:layout_marginBottom="56dp" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
- <com.google.android.material.bottomnavigation.BottomNavigationView
- android:id="@+id/bottom_nav"
+ <!-- Instrument Display Container -->
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:id="@+id/instrument_display_container"
android:layout_width="match_parent"
- android:layout_height="56dp"
- android:layout_gravity="bottom"
- android:background="?attr/colorSurface"
- app:menu="@menu/bottom_nav_menu" />
+ android:layout_height="match_parent"
+ android:background="@color/instrument_background"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <!-- Guidelines for a 3x2 grid-like layout (6 sections) -->
+ <androidx.constraintlayout.widget.Guideline
+ android:id="@+id/guideline_vertical_33"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ app:layout_constraintGuide_percent="0.33" />
+ <androidx.constraintlayout.widget.Guideline
+ android:id="@+id/guideline_vertical_66"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ app:layout_constraintGuide_percent="0.66" />
+ <androidx.constraintlayout.widget.Guideline
+ android:id="@+id/guideline_horizontal_50"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ app:layout_constraintGuide_percent="0.5" />
+
+ <!-- Wind Instrument -->
+ <TextView
+ android:id="@+id/label_wind"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_wind"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_aws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/label_wind"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_aws"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_aws"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/value_aws"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_tws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/label_aws"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_tws"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_tws"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/value_tws"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- Compass Instrument -->
+ <TextView
+ android:id="@+id/label_compass"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_compass"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_hdg"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="---"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/label_compass"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_hdg"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_hdg"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/value_hdg"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_cog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="---"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/label_hdg"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_cog"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_cog"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/value_cog"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- Boat Speed Instrument -->
+ <TextView
+ android:id="@+id/label_boatspeed"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_boatspeed"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_bsp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toBottomOf="@+id/label_boatspeed"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_bsp"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_bsp"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toBottomOf="@+id/value_bsp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toBottomOf="@+id/label_bsp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/label_sog"
+ style="@style/InstrumentSecondaryLabel"
+ android:text="@string/instrument_label_sog"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toBottomOf="@+id/value_sog"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- VMG Instrument -->
+ <TextView
+ android:id="@+id/label_vmg"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_vmg"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="@+id/guideline_horizontal_50"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_vmg"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/label_vmg"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- Depth Instrument -->
+ <TextView
+ android:id="@+id/label_depth"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_depth"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toTopOf="@+id/guideline_horizontal_50"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_depth"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="--.-"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_33"
+ app:layout_constraintTop_toBottomOf="@+id/label_depth"
+ app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ <!-- Polar % Instrument -->
+ <TextView
+ android:id="@+id/label_polar_pct"
+ style="@style/InstrumentLabel"
+ android:text="@string/instrument_label_polar_pct"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toTopOf="@+id/guideline_horizontal_50"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+ <TextView
+ android:id="@+id/value_polar_pct"
+ style="@style/InstrumentPrimaryValue"
+ android:text="@string/placeholder_polar_value"
+ app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
+ app:layout_constraintTop_toBottomOf="@+id/label_polar_pct"
+ 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"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:layout_margin="16dp"
+ app:layout_constraintDimensionRatio="1:1"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/label_baro_unit"
+ app:layout_constraintBottom_toBottomOf="parent"
+ />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_tidal"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Toggle Tidal Current Overlay"
+ app:srcCompat="@android:drawable/ic_menu_directions"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toTopOf="@+id/fab_toggle_instruments" />
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_toggle_instruments"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Toggle Instrument Display"
+ app:srcCompat="@android:drawable/ic_menu_rotate"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent" />
+
+ <!-- Anchor FAB -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_anchor"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="@string/fab_anchor_content_description"
+ app:srcCompat="@android:drawable/ic_menu_myplaces"
+ app:backgroundTint="@color/anchor_button_background"
+ app:layout_constraintBottom_toTopOf="@+id/fab_mob"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mob"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="@string/fab_mob_content_description"
+ app:srcCompat="@android:drawable/ic_dialog_alert"
+ app:backgroundTint="@color/mob_button_background"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent" />
+
+ <!-- Anchor Configuration Container -->
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:id="@+id/anchor_config_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="#DD212121"
+ android:padding="16dp"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent">
+
+ <TextView
+ android:id="@+id/anchor_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_config_title"
+ android:textColor="@android:color/white"
+ android:textSize="20sp"
+ android:textStyle="bold"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <TextView
+ android:id="@+id/anchor_status_text"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:textColor="@android:color/white"
+ android:textSize="16sp"
+ tools:text="Anchor Inactive"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/anchor_title" />
+
+ <LinearLayout
+ android:id="@+id/radius_control_layout"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="16dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/anchor_status_text">
+
+ <Button
+ android:id="@+id/button_decrease_radius"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="-"
+ android:textSize="20sp"
+ android:minWidth="48dp"
+ android:layout_marginEnd="8dp" />
+
+ <TextView
+ android:id="@+id/anchor_radius_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="@android:color/white"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ tools:text="Radius: 50.0m"
+ android:gravity="center_vertical" />
+
+ <Button
+ android:id="@+id/button_increase_radius"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+"
+ android:textSize="20sp"
+ android:minWidth="48dp"
+ android:layout_marginStart="8dp" />
+
+ </LinearLayout>
+
+ <Button
+ android:id="@+id/button_set_anchor"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/button_set_anchor"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/radius_control_layout" />
+
+ <Button
+ android:id="@+id/button_stop_anchor"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/button_stop_anchor"
+ android:backgroundTint="@android:color/holo_red_dark"
+ app:layout_constraintStart_toEndOf="@+id/button_set_anchor"
+ app:layout_constraintTop_toBottomOf="@+id/radius_control_layout"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.5" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <!-- MOB Navigation Display Container -->
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:id="@+id/mob_navigation_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/instrument_background"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <TextView
+ android:id="@+id/mob_label_distance"
+ style="@style/InstrumentLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/mob_label_distance"
+ app:layout_constraintBottom_toTopOf="@+id/mob_value_distance"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintVertical_chainStyle="packed"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <TextView
+ android:id="@+id/mob_value_distance"
+ style="@style/InstrumentPrimaryValue"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ tools:text="125 m"
+ android:textSize="80sp"
+ app:layout_constraintBottom_toTopOf="@+id/mob_label_elapsed_time"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/mob_label_distance" />
+
+ <TextView
+ android:id="@+id/mob_label_elapsed_time"
+ style="@style/InstrumentLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:text="@string/mob_label_elapsed_time"
+ app:layout_constraintBottom_toTopOf="@+id/mob_value_elapsed_time"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/mob_value_distance" />
+
+ <TextView
+ android:id="@+id/mob_value_elapsed_time"
+ style="@style/InstrumentPrimaryValue"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ tools:text="00:01:23"
+ android:textSize="60sp"
+ app:layout_constraintBottom_toTopOf="@+id/mob_recovered_button"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/mob_label_elapsed_time" />
+
+ <Button
+ android:id="@+id/mob_recovered_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="64dp"
+ android:text="@string/mob_button_recovered"
+ android:paddingStart="32dp"
+ android:paddingEnd="32dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="16dp"
+ android:textSize="24sp"
+ android:backgroundTint="@color/mob_button_background"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/mob_value_elapsed_time" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <!-- Voice Log FAB -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_voice_log"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Open Voice Log"
+ android:src="@android:drawable/ic_btn_speak_now"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toTopOf="@+id/fab_tidal" />
+
+ <!-- Voice Log Fragment Container -->
+ <FrameLayout
+ android:id="@+id/voice_log_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@android:color/white"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file
diff --git a/android-app/app/src/main/res/layout/activity_weather.xml b/android-app/app/src/main/res/layout/activity_weather.xml
new file mode 100644
index 0000000..36ea871
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_weather.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <FrameLayout
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1" />
+
+ <com.google.android.material.bottomnavigation.BottomNavigationView
+ android:id="@+id/bottom_nav"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:menu="@menu/bottom_nav_menu"
+ xmlns:app="http://schemas.android.com/apk/res-auto" />
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml
new file mode 100644
index 0000000..e5f864c
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="24dp">
+
+ <TextView
+ android:id="@+id/tv_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Tap microphone to log"
+ android:textSize="18sp"
+ android:textAlignment="center"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:id="@+id/tv_recognized"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text=""
+ android:textSize="16sp"
+ android:textAlignment="center"
+ android:padding="12dp"
+ android:minHeight="80dp"
+ android:background="#F5F5F5"
+ android:layout_marginBottom="24dp" />
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mic"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Start voice recognition"
+ android:src="@android:drawable/ic_btn_speak_now"
+ android:layout_marginBottom="16dp" />
+
+ <LinearLayout
+ android:id="@+id/ll_confirm_buttons"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone">
+
+ <Button
+ android:id="@+id/btn_save"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Save"
+ android:layout_marginEnd="8dp" />
+
+ <Button
+ android:id="@+id/btn_retry"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Retry" />
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/tv_saved_confirmation"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text=""
+ android:textSize="14sp"
+ android:layout_marginTop="16dp" />
+</LinearLayout>
diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..52d5417
--- /dev/null
+++ b/android-app/app/src/main/res/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/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..52d5417
--- /dev/null
+++ b/android-app/app/src/main/res/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/raw/mob_alarm.mp3 b/android-app/app/src/main/res/raw/mob_alarm.mp3
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/android-app/app/src/main/res/raw/mob_alarm.mp3
@@ -0,0 +1 @@
+
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index 2382364..43e0076 100644..100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -1,12 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
+ <color name="purple_200">#FFBB86FC</color>
+ <color name="purple_500">#FF6200EE</color>
+ <color name="purple_700">#FF3700B3</color>
+ <color name="teal_200">#FF03DAC5</color>
+ <color name="teal_700">#FF018786</color>
+ <color name="black">#FF000000</color>
+ <color name="white">#FFFFFFFF</color>
+
+ <!-- Maritime theme colors -->
<color name="primary">#0D47A1</color>
<color name="primary_dark">#002171</color>
<color name="accent">#FF6D00</color>
<color name="surface">#FFFFFF</color>
<color name="on_primary">#FFFFFF</color>
+
+ <!-- Colors for instrument display -->
+ <color name="instrument_text_normal">#FFFFFFFF</color>
+ <color name="instrument_text_secondary">#B3FFFFFF</color>
+ <color name="instrument_text_alarm">#FFFF0000</color>
+ <color name="instrument_text_stale">#FFFFFF00</color>
+ <color name="instrument_background">#E61E1E1E</color>
+ <color name="mob_button_background">#FFD70000</color>
+ <color name="anchor_button_background">#3F51B5</color>
+
+ <!-- Wind overlay colors -->
<color name="wind_arrow">#FFFFFFFF</color>
<color name="wind_slow">#4CAF50</color>
<color name="wind_medium">#FF9800</color>
<color name="wind_strong">#F44336</color>
+
+ <!-- Night Vision Mode Colors -->
+ <color name="night_red_primary">#FFFF0000</color>
+ <color name="night_red_variant">#FFBB0000</color>
+ <color name="night_on_red">#FF000000</color>
+ <color name="night_background">#FF000000</color>
+ <color name="night_on_background">#FFFF0000</color>
+ <color name="night_surface">#FF110000</color>
+ <color name="night_on_surface">#FFFF0000</color>
</resources>
diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml
new file mode 100755
index 0000000..1b65ea9
--- /dev/null
+++ b/android-app/app/src/main/res/values/dimens.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Font sizes for instrument display -->
+ <dimen name="text_size_instrument_primary">24sp</dimen>
+ <dimen name="text_size_instrument_secondary">18sp</dimen>
+ <dimen name="text_size_instrument_label">14sp</dimen>
+ <dimen name="instrument_margin">8dp</dimen>
+ <dimen name="instrument_padding">4dp</dimen>
+</resources>
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
index b7d3bd8..499ba8d 100644..100755
--- a/android-app/app/src/main/res/values/strings.xml
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -1,8 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <string name="app_name">Nav</string>
+ <string name="app_name">nav</string>
+
+ <!-- Navigation -->
<string name="nav_map">Map</string>
<string name="nav_forecast">Forecast</string>
+
+ <!-- Instrument Labels -->
+ <string name="instrument_label_wind">WIND</string>
+ <string name="instrument_label_aws">AWS</string>
+ <string name="instrument_label_tws">TWS</string>
+ <string name="instrument_label_compass">COMPASS</string>
+ <string name="instrument_label_hdg">HDG</string>
+ <string name="instrument_label_cog">COG</string>
+ <string name="instrument_label_boatspeed">BOAT SPEED</string>
+ <string name="instrument_label_bsp">BSP</string>
+ <string name="instrument_label_sog">SOG</string>
+ <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>
+ <string name="placeholder_tws_value">--.-</string>
+ <string name="placeholder_hdg_value">---</string>
+ <string name="placeholder_cog_value">---</string>
+ <string name="placeholder_bsp_value">--.-</string>
+ <string name="placeholder_sog_value">--.-</string>
+ <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>
+
+ <!-- MOB Navigation View Strings -->
+ <string name="mob_label_distance">DISTANCE TO MOB</string>
+ <string name="mob_label_elapsed_time">ELAPSED TIME</string>
+ <string name="mob_button_recovered">RECOVERED</string>
+
+ <!-- Anchor Watch Strings -->
+ <string name="anchor_config_title">Anchor Watch</string>
+ <string name="button_set_anchor">SET ANCHOR</string>
+ <string name="button_stop_anchor">STOP WATCH</string>
+ <string name="anchor_inactive">Anchor Watch Inactive</string>
+ <string name="anchor_active_format">Anchor Set at %1$.4f, %2$.4f\nRadius: %3$.1fm\nDistance: %4$.1fm (%5$.1fm from limit)</string>
+ <string name="anchor_active_dragging_format">!!! ANCHOR DRAG !!!\nAnchor Set at %1$.4f, %2$.4f\nRadius: %3$.1fm\nDistance: %4$.1fm (%5$.1fm OVER limit)</string>
+
+ <!-- Weather / Forecast Strings -->
<string name="loading_weather">Fetching weather…</string>
<string name="error_location">Could not get location. Showing default position.</string>
<string name="error_weather">Failed to load weather data.</string>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
index cecd32f..abef4b9 100644..100755
--- a/android-app/app/src/main/res/values/themes.xml
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -1,9 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
-<resources>
+<resources xmlns:tools="http://schemas.android.com/tools">
+ <!-- Base application theme. -->
+ <style name="Theme.Nav" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
+ <item name="colorPrimary">@color/purple_200</item>
+ <item name="colorPrimaryVariant">@color/purple_700</item>
+ <item name="colorOnPrimary">@color/black</item>
+ <item name="colorSecondary">@color/teal_200</item>
+ <item name="colorSecondaryVariant">@color/teal_200</item>
+ <item name="colorOnSecondary">@color/black</item>
+ <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
+ </style>
+
+ <!-- Maritime theme (weather/forecast features) -->
<style name="Theme.NavApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:statusBarColor">@color/primary_dark</item>
</style>
+
+ <!-- Night Vision Theme -->
+ <style name="Theme.Nav.NightVision" parent="Theme.MaterialComponents.NoActionBar">
+ <item name="colorPrimary">@color/night_red_primary</item>
+ <item name="colorPrimaryVariant">@color/night_red_variant</item>
+ <item name="colorOnPrimary">@color/night_on_red</item>
+ <item name="colorSecondary">@color/night_red_primary</item>
+ <item name="colorSecondaryVariant">@color/night_red_variant</item>
+ <item name="colorOnSecondary">@color/night_on_red</item>
+ <item name="android:colorBackground">@color/night_background</item>
+ <item name="colorSurface">@color/night_surface</item>
+ <item name="colorOnSurface">@color/night_on_surface</item>
+ <item name="android:statusBarColor" tools:targetApi="l">@color/night_background</item>
+ </style>
+
+ <!-- Instrument Display Styles -->
+ <style name="InstrumentLabel" parent="Widget.AppCompat.TextView">
+ <item name="android:layout_width">0dp</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:gravity">center</item>
+ <item name="android:textColor">@color/instrument_text_normal</item>
+ <item name="android:textSize">@dimen/text_size_instrument_label</item>
+ <item name="android:textStyle">bold</item>
+ <item name="android:paddingTop">@dimen/instrument_padding</item>
+ <item name="android:paddingBottom">@dimen/instrument_padding</item>
+ </style>
+
+ <style name="InstrumentPrimaryValue" parent="Widget.AppCompat.TextView">
+ <item name="android:layout_width">0dp</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:gravity">center</item>
+ <item name="android:textColor">@color/instrument_text_normal</item>
+ <item name="android:textSize">@dimen/text_size_instrument_primary</item>
+ <item name="android:textStyle">bold</item>
+ </style>
+
+ <style name="InstrumentSecondaryLabel" parent="Widget.AppCompat.TextView">
+ <item name="android:layout_width">0dp</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:gravity">center</item>
+ <item name="android:textColor">@color/instrument_text_normal</item>
+ <item name="android:textSize">@dimen/text_size_instrument_secondary</item>
+ </style>
</resources>
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
+ }
+}