summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
commit97715ab4007ff3101f58edf4385cef1fc3d1615b (patch)
tree464bdb1df8cfed31402f5316fe84df974c0e59e2 /android-app/app/src/main/kotlin/org
parent9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff)
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt290
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt3
22 files changed, 991 insertions, 332 deletions
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
deleted file mode 100644
index 0c63662..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.terst.nav
-
-import android.location.Location
-import kotlin.math.*
-
-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
-
- /**
- * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
- * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out))
- *
- * @param depthMeters Depth from surface to seabed in meters.
- * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters.
- * @param rodeOutMeters Length of chain/rode deployed in meters.
- * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid.
- */
- fun calculateRecommendedWatchCircleRadius(
- depthMeters: Double,
- freeboardMeters: Double,
- rodeOutMeters: Double
- ): Double {
- if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) {
- return 0.0 // Invalid inputs
- }
-
- val totalVerticalDistance = depthMeters + freeboardMeters
-
- // Ensure we don't take asin of a value > 1 or < -1
- if (totalVerticalDistance > rodeOutMeters) {
- // Rode is too short for the depth+freeboard, effectively boat is directly above anchor
- // In this case, the watch circle radius is 0, or very small.
- return 0.0
- }
-
- // angle = asin( (Depth + Freeboard) / Rode Out )
- val angle = asin(totalVerticalDistance / rodeOutMeters)
-
- // Watch circle radius = Rode Out * cos(angle)
- return rodeOutMeters * cos(angle)
- }
- }
-
- 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/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 138fc6c..b18db8d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -20,12 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.asStateFlow
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 org.terst.nav.data.model.SensorData
+import org.terst.nav.safety.AnchorWatchState
+import org.terst.nav.wind.TrueWindCalculator
+import org.terst.nav.wind.ApparentWind
+import org.terst.nav.wind.TrueWindData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.CoroutineScope
@@ -34,22 +40,23 @@ 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
- }
-}
-
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the navigation subsystem.
+ */
class LocationService : Service() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
@@ -60,22 +67,30 @@ class LocationService : Service() {
private lateinit var nmeaStreamManager: NmeaStreamManager
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val windCalculator = TrueWindCalculator()
+
+ // GPS sensor fusion state
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+ private val nmeaStalenessThresholdMs: Long = 5_000L
+ private val nmeaExtendedThresholdMs: Long = 10_000L
+
private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
private val NOTIFICATION_ID = 123
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
+ private var isAlarmTriggered = false
override fun onCreate() {
super.onCreate()
Log.d("LocationService", "Service created")
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
- anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ anchorAlarmManager = AnchorAlarmManager(this)
barometerSensorManager = BarometerSensorManager(this)
nmeaParser = NmeaParser()
nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
createNotificationChannel()
- // Observe barometer status and update our public state
+ // Observe barometer status
serviceScope.launch {
barometerSensorManager.barometerStatus.collect { status ->
_barometerStatus.value = status
@@ -84,14 +99,17 @@ class LocationService : Service() {
// Collect NMEA GPS positions
serviceScope.launch {
- nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
- _nmeaGpsPositionFlow.emit(gpsPosition)
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { position ->
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ _nmeaGpsPositionFlow.emit(position)
}
}
// Collect NMEA Wind Data
serviceScope.launch {
nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ updateTrueWindFromNmea(windData)
_nmeaWindDataFlow.emit(windData)
}
}
@@ -110,26 +128,22 @@ class LocationService : Service() {
}
}
- // Mock tidal current data generator
- serviceScope.launch {
- while (true) {
- val currents = MockTidalCurrentGenerator.generateMockCurrents()
- _tidalCurrentState.update { it.copy(currents = currents) }
- kotlinx.coroutines.delay(60000) // Update every minute
- }
- }
-
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
+ val position = GpsPosition(
latitude = location.latitude,
longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
+ sog = location.speed * 1.94384, // m/s to knots
+ cog = location.bearing.toDouble(),
+ timestampMs = location.time,
+ accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null
)
+ lastAndroidPosition = position
+ recomputeBestPosition()
+
serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
+ _locationFlow.emit(position)
}
// Check for anchor drag if anchor watch is active
@@ -139,32 +153,71 @@ class LocationService : Service() {
}
}
- /**
- * Checks if the current location is outside the anchor watch circle.
- */
+ private fun recomputeBestPosition() {
+ val now = System.currentTimeMillis()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ private fun updateTrueWindFromNmea(wind: WindData) {
+ val sog = _bestPosition.value?.sog
+ val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue
+
+ if (sog != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle),
+ bsp = sog, // Use SOG as proxy for BSP if BSP is not available
+ hdgDeg = hdg
+ )
+ }
+ }
+
private fun checkAnchorDrag(location: Location) {
_anchorWatchState.update { currentState ->
if (currentState.isActive && currentState.anchorLocation != null) {
val isDragging = currentState.isDragging(location)
if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!")
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
- }
+ } else if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
}
currentState
}
@@ -173,24 +226,21 @@ class LocationService : Service() {
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
+ _currentPowerMode.emit(PowerMode.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)
@@ -198,45 +248,34 @@ class LocationService : Service() {
}
}
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
+ setPowerMode(PowerMode.FULL)
}
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 onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
- Log.d("LocationService", "Service destroyed")
stopLocationUpdatesInternal()
anchorAlarmManager.stopAlarm()
barometerSensorManager.stop()
- nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
+ nmeaStreamManager.stop()
_anchorWatchState.value = AnchorWatchState(isActive = false)
- isAlarmTriggered = false // Reset alarm trigger state
- serviceScope.cancel() // Cancel the coroutine scope
+ isAlarmTriggered = false
+ serviceScope.cancel()
}
-
@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
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
@@ -246,22 +285,15 @@ class LocationService : Service() {
}
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.")
}
}
}
@@ -278,25 +310,15 @@ class LocationService : Service() {
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_IMMUTABLE
- )
-
+ 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...")
+ .setContentText("Tracking your location...")
.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()
@@ -307,29 +329,27 @@ class LocationService : Service() {
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.")
+ }
+
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = null, // TODO: Pull from latest forecast
+ currentDirectionDeg = null
+ )
}
companion object {
@@ -338,56 +358,42 @@ class LocationService : 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 const val NMEA_GATEWAY_IP = "192.168.1.1"
+ private const val NMEA_GATEWAY_PORT = 10110
+
+ private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState
+
private val _barometerStatus = MutableStateFlow(BarometerStatus())
+ val barometerStatus: StateFlow<BarometerStatus> get() = _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 _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow
+
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1)
+ val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow
+
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1)
+ val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow
+
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1)
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
- val currentPowerMode: StateFlow<PowerMode>
- get() = _currentPowerMode
+ 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
index 3f09309..fd2cf61 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -39,6 +39,7 @@ import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.safety.SafetyFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
+import org.terst.nav.safety.AnchorWatchState
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
@@ -46,7 +47,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var mobHandler: MobHandler? = null
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
- private var anchorWatchHandler: AnchorWatchHandler? = null
private val loadedStyleFlow = MutableStateFlow<Style?>(null)
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
@@ -186,7 +186,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onConfigureAnchor() {
- anchorWatchHandler?.toggleVisibility()
+ // Now handled via fragment navigation from SafetyFragment
}
private fun setupHandlers() {
@@ -305,13 +305,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
- mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.courseOverGround)
- val sogKnots = gpsData.speedOverGround * 1.94384
- val cogDeg = gpsData.courseOverGround
- viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, cogDeg.toDouble())
+ mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat())
+
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog)
instrumentHandler?.updateDisplay(
- sog = "%.1f".format(Locale.getDefault(), sogKnots),
- cog = "%.0f°".format(Locale.getDefault(), cogDeg)
+ sog = "%.1f".format(Locale.getDefault(), gpsData.sog),
+ cog = "%.0f°".format(Locale.getDefault(), gpsData.cog)
)
if (!conditionsLoaded) {
conditionsLoaded = true
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
new file mode 100644
index 0000000..fc1d79d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package org.terst.nav.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
index e17e5ca..6a976f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion
import java.time.Instant
interface GribFileManager {
- /** Save metadata for a newly-downloaded GRIB file. */
fun saveMetadata(file: GribFile)
- /** Return all stored GRIB files for [region], newest first. */
fun listFiles(region: GribRegion): List<GribFile>
- /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
fun latestFile(region: GribRegion): GribFile?
- /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
fun delete(file: GribFile): Boolean
- /** Delete all GRIB files older than [before]. Returns count of deleted files. */
fun purgeOlderThan(before: Instant): Int
- /** Total size in bytes of all stored GRIB files. */
fun totalSizeBytes(): Long
}
class InMemoryGribFileManager : GribFileManager {
private val files = mutableListOf<GribFile>()
-
override fun saveMetadata(file: GribFile) { files.add(file) }
-
- override fun listFiles(region: GribRegion): List<GribFile> =
- files.filter { it.region.name == region.name }
- .sortedByDescending { it.downloadedAt }
-
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
-
override fun delete(file: GribFile): Boolean = files.remove(file)
-
- override fun purgeOlderThan(before: Instant): Int {
- val toRemove = files.filter { it.downloadedAt.isBefore(before) }
- files.removeAll(toRemove)
- return toRemove.size
- }
-
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..f39957b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..875d971
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import org.terst.nav.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
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
index 5faf30c..99cef2d 100644
--- 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
@@ -1,9 +1,20 @@
package org.terst.nav.gps
+/**
+ * Represents a single GPS fix.
+ *
+ * @param latitude Degrees, positive = North, negative = South.
+ * @param longitude Degrees, positive = East, negative = West.
+ * @param sog Speed Over Ground in knots.
+ * @param cog Course Over Ground in degrees true (0-360).
+ * @param timestampMs Unix epoch milliseconds UTC.
+ * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown.
+ */
data class GpsPosition(
val latitude: Double,
val longitude: Double,
- val sog: Double, // knots
- val cog: Double, // degrees true
- val timestampMs: Long
+ val sog: Double,
+ val cog: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Double? = null
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..67cfcce
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package org.terst.nav.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..6417db9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package org.terst.nav.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
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
index 453c758..6a470b8 100644
--- 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
@@ -273,8 +273,9 @@ class NmeaParser {
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
+ val millis = if (timeStr.contains('.')) {
+ val fracStr = timeStr.substringAfter('.')
+ ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
} else 0
cal.set(Calendar.HOUR_OF_DAY, hours)
cal.set(Calendar.MINUTE, minutes)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
new file mode 100644
index 0000000..13fb132
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..8ac73cf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package org.terst.nav.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
new file mode 100644
index 0000000..a6562d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package org.terst.nav.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..9121ce6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.safety
+
+import android.location.Location
+import kotlin.math.*
+
+/**
+ * Holds state for the anchor watch and provides the suggested watch-circle radius.
+ */
+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
+
+ /**
+ * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
+ */
+ fun calculateRecommendedWatchCircleRadius(
+ depthMeters: Double,
+ freeboardMeters: Double,
+ rodeOutMeters: Double
+ ): Double {
+ if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0
+ val totalVerticalDistance = depthMeters + freeboardMeters
+ if (totalVerticalDistance > rodeOutMeters) return 0.0
+ val angle = asin(totalVerticalDistance / rodeOutMeters)
+ return rodeOutMeters * cos(angle)
+ }
+ }
+
+ fun isDragging(currentLocation: Location): Boolean {
+ anchorLocation ?: return false
+ if (!isActive) return false
+ val distance = anchorLocation.distanceTo(currentLocation)
+ return distance > watchCircleRadiusMeters
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
new file mode 100644
index 0000000..b1e5652
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
@@ -0,0 +1,88 @@
+package org.terst.nav.tide
+
+import com.example.androidapp.data.model.TidePrediction
+import com.example.androidapp.data.model.TideStation
+import kotlin.math.cos
+
+/**
+ * Computes harmonic tide predictions using the standard formula:
+ * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
+ *
+ * where:
+ * Z0 = datum offset (mean water level above chart datum, metres)
+ * Hi = amplitude of constituent i (metres)
+ * ωi = angular speed of constituent i (degrees / hour)
+ * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC)
+ * φi = phase lag (degrees)
+ */
+object HarmonicTideCalculator {
+
+ /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */
+ internal const val EPOCH_MS = 946_684_800_000L
+
+ /**
+ * Predict the tide height at a single moment.
+ *
+ * @param station Tide station with harmonic constituents.
+ * @param timestampMs Unix epoch milliseconds for the desired time.
+ * @return Predicted height in metres above chart datum.
+ */
+ fun predictHeight(station: TideStation, timestampMs: Long): Double {
+ val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0
+ var height = station.datumOffsetMeters
+ for (c in station.constituents) {
+ val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg
+ height += c.amplitudeMeters * cos(Math.toRadians(angleDeg))
+ }
+ return height
+ }
+
+ /**
+ * Predict tide heights over a time range at regular intervals.
+ *
+ * @param station Tide station.
+ * @param fromMs Start of range (Unix milliseconds, inclusive).
+ * @param toMs End of range (Unix milliseconds, inclusive).
+ * @param intervalMs Time step in milliseconds (must be positive).
+ * @return List of [TidePrediction] ordered by ascending timestamp.
+ */
+ fun predictRange(
+ station: TideStation,
+ fromMs: Long,
+ toMs: Long,
+ intervalMs: Long
+ ): List<TidePrediction> {
+ require(intervalMs > 0) { "intervalMs must be positive" }
+ require(fromMs <= toMs) { "fromMs must not exceed toMs" }
+ val predictions = mutableListOf<TidePrediction>()
+ var t = fromMs
+ while (t <= toMs) {
+ predictions += TidePrediction(t, predictHeight(station, t))
+ t += intervalMs
+ }
+ return predictions
+ }
+
+ /**
+ * Find high and low water events from a pre-computed prediction series.
+ *
+ * Detects local maxima (high water) and minima (low water) by comparing
+ * each interior sample with its immediate neighbours.
+ *
+ * @param predictions Ordered list of tide predictions (at least 3 points).
+ * @return Subset list containing only high/low turning points.
+ */
+ fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> {
+ if (predictions.size < 3) return emptyList()
+ val result = mutableListOf<TidePrediction>()
+ for (i in 1 until predictions.size - 1) {
+ val prev = predictions[i - 1].heightMeters
+ val curr = predictions[i].heightMeters
+ val next = predictions[i + 1].heightMeters
+ val isMax = curr >= prev && curr >= next
+ val isMin = curr <= prev && curr <= next
+ if (isMax || isMin) result += predictions[i]
+ }
+ return result
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
deleted file mode 100644
index d55de90..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.terst.nav.ui
-
-import android.content.Context
-import android.content.Intent
-import android.view.View
-import android.widget.Button
-import android.widget.TextView
-import android.widget.Toast
-import androidx.constraintlayout.widget.ConstraintLayout
-import org.terst.nav.AnchorWatchState
-import org.terst.nav.LocationService
-import java.util.Locale
-
-/**
- * Handles the Anchor Watch UI interactions and state updates.
- */
-class AnchorWatchHandler(
- private val context: Context,
- private val container: ConstraintLayout,
- private val statusText: TextView,
- private val radiusText: TextView,
- private val buttonDecrease: Button,
- private val buttonIncrease: Button,
- private val buttonSet: Button,
- private val buttonStop: Button
-) {
- private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
-
- init {
- updateRadiusDisplay()
-
- buttonDecrease.setOnClickListener {
- updateRadius((currentRadius - 5).coerceAtLeast(10.0))
- }
-
- buttonIncrease.setOnClickListener {
- updateRadius((currentRadius + 5).coerceAtMost(200.0))
- }
-
- buttonSet.setOnClickListener {
- startWatch()
- }
-
- buttonStop.setOnClickListener {
- stopWatch()
- }
- }
-
- private fun updateRadius(newRadius: Double) {
- currentRadius = newRadius
- updateRadiusDisplay()
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- }
-
- private fun updateRadiusDisplay() {
- radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius)
- }
-
- private fun startWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_START_ANCHOR_WATCH
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
-
- private fun stopWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_ANCHOR_WATCH
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
- }
-
- /**
- * Updates the UI based on the current anchor watch state.
- */
- fun updateUI(state: AnchorWatchState) {
- statusText.text = if (state.isActive) {
- "STATUS: ACTIVE" // Simple status for UI
- } else {
- "STATUS: INACTIVE"
- }
- currentRadius = state.watchCircleRadiusMeters
- updateRadiusDisplay()
- }
-
- /**
- * Toggles the visibility of the anchor configuration container.
- */
- fun toggleVisibility() {
- container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE
- }
-}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 4f08de7..bfefb6f 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -19,7 +19,7 @@ import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
-import org.terst.nav.AnchorWatchState
+import org.terst.nav.safety.AnchorWatchState
import org.terst.nav.TidalCurrentState
import org.terst.nav.track.TrackPoint
import kotlin.math.cos
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..d435f00
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package org.terst.nav.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import org.terst.nav.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
new file mode 100644
index 0000000..fd504cb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..dc3117c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
new file mode 100644
index 0000000..8c3ac56
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)