summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-09 09:39:20 +0000
committerClaude <noreply@anthropic.com>2026-04-09 09:39:20 +0000
commite109271f563327e461191c7c2988243c75cd58ab (patch)
treee8401f41bb7f96421175d242bee77245eb68c738 /android-app/app/src/main
parent3cddcd3b0a8719da5e828734a1d3054cef803235 (diff)
parent5ee2dd8925afa858f466ae63db3f7df5c7516953 (diff)
merge: reconcile master particle-wind work with origin/main redesign
Merged divergent branches (common ancestor a9d87b6). Took origin/main as base (layer manager, recenter, quit button, GPX tracks, marine conditions, dark theme, user position layer, active/past track layers) and layered in particle wind simulation work: - MapHandler: added setupWindLayer, updateWindLayer, updateWindGridLayer; kept their user-pos layer, follow/recenter state, active+past tracks - WeatherRepository: kept their fetchCurrentConditions, added fetchWindGrid - MainViewModel: kept their track/marine/summary changes, added windGrid StateFlow, loadWindGrid, buildGrid - MainActivity: kept their full UI (layer picker, quit, recenter, fade animations), added particleWindView wiring, weatherLoaded guard, windArrow/windGrid observers - activity_main.xml: took their LinearLayout redesign, added ParticleWindView overlay inside ConstraintLayout https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/AndroidManifest.xml3
-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.kt309
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt3
-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/repository/WeatherRepository.kt26
-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/track/GpxParser.kt96
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt153
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt95
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt43
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt102
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt66
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt51
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt86
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt117
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt54
-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/DirectionArrowView.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt183
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt48
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt231
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt132
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt158
-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/ui/safety/SafetyFragment.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt11
-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
-rw-r--r--android-app/app/src/main/res/drawable/ic_layers.xml9
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml174
-rw-r--r--android-app/app/src/main/res/layout/fragment_pretrip_report.xml144
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml9
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml114
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml14
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml375
-rw-r--r--android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml104
-rw-r--r--android-app/app/src/main/res/layout/layout_track_summary_sheet.xml199
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml6
-rwxr-xr-xandroid-app/app/src/main/res/values/colors.xml26
-rwxr-xr-xandroid-app/app/src/main/res/values/dimens.xml10
-rwxr-xr-xandroid-app/app/src/main/res/values/themes.xml72
63 files changed, 4166 insertions, 718 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index e2e311d..2f23e87 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <!-- Documents/ write access — not needed on API 29+ (MediaStore handles it) -->
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28" />
<application
android:name=".NavApplication"
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 7046b23..f84e5fb 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
@@ -8,39 +8,35 @@ import android.graphics.Canvas
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
-import android.util.Log
+import android.view.HapticFeedbackConstants
import android.view.View
import android.widget.FrameLayout
-import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.button.MaterialButton
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
-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 org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
-import org.maplibre.android.style.layers.RasterLayer
-import org.maplibre.android.style.sources.RasterSource
-import org.maplibre.android.style.sources.TileSet
import org.terst.nav.ui.*
import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.map.ParticleWindView
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 {
@@ -48,15 +44,19 @@ 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 var weatherLoaded = false
- private var mapLibreMapRef: org.maplibre.android.maps.MapLibreMap? = null
+ private lateinit var layerManager: MapLayerManager
private var particleWindView: ParticleWindView? = null
+ private var weatherLoaded = false
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
private lateinit var fabRecordTrack: FloatingActionButton
+ private lateinit var fabMob: FloatingActionButton
+ private lateinit var fabRecenter: MaterialButton
+ private lateinit var btnQuit: MaterialButton
+ private lateinit var bottomSheet: CardView
+ private lateinit var bottomNav: BottomNavigationView
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -64,7 +64,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onResume() {
super.onResume()
- mapView?.onResume()
+ if (!NavApplication.isTesting) mapView?.onResume()
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
@@ -82,20 +82,37 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun initializeUI() {
fragmentContainer = findViewById(R.id.fragment_container)
- setupMap()
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabMob = findViewById(R.id.fab_mob)
+ fabRecenter = findViewById(R.id.fab_recenter)
+ btnQuit = findViewById(R.id.btn_quit)
+ bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+ bottomNav = findViewById(R.id.bottom_navigation)
+
setupBottomSheet()
setupBottomNavigation()
setupHandlers()
-
- findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
- onActivateMob()
- }
+ setupMap()
- fabRecordTrack = findViewById(R.id.fab_record_track)
+ // Single tap starts; long press stops (requires deliberate intent mid-sail)
fabRecordTrack.setOnClickListener {
- if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
+ if (!viewModel.isRecording.value) viewModel.startTrack()
}
- // Observe immediately — pure UI state, not gated on GPS permission
+ fabRecordTrack.setOnLongClickListener {
+ if (viewModel.isRecording.value) {
+ it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ viewModel.stopTrack()
+ true
+ } else false
+ }
+
+ fabMob.setOnClickListener { onActivateMob() }
+
+ fabRecenter.setOnClickListener {
+ mapHandler?.recenter()
+ }
+
+ btnQuit.setOnClickListener { onQuitRequested() }
lifecycleScope.launch {
viewModel.isRecording.collect { recording ->
val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
@@ -103,17 +120,22 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
}
}
+ lifecycleScope.launch {
+ viewModel.trackSummary.collect { summary ->
+ org.terst.nav.track.TrackSummarySheet
+ .from(summary, viewModel.trackStartMs)
+ .show(supportFragmentManager, "track_summary")
+ }
+ }
}
private fun setupBottomSheet() {
- val sheet = findViewById<View>(R.id.instrument_bottom_sheet)
- bottomSheetBehavior = BottomSheetBehavior.from(sheet)
+ bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
private fun setupBottomNavigation() {
- val nav = findViewById<BottomNavigationView>(R.id.bottom_navigation)
- nav.setOnItemSelectedListener { item ->
+ bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_map -> {
hideOverlays()
@@ -122,10 +144,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
true
}
- R.id.nav_instruments -> {
- hideOverlays()
- bottomSheetBehavior.isHideable = false
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
+ R.id.nav_layers -> {
+ val currentStyle = loadedStyleFlow.value
+ if (currentStyle != null) {
+ LayerPickerSheet(
+ manager = layerManager,
+ onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) },
+ onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) }
+ ).show(supportFragmentManager, "layer_picker")
+ }
+ bottomNav.post { bottomNav.selectedItemId = R.id.nav_map }
true
}
R.id.nav_log -> {
@@ -156,6 +184,29 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fragmentContainer.visibility = View.GONE
}
+ private fun showReport(fragment: androidx.fragment.app.Fragment) {
+ bottomSheetBehavior.isHideable = true
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+ showOverlay(fragment)
+ }
+
+ private fun onQuitRequested() {
+ if (viewModel.isRecording.value) {
+ androidx.appcompat.app.AlertDialog.Builder(this)
+ .setMessage("Recording in progress. Quit and discard the current track?")
+ .setPositiveButton("Quit") { _, _ -> exitApp() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ } else {
+ exitApp()
+ }
+ }
+
+ private fun exitApp() {
+ stopService(Intent(this, LocationService::class.java))
+ finishAffinity()
+ }
+
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
@@ -166,38 +217,45 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onConfigureAnchor() {
- anchorWatchHandler?.toggleVisibility()
+ showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler())
}
private fun setupHandlers() {
instrumentHandler = InstrumentHandler(
- valueAws = findViewById(R.id.value_aws),
- valueTws = findViewById(R.id.value_tws),
- valueHdg = findViewById(R.id.value_hdg),
- valueCog = findViewById(R.id.value_cog),
- valueBsp = findViewById(R.id.value_bsp),
- valueSog = findViewById(R.id.value_sog),
- valueVmg = findViewById(R.id.value_vmg),
+ 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),
valueDepth = findViewById(R.id.value_depth),
- valuePolarPct = findViewById(R.id.value_polar_pct),
- valueBaro = findViewById(R.id.value_baro),
- labelTrend = null, // simplified
- barometerTrendView = null, // simplified
- polarDiagramView = findViewById(R.id.polar_diagram_view)
+ valueBaro = findViewById(R.id.value_baro),
+ arrowAws = findViewById(R.id.arrow_aws),
+ arrowTws = findViewById(R.id.arrow_tws),
+ arrowHdg = findViewById(R.id.arrow_hdg),
+ arrowCog = findViewById(R.id.arrow_cog),
+ valueCurrSpd = findViewById(R.id.value_curr_spd),
+ valueWaveHt = findViewById(R.id.value_wave_ht),
+ valueSwellHt = findViewById(R.id.value_swell_ht),
+ valueSwellPer = findViewById(R.id.value_swell_per),
+ arrowCurr = findViewById(R.id.arrow_curr),
+ arrowWaves = findViewById(R.id.arrow_waves),
+ arrowSwell = findViewById(R.id.arrow_swell),
+ bearingCurr = findViewById(R.id.bearing_curr),
+ bearingWaves = findViewById(R.id.bearing_waves),
+ bearingSwell = findViewById(R.id.bearing_swell),
+ waveView = findViewById(R.id.wave_divider)
)
-
- // anchorWatchHandler is initialized when the anchor config UI is available
-
- val mockPolarTable = createMockPolarTable()
- findViewById<PolarDiagramView>(R.id.polar_diagram_view).setPolarTable(mockPolarTable)
- startInstrumentSimulation(mockPolarTable)
+ instrumentHandler?.updateDisplay(
+ aws = "—", tws = "—", hdg = "—",
+ cog = "—", bsp = "—", sog = "—",
+ baro = "—"
+ )
+ instrumentHandler?.updateConditions(currSpd = "—")
}
- // Helper to convert dp to px
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
- // ... (Keep existing permission and service logic)
-
private fun checkForegroundPermissions() {
val fineLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
val coarseLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -232,29 +290,40 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun setupMap() {
+ layerManager = MapLayerManager(this)
mapView = findViewById(R.id.mapView)
particleWindView = findViewById(R.id.particle_wind_view)
+ if (NavApplication.isTesting) return
+
mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
- mapLibreMapRef = maplibreMap
mapHandler = MapHandler(maplibreMap)
particleWindView?.attachMap(maplibreMap)
- 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 ->
+
+ lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack)
+ } else {
+ fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+ }
+
+ val styleBuilder = Style.Builder()
+ .fromUri("https://tiles.openfreemap.org/styles/bright")
+ layerManager.addToStyleBuilder(styleBuilder)
+
+ maplibreMap.setStyle(styleBuilder) { style ->
loadedStyleFlow.value = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
+ val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow)
val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow)
- mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap)
mapHandler?.setupWindLayer(style, windArrowBitmap)
- mapHandler?.setupWindGridLayer(style)
}
maplibreMap.addOnCameraIdleListener {
@@ -264,15 +333,35 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bounds.lonWest, bounds.lonEast
)
}
+
+ maplibreMap.addOnMapLongClickListener { _ ->
+ val currentStyle = loadedStyleFlow.value ?: return@addOnMapLongClickListener true
+ LayerPickerSheet(
+ manager = layerManager,
+ onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) },
+ onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) }
+ ).show(supportFragmentManager, "layer_picker")
+ true
+ }
}
}
private fun observeDataSources() {
+ var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
- val sogKnots = gpsData.speedOverGround * 1.94384
- viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.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(), gpsData.sog),
+ cog = "%.0f°".format(Locale.getDefault(), gpsData.cog),
+ cogBearingDeg = gpsData.cog.toFloat()
+ )
+ if (!conditionsLoaded) {
+ conditionsLoaded = true
+ viewModel.loadConditions(gpsData.latitude, gpsData.longitude)
+ }
if (!weatherLoaded) {
weatherLoaded = true
viewModel.loadWeather(gpsData.latitude, gpsData.longitude)
@@ -293,6 +382,41 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ LocationService.barometerStatus.collect { status ->
+ if (status.history.isNotEmpty()) {
+ instrumentHandler?.updateDisplay(baro = status.formatPressure())
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.marineConditions.collect { c ->
+ if (c == null) return@collect
+ instrumentHandler?.updateDisplay(
+ tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) },
+ twsBearingDeg = c.windDirDeg?.toFloat()
+ )
+ instrumentHandler?.updateConditions(
+ currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—",
+ currDirDeg = c.currentDirDeg?.toFloat(),
+ waveHeightM = c.waveHeightM,
+ waveDirDeg = c.waveDirDeg?.toFloat(),
+ swellHeightM = c.swellHeightM,
+ swellDirDeg = c.swellDirDeg?.toFloat(),
+ swellPeriodS = c.swellPeriodS
+ )
+ val swellHtFt = c.swellHeightM?.let { (it * 3.28084f).toFloat() } ?: 3f
+ val windHtFt = c.waveHeightM?.let { (it * 3.28084f).toFloat() } ?: 1.5f
+ val swellPeriod = c.swellPeriodS?.toFloat() ?: 10f
+ val windKt = c.windSpeedKt?.toFloat() ?: 0f
+ instrumentHandler?.updateWaveState(swellHtFt, swellPeriod, windHtFt, windKt)
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.nmeaDepthDataFlow.collect { depthData ->
+ instrumentHandler?.updateDisplay(depthM = depthData.depthMeters)
+ }
+ }
+ lifecycleScope.launch {
LocationService.anchorWatchState.collect { state ->
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
@@ -300,29 +424,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
loadedStyleFlow.filterNotNull()
.combine(viewModel.trackPoints) { style, points -> style to points }
- .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) }
- }
- }
-
- private fun startInstrumentSimulation(polarTable: PolarTable) {
- lifecycleScope.launch {
- var simulatedTws = 8.0
- var simulatedTwa = 40.0
- while (true) {
- val bsp = polarTable.interpolateBsp(simulatedTws, simulatedTwa)
- instrumentHandler?.updateDisplay(
- aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1),
- tws = "%.1f".format(Locale.getDefault(), simulatedTws),
- bsp = "%.1f".format(Locale.getDefault(), bsp),
- sog = "%.1f".format(Locale.getDefault(), bsp * 0.95),
- vmg = "%.1f".format(Locale.getDefault(), polarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, bsp) ?: 0.0),
- polarPct = "%.0f%%".format(Locale.getDefault(), polarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, bsp)),
- baro = "1013.2"
- )
- instrumentHandler?.updatePolarDiagram(simulatedTws, simulatedTwa, bsp)
- simulatedTwa = (simulatedTwa + 0.5).let { if (it > 170) 40.0 else it }
- delay(1000)
- }
+ .combine(viewModel.pastTracks) { (style, active), past -> Triple(style, active, past) }
+ .collect { (style, active, past) -> mapHandler?.updateTrackLayer(style, active, past) }
}
}
@@ -335,18 +438,26 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
return bitmap
}
- private fun createMockPolarTable(): PolarTable {
- val curves = listOf(6.0, 8.0, 10.0).map { tws ->
- PolarCurve(tws, listOf(30.0, 45.0, 60.0, 90.0, 120.0, 150.0, 180.0).map { twa ->
- PolarPoint(twa, tws * (0.4 + twa / 200.0))
- })
+ private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+ }
+
+ private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ if (v.visibility == View.VISIBLE && v.alpha == 1f) return@forEach
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
}
- return PolarTable(curves)
}
- override fun onStart() { super.onStart(); mapView?.onStart() }
- override fun onPause() { super.onPause(); mapView?.onPause() }
- override fun onStop() { super.onStop(); mapView?.onStop() }
- override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
- override fun onLowMemory() { super.onLowMemory(); mapView?.onLowMemory() }
+ override fun onStart() { super.onStart(); if (!NavApplication.isTesting) mapView?.onStart() }
+ override fun onPause() { super.onPause(); if (!NavApplication.isTesting) mapView?.onPause() }
+ override fun onStop() { super.onStop(); if (!NavApplication.isTesting) mapView?.onStop() }
+ override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy() }
+ override fun onLowMemory() { super.onLowMemory(); if (!NavApplication.isTesting) mapView?.onLowMemory() }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0b507d2..7c43dd5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -12,11 +12,23 @@ import java.util.Locale
class NavApplication : Application() {
companion object {
+ val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ lateinit var trackRepository: org.terst.nav.track.TrackRepository
var isTesting: Boolean = false
+ get() {
+ if (field) return true
+ return try {
+ Class.forName("androidx.test.espresso.Espresso")
+ true
+ } catch (e: ClassNotFoundException) {
+ false
+ }
+ }
}
override fun onCreate() {
super.onCreate()
+ trackRepository = org.terst.nav.track.TrackRepository(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
index 67c14f8..5a7d2e2 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
@@ -11,7 +11,7 @@ interface MarineApiService {
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("hourly") hourly: String =
- "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction",
+ "wave_height,wave_direction,swell_wave_height,swell_wave_direction,swell_wave_period,ocean_current_velocity,ocean_current_direction",
@Query("forecast_days") forecastDays: Int = 7
): MarineResponse
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt
new file mode 100644
index 0000000..3cde023
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt
@@ -0,0 +1,17 @@
+package org.terst.nav.data.model
+
+/**
+ * Snapshot of current marine conditions derived from the first forecast hour.
+ * All fields are nullable — null means the model returned no data for that parameter.
+ */
+data class MarineConditions(
+ val windSpeedKt: Double?,
+ val windDirDeg: Double?,
+ val waveHeightM: Double?,
+ val waveDirDeg: Double?,
+ val swellHeightM: Double?,
+ val swellDirDeg: Double?,
+ val swellPeriodS: Double?,
+ val currentSpeedKt: Double?, // ocean_current_velocity converted from m/s to knots
+ val currentDirDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
index ab9799b..cf5216d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
@@ -15,6 +15,9 @@ data class MarineHourly(
@Json(name = "time") val time: List<String>,
@Json(name = "wave_height") val waveHeight: List<Double?>,
@Json(name = "wave_direction") val waveDirection: List<Double?>,
+ @Json(name = "swell_wave_height") val swellWaveHeight: List<Double?>,
+ @Json(name = "swell_wave_direction") val swellWaveDirection: List<Double?>,
+ @Json(name = "swell_wave_period") val swellWavePeriod: List<Double?>,
@Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List<Double?>,
@Json(name = "ocean_current_direction") val oceanCurrentDirection: List<Double?>
)
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/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
index 3459d7b..c79366d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
@@ -3,6 +3,7 @@ package org.terst.nav.data.repository
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
class WeatherRepository(
@@ -12,8 +13,6 @@ class WeatherRepository(
/**
* Fetch 7-day hourly forecast items for the given position.
- * Both weather and marine data are requested; only weather fields are needed for ForecastItem,
- * but marine is fetched here to prime the cache for wind-arrow use.
*/
suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> =
runCatching {
@@ -48,6 +47,29 @@ class WeatherRepository(
}
/**
+ * Fetches current marine conditions (first forecast hour) for [lat]/[lon].
+ * Ocean current velocity is converted from m/s to knots.
+ */
+ suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result<MarineConditions> =
+ runCatching {
+ val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1)
+ val marine = marineApi.getMarineForecast(lat, lon)
+ val w = weather.hourly
+ val m = marine.hourly
+ MarineConditions(
+ windSpeedKt = w.windspeed10m.firstOrNull(),
+ windDirDeg = w.winddirection10m.firstOrNull(),
+ waveHeightM = m.waveHeight.firstOrNull(),
+ waveDirDeg = m.waveDirection.firstOrNull(),
+ swellHeightM = m.swellWaveHeight.firstOrNull(),
+ swellDirDeg = m.swellWaveDirection.firstOrNull(),
+ swellPeriodS = m.swellWavePeriod.firstOrNull(),
+ currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 },
+ currentDirDeg = m.oceanCurrentDirection.firstOrNull()
+ )
+ }
+
+ /**
* Fetch a current wind arrow for each point in [points] using Open-Meteo's batch endpoint.
* Points are split into chunks of 10 (API limit per call).
*/
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/track/GpxParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
new file mode 100644
index 0000000..3287f1d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
@@ -0,0 +1,96 @@
+package org.terst.nav.track
+
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.time.Instant
+
+/**
+ * Parses a GPX 1.1 file produced by [GpxSerializer] back into a list of [TrackPoint]s.
+ *
+ * Uses XmlPullParser (built into Android, also available on JVM via kxml2) so
+ * no additional XML dependencies are required.
+ */
+object GpxParser {
+
+ fun parse(input: InputStream): List<TrackPoint> {
+ val points = mutableListOf<TrackPoint>()
+ val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware = true }
+ val xpp = factory.newPullParser()
+ xpp.setInput(input, "UTF-8")
+
+ var lat = 0.0; var lon = 0.0; var timeMs = 0L
+ var sog = 0.0; var cog = 0.0
+ var hdg: Double? = null; var bsp: Double? = null
+ var depth: Double? = null; var baro: Double? = null
+ var windSpd: Double? = null; var windAng: Double? = null
+ var trueWind = false; var airTemp: Double? = null
+ var waveHt: Double? = null; var currSpd: Double? = null; var currDir: Double? = null
+ var inExtensions = false; var currentTag = ""
+
+ var event = xpp.eventType
+ while (event != XmlPullParser.END_DOCUMENT) {
+ when (event) {
+ XmlPullParser.START_TAG -> {
+ currentTag = xpp.name ?: ""
+ when (currentTag) {
+ "trkpt" -> {
+ lat = xpp.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: 0.0
+ lon = xpp.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: 0.0
+ // reset fields for this point
+ timeMs = 0L; sog = 0.0; cog = 0.0
+ hdg = null; bsp = null; depth = null; baro = null
+ windSpd = null; windAng = null; trueWind = false
+ airTemp = null; waveHt = null; currSpd = null; currDir = null
+ }
+ "extensions" -> inExtensions = true
+ }
+ }
+ XmlPullParser.TEXT -> {
+ val text = xpp.text?.trim() ?: ""
+ if (text.isEmpty()) { event = xpp.next(); continue }
+ when {
+ currentTag == "time" -> timeMs = runCatching {
+ Instant.parse(text).toEpochMilli()
+ }.getOrDefault(0L)
+ inExtensions -> when (currentTag) {
+ "sog" -> sog = text.toDoubleOrNull() ?: sog
+ "cog" -> cog = text.toDoubleOrNull() ?: cog
+ "hdg" -> hdg = text.toDoubleOrNull()
+ "bsp" -> bsp = text.toDoubleOrNull()
+ "depth" -> depth = text.toDoubleOrNull()
+ "baro" -> baro = text.toDoubleOrNull()
+ "windSpd" -> windSpd = text.toDoubleOrNull()
+ "windAng" -> windAng = text.toDoubleOrNull()
+ "trueWind" -> trueWind = text == "true"
+ "airTemp" -> airTemp = text.toDoubleOrNull()
+ "waveHt" -> waveHt = text.toDoubleOrNull()
+ "currSpd" -> currSpd = text.toDoubleOrNull()
+ "currDir" -> currDir = text.toDoubleOrNull()
+ }
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ val tag = xpp.name ?: ""
+ when (tag) {
+ "trkpt" -> points.add(TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sog, cogDeg = cog,
+ headingDeg = hdg, waterSpeedKnots = bsp,
+ depthMeters = depth, baroHpa = baro,
+ windSpeedKnots = windSpd, windAngleDeg = windAng,
+ isTrueWind = trueWind, airTempC = airTemp,
+ waveHeightM = waveHt, currentSpeedKts = currSpd,
+ currentDirDeg = currDir,
+ timestampMs = if (timeMs > 0) timeMs else System.currentTimeMillis()
+ ))
+ "extensions" -> inExtensions = false
+ }
+ currentTag = ""
+ }
+ }
+ event = xpp.next()
+ }
+ return points
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
new file mode 100644
index 0000000..e4b9448
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
@@ -0,0 +1,62 @@
+package org.terst.nav.track
+
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+/**
+ * Serializes a list of [TrackPoint]s to a GPX 1.1 XML string.
+ *
+ * Nav-specific fields (SOG, COG, depth, baro, wind) are stored in a
+ * `<extensions>` block under the `nav:` namespace so the file remains
+ * valid GPX while preserving full fidelity for round-trip reload.
+ */
+object GpxSerializer {
+
+ private val ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
+ .withZone(ZoneOffset.UTC)
+
+ fun serialize(points: List<TrackPoint>, trackName: String): String = buildString {
+ appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
+ appendLine(
+ """<gpx version="1.1" creator="Nav" """ +
+ """xmlns="http://www.topografix.com/GPX/1/1" """ +
+ """xmlns:nav="https://terst.org/nav/gpx/1">"""
+ )
+ appendLine(" <trk>")
+ appendLine(" <name>${escapeXml(trackName)}</name>")
+ appendLine(" <trkseg>")
+
+ for (pt in points) {
+ appendLine(""" <trkpt lat="${pt.lat}" lon="${pt.lon}">""")
+ appendLine(" <ele>0</ele>")
+ appendLine(" <time>${ISO8601.format(Instant.ofEpochMilli(pt.timestampMs))}</time>")
+ appendLine(" <extensions>")
+ appendLine(" <nav:sog>${pt.sogKnots}</nav:sog>")
+ appendLine(" <nav:cog>${pt.cogDeg}</nav:cog>")
+ pt.headingDeg?.let { appendLine(" <nav:hdg>$it</nav:hdg>") }
+ pt.waterSpeedKnots?.let { appendLine(" <nav:bsp>$it</nav:bsp>") }
+ pt.depthMeters?.let { appendLine(" <nav:depth>$it</nav:depth>") }
+ pt.baroHpa?.let { appendLine(" <nav:baro>$it</nav:baro>") }
+ pt.windSpeedKnots?.let { appendLine(" <nav:windSpd>$it</nav:windSpd>") }
+ pt.windAngleDeg?.let { appendLine(" <nav:windAng>$it</nav:windAng>") }
+ if (pt.isTrueWind) { appendLine(" <nav:trueWind>true</nav:trueWind>") }
+ pt.airTempC?.let { appendLine(" <nav:airTemp>$it</nav:airTemp>") }
+ pt.waveHeightM?.let { appendLine(" <nav:waveHt>$it</nav:waveHt>") }
+ pt.currentSpeedKts?.let { appendLine(" <nav:currSpd>$it</nav:currSpd>") }
+ pt.currentDirDeg?.let { appendLine(" <nav:currDir>$it</nav:currDir>") }
+ appendLine(" </extensions>")
+ appendLine(" </trkpt>")
+ }
+
+ appendLine(" </trkseg>")
+ appendLine(" </trk>")
+ append("</gpx>")
+ }
+
+ private fun escapeXml(s: String) = s
+ .replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;")
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
index d803c8c..ed38e5e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -5,8 +5,16 @@ data class TrackPoint(
val lon: Double,
val sogKnots: Double,
val cogDeg: Double,
- val windSpeedKnots: Double,
- val windAngleDeg: Double,
- val isTrueWind: Boolean,
- val timestampMs: Long
+ val headingDeg: Double? = null,
+ val waterSpeedKnots: Double? = null,
+ val depthMeters: Double? = null,
+ val baroHpa: Double? = null,
+ val windSpeedKnots: Double? = null,
+ val windAngleDeg: Double? = null,
+ val isTrueWind: Boolean = false,
+ val airTempC: Double? = null,
+ val waveHeightM: Double? = null,
+ val currentSpeedKts: Double? = null,
+ val currentDirDeg: Double? = null,
+ val timestampMs: Long = System.currentTimeMillis()
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index 7953822..7ef67af 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,26 +1,76 @@
package org.terst.nav.track
-class TrackRepository {
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class TrackRepository(context: Context) {
+
+ private val storage = TrackStorage(context)
var isRecording: Boolean = false
private set
- private val points = mutableListOf<TrackPoint>()
+ private val activePoints = mutableListOf<TrackPoint>()
+
+ /** Epoch-ms when the current (or last) track started. Exposed for the summary sheet title. */
+ var trackStartMs: Long = 0L
+ private set
+
+ // Loaded lazily from Documents/Nav/ on first access
+ private val _pastTracks = mutableListOf<List<TrackPoint>>()
+ private var pastTracksLoaded = false
fun startTrack() {
- points.clear()
+ activePoints.clear()
+ trackStartMs = System.currentTimeMillis()
isRecording = true
}
- fun stopTrack() {
+ /**
+ * Stops the active track, computes a [TrackSummary], persists the track
+ * to shared storage, and returns the summary. Returns null if the track
+ * was too short (< 2 minutes) or had no points.
+ */
+
+ suspend fun stopTrack(): TrackSummary? = withContext(Dispatchers.IO) {
+ if (!isRecording) return@withContext null
isRecording = false
+ val points = activePoints.toList()
+ activePoints.clear()
+ if (points.size < 2) return@withContext null
+
+ val summary = summarise(points)
+ // Discard tracks shorter than 2 minutes — likely accidental taps
+ if (summary.durationMs < 2 * 60_000L) return@withContext null
+
+ _pastTracks.add(0, points)
+ val saved = storage.saveTrack(points, trackStartMs)
+ if (!saved) Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart")
+ summary
}
fun addPoint(point: TrackPoint): Boolean {
if (!isRecording) return false
- points.add(point)
+ activePoints.add(point)
return true
}
- fun getPoints(): List<TrackPoint> = points.toList()
+ fun getPoints(): List<TrackPoint> = activePoints.toList()
+
+ /**
+ * Returns all completed tracks, loading from Documents/Nav/ on first call.
+ * Subsequent calls return the in-memory list (storage is source of truth
+ * only at startup).
+ */
+ suspend fun getPastTracks(): List<List<TrackPoint>> = withContext(Dispatchers.IO) {
+ if (!pastTracksLoaded) {
+ pastTracksLoaded = true
+ val stored = storage.loadAllTracks()
+ // Merge: put stored tracks behind any in-memory tracks from this session
+ _pastTracks.addAll(stored)
+ }
+ _pastTracks.toList()
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
new file mode 100644
index 0000000..08e1dc9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
@@ -0,0 +1,153 @@
+package org.terst.nav.track
+
+import android.content.ContentValues
+import android.content.Context
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Log
+import java.io.File
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+private const val TAG = "TrackStorage"
+
+/**
+ * Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
+ *
+ * Files written here survive app uninstall because they live in user-owned
+ * shared storage rather than app-private storage.
+ *
+ * API 29+: uses MediaStore (no permission required for Documents/).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
+ * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ */
+class TrackStorage(private val context: Context) {
+
+ private val fileTimestamp = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd_HH-mm-ss")
+ .withZone(ZoneOffset.UTC)
+
+ private val trackName = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd HH:mm")
+ .withZone(ZoneOffset.UTC)
+
+ /** Write a completed track to Documents/Nav/. Returns true on success. */
+ fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
+ if (points.isEmpty()) return false
+ val name = trackName.format(Instant.ofEpochMilli(startMs))
+ val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
+ val gpx = GpxSerializer.serialize(points, name)
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveViaMediaStore(fileName, gpx)
+ } else {
+ saveViaFile(fileName, gpx)
+ }
+ }
+
+ /** Load all tracks previously saved to Documents/Nav/. */
+ fun loadAllTracks(): List<List<TrackPoint>> {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ loadViaMediaStore()
+ } else {
+ loadViaFile()
+ }
+ }
+
+ // ── API 29+ ──────────────────────────────────────────────────────────────
+
+ private fun saveViaMediaStore(fileName: String, gpx: String): Boolean {
+ // Guard: external storage must be mounted before touching MediaStore
+ val storageState = Environment.getExternalStorageState()
+ if (storageState != Environment.MEDIA_MOUNTED) {
+ Log.e(TAG, "External storage not mounted (state=$storageState) — cannot save $fileName")
+ return false
+ }
+
+ // IS_PENDING marks the entry as in-progress, preventing a race condition on
+ // Android 10-11 where insert() succeeds but openOutputStream() returns null
+ // because the file hasn't been physically created on disk yet.
+ val values = ContentValues().apply {
+ put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml")
+ put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/")
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val uri = context.contentResolver.insert(
+ MediaStore.Files.getContentUri("external"), values
+ ) ?: run {
+ Log.e(TAG, "MediaStore insert returned null for $fileName")
+ return false
+ }
+
+ return runCatching {
+ val stream = context.contentResolver.openOutputStream(uri)
+ if (stream == null) {
+ context.contentResolver.delete(uri, null, null)
+ Log.e(TAG, "openOutputStream null for $fileName — deleted orphan entry")
+ return@runCatching false
+ }
+ stream.use { it.write(gpx.toByteArray()) }
+
+ // Clear IS_PENDING so the file is visible to other apps and file managers
+ val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
+ context.contentResolver.update(uri, update, null, null)
+
+ Log.d(TAG, "Saved $fileName (${gpx.length} bytes) → $uri")
+ true
+ }.getOrElse { e ->
+ Log.e(TAG, "Write failed for $fileName: ${e.message}")
+ runCatching { context.contentResolver.delete(uri, null, null) }
+ false
+ }
+ }
+
+ private fun loadViaMediaStore(): List<List<TrackPoint>> {
+ val tracks = mutableListOf<List<TrackPoint>>()
+ val uri = MediaStore.Files.getContentUri("external")
+ val projection = arrayOf(MediaStore.Files.FileColumns._ID)
+ val selection = "${MediaStore.Files.FileColumns.RELATIVE_PATH} = ? " +
+ "AND ${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?"
+ val args = arrayOf("Documents/Nav/", "nav_%.gpx")
+
+ context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor ->
+ val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ while (cursor.moveToNext()) {
+ val fileUri = android.net.Uri.withAppendedPath(uri, cursor.getLong(idCol).toString())
+ runCatching {
+ context.contentResolver.openInputStream(fileUri)?.use { stream ->
+ val points = GpxParser.parse(stream)
+ if (points.isNotEmpty()) tracks.add(points)
+ }
+ }
+ }
+ }
+ return tracks
+ }
+
+ // ── API < 29 ─────────────────────────────────────────────────────────────
+
+ private fun navDir(): File {
+ val docs = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
+ return File(docs, "Nav").also { it.mkdirs() }
+ }
+
+ private fun saveViaFile(fileName: String, gpx: String): Boolean = runCatching {
+ File(navDir(), fileName).writeText(gpx)
+ true
+ }.getOrDefault(false)
+
+ private fun loadViaFile(): List<List<TrackPoint>> {
+ val dir = navDir()
+ if (!dir.exists()) return emptyList()
+ return dir.listFiles { f -> f.name.startsWith("nav_") && f.name.endsWith(".gpx") }
+ ?.sortedBy { it.name }
+ ?.mapNotNull { file ->
+ runCatching { GpxParser.parse(file.inputStream()) }
+ .getOrNull()
+ ?.takeIf { it.isNotEmpty() }
+ } ?: emptyList()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
new file mode 100644
index 0000000..3f2f3be
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.track
+
+import kotlin.math.*
+
+data class TrackSummary(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val maxSogKt: Double,
+ val avgSogKt: Double,
+ val avgWindKt: Double?, // null if no wind data in track
+ val avgWaveHeightM: Double? // null if no wave data in track
+) {
+ val durationMinutes: Long get() = durationMs / 60_000
+}
+
+/** Computes a [TrackSummary] from a completed list of [TrackPoint]s. */
+fun summarise(points: List<TrackPoint>): TrackSummary {
+ require(points.isNotEmpty())
+
+ var distanceNm = 0.0
+ for (i in 1 until points.size) {
+ distanceNm += haversineNm(points[i - 1], points[i])
+ }
+
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val windReadings = points.mapNotNull { it.windSpeedKnots }
+ val avgWind = if (windReadings.isNotEmpty()) windReadings.average() else null
+
+ val waveReadings = points.mapNotNull { it.waveHeightM }
+ val avgWave = if (waveReadings.isNotEmpty()) waveReadings.average() else null
+
+ return TrackSummary(
+ distanceNm = distanceNm,
+ durationMs = durationMs,
+ maxSogKt = maxSog,
+ avgSogKt = avgSog,
+ avgWindKt = avgWind,
+ avgWaveHeightM = avgWave
+ )
+}
+
+private fun haversineNm(a: TrackPoint, b: TrackPoint): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(b.lat - a.lat)
+ val dLon = Math.toRadians(b.lon - a.lon)
+ val sinDLat = sin(dLat / 2)
+ val sinDLon = sin(dLon / 2)
+ val h = sinDLat * sinDLat +
+ cos(Math.toRadians(a.lat)) * cos(Math.toRadians(b.lat)) * sinDLon * sinDLon
+ return 2 * r * asin(sqrt(h))
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
new file mode 100644
index 0000000..8d9d7c7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
@@ -0,0 +1,95 @@
+package org.terst.nav.track
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.terst.nav.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+class TrackSummarySheet : BottomSheetDialogFragment() {
+
+ companion object {
+ private const val ARG_DISTANCE = "distance_nm"
+ private const val ARG_DURATION = "duration_ms"
+ private const val ARG_MAX_SOG = "max_sog"
+ private const val ARG_AVG_SOG = "avg_sog"
+ private const val ARG_AVG_WIND = "avg_wind" // -1 if absent
+ private const val ARG_AVG_WAVE = "avg_wave_m" // -1 if absent
+ private const val ARG_START_MS = "start_ms"
+
+ fun from(summary: TrackSummary, startMs: Long) = TrackSummarySheet().apply {
+ arguments = Bundle().apply {
+ putDouble(ARG_DISTANCE, summary.distanceNm)
+ putLong(ARG_DURATION, summary.durationMs)
+ putDouble(ARG_MAX_SOG, summary.maxSogKt)
+ putDouble(ARG_AVG_SOG, summary.avgSogKt)
+ putDouble(ARG_AVG_WIND, summary.avgWindKt ?: -1.0)
+ putDouble(ARG_AVG_WAVE, summary.avgWaveHeightM ?: -1.0)
+ putLong(ARG_START_MS, startMs)
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_track_summary_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val args = requireArguments()
+ val distanceNm = args.getDouble(ARG_DISTANCE)
+ val durationMs = args.getLong(ARG_DURATION)
+ val maxSog = args.getDouble(ARG_MAX_SOG)
+ val avgSog = args.getDouble(ARG_AVG_SOG)
+ val avgWind = args.getDouble(ARG_AVG_WIND).takeIf { it >= 0 }
+ val avgWaveM = args.getDouble(ARG_AVG_WAVE).takeIf { it >= 0 }
+ val startMs = args.getLong(ARG_START_MS)
+
+ val titleFmt = DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ view.findViewById<TextView>(R.id.summary_title).text =
+ "Track · ${titleFmt.format(Instant.ofEpochMilli(startMs))}"
+
+ view.findViewById<TextView>(R.id.summary_distance).text =
+ "%.1f".format(Locale.getDefault(), distanceNm)
+
+ view.findViewById<TextView>(R.id.summary_duration).text = formatDuration(durationMs)
+
+ view.findViewById<TextView>(R.id.summary_max_sog).text =
+ "%.1f".format(Locale.getDefault(), maxSog)
+
+ view.findViewById<TextView>(R.id.summary_avg_sog).text =
+ "%.1f".format(Locale.getDefault(), avgSog)
+
+ val conditionsRow = view.findViewById<LinearLayout>(R.id.summary_conditions_row)
+ val windCell = view.findViewById<LinearLayout>(R.id.summary_wind_cell)
+ val waveCell = view.findViewById<LinearLayout>(R.id.summary_wave_cell)
+
+ if (avgWind != null) {
+ windCell.visibility = View.VISIBLE
+ view.findViewById<TextView>(R.id.summary_avg_wind).text =
+ "%.0f".format(Locale.getDefault(), avgWind)
+ }
+ if (avgWaveM != null) {
+ waveCell.visibility = View.VISIBLE
+ val waveFt = avgWaveM * 3.28084
+ view.findViewById<TextView>(R.id.summary_avg_wave).text =
+ "%.1f".format(Locale.getDefault(), waveFt)
+ }
+ if (avgWind != null || avgWaveM != null) {
+ conditionsRow.visibility = View.VISIBLE
+ }
+ }
+
+ private fun formatDuration(ms: Long): String {
+ val totalMinutes = ms / 60_000
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+ return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
new file mode 100644
index 0000000..2362079
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
@@ -0,0 +1,43 @@
+package org.terst.nav.tripreport
+
+enum class BoatType {
+ MONOHULL,
+ MULTIHULL
+}
+
+enum class RigType {
+ SLOOP,
+ CUTTER,
+ KETCH
+}
+
+data class BoatProfile(
+ val name: String,
+ val lengthFt: Double,
+ val type: BoatType,
+ val rig: RigType,
+ val hasSpinnaker: Boolean = false,
+ val hasGennaker: Boolean = false
+)
+
+data class PreTripSummary(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val windSpeedKt: Double,
+ val windDirDeg: Double,
+ val waveHeightM: Double?,
+ val weatherDescription: String,
+ val boatProfile: BoatProfile
+)
+
+data class SailSuggestion(
+ val sailName: String,
+ val action: String // e.g., "Full Main", "1 Reef", "Furl"
+)
+
+data class PreTripReport(
+ val summary: PreTripSummary,
+ val routingSuggestion: String,
+ val sailPlan: List<SailSuggestion>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
new file mode 100644
index 0000000..819485f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
@@ -0,0 +1,102 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import org.terst.nav.R
+import org.terst.nav.ui.MainViewModel
+import java.util.Locale
+
+class PreTripReportFragment : Fragment() {
+
+ private val viewModel: PreTripReportViewModel by activityViewModels()
+ private val mainViewModel: MainViewModel by activityViewModels()
+
+ private lateinit var tvWeatherSummary: TextView
+ private lateinit var tvRoutingContent: TextView
+ private lateinit var tvSailPlanContent: TextView
+ private lateinit var cardReport: MaterialCardView
+ private lateinit var btnGenerate: MaterialButton
+ private lateinit var progress: ProgressBar
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_pretrip_report, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvWeatherSummary = view.findViewById(R.id.tv_weather_summary)
+ tvRoutingContent = view.findViewById(R.id.tv_routing_content)
+ tvSailPlanContent = view.findViewById(R.id.tv_sail_plan_content)
+ cardReport = view.findViewById(R.id.card_report)
+ btnGenerate = view.findViewById(R.id.btn_generate_pretrip)
+ progress = view.findViewById(R.id.progress_pretrip)
+
+ btnGenerate.setOnClickListener {
+ generateReport()
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { renderState(it) }
+ }
+ }
+
+ private fun generateReport() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val forecast = mainViewModel.forecast.value.firstOrNull()
+ val conditions = mainViewModel.marineConditions.value
+ // For now, use 0,0 if no location, but ideally we'd have last known
+ // In a real app, we'd get this from a LocationProvider
+ viewModel.generate(0.0, 0.0, forecast, conditions)
+ }
+ }
+
+ private fun renderState(state: PreTripState) {
+ when (state) {
+ is PreTripState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnGenerate.isEnabled = false
+ }
+ is PreTripState.Success -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ cardReport.visibility = View.VISIBLE
+
+ val r = state.report
+ tvWeatherSummary.text = "Wind: %.1f kts from %.0f°\nWaves: %s\nSky: %s".format(
+ Locale.getDefault(),
+ r.summary.windSpeedKt,
+ r.summary.windDirDeg,
+ r.summary.waveHeightM?.let { "%.1fm".format(it) } ?: "N/A",
+ r.summary.weatherDescription
+ )
+
+ tvRoutingContent.text = r.routingSuggestion
+
+ val sailPlanText = r.sailPlan.joinToString("\n") {
+ "• ${it.sailName}: ${it.action}"
+ }
+ tvSailPlanContent.text = sailPlanText
+ }
+ is PreTripState.Error -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ // Show toast or error message
+ }
+ else -> {}
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt
new file mode 100644
index 0000000..2ccabfb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt
@@ -0,0 +1,66 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+
+class PreTripReportGenerator {
+
+ fun generateReport(
+ lat: Double,
+ lon: Double,
+ forecast: ForecastItem?,
+ conditions: MarineConditions?,
+ boatProfile: BoatProfile
+ ): PreTripReport {
+ val summary = PreTripSummary(
+ timestampMs = System.currentTimeMillis(),
+ lat = lat,
+ lon = lon,
+ windSpeedKt = forecast?.windKt ?: 0.0,
+ windDirDeg = forecast?.windDirDeg ?: 0.0,
+ waveHeightM = conditions?.waveHeightM,
+ weatherDescription = forecast?.weatherDescription() ?: "Unknown",
+ boatProfile = boatProfile
+ )
+
+ val routing = suggestRouting(summary)
+ val sailPlan = suggestSailPlan(summary)
+
+ return PreTripReport(summary, routing, sailPlan)
+ }
+
+ private fun suggestRouting(summary: PreTripSummary): String {
+ val wind = summary.windSpeedKt
+ val waves = summary.waveHeightM ?: 0.0
+
+ return when {
+ wind > 35.0 -> "STORM WARNING: Winds exceed 35kts. Consider remaining in port or seeking shelter immediately."
+ wind > 25.0 && waves > 2.5 -> "HEAVY WEATHER: Expect challenging conditions. Coastal routing advised to minimize fetch."
+ wind < 5.0 -> "LIGHT WINDS: Motor-sailing likely required for efficient passage."
+ else -> "FAVORABLE CONDITIONS: Standard routing based on destination bearing should be effective."
+ }
+ }
+
+ private fun suggestSailPlan(summary: PreTripSummary): List<SailSuggestion> {
+ val wind = summary.windSpeedKt
+ val suggestions = mutableListOf<SailSuggestion>()
+
+ // Main sail
+ suggestions.add(when {
+ wind > 30.0 -> SailSuggestion("Main", "Deep Reef / Trysail")
+ wind > 22.0 -> SailSuggestion("Main", "2nd Reef")
+ wind > 16.0 -> SailSuggestion("Main", "1st Reef")
+ else -> SailSuggestion("Main", "Full Main")
+ })
+
+ // Headsail
+ suggestions.add(when {
+ wind > 25.0 -> SailSuggestion("Headsail", "Storm Jib / Furl 50%")
+ wind > 18.0 -> SailSuggestion("Headsail", "Working Jib / Furl 30%")
+ wind < 10.0 && summary.boatProfile.hasGennaker -> SailSuggestion("Gennaker", "Deploy for light air reach")
+ else -> SailSuggestion("Headsail", "Full Genoa")
+ })
+
+ return suggestions
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
new file mode 100644
index 0000000..9fd32c7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
@@ -0,0 +1,51 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+
+sealed class PreTripState {
+ object Idle : PreTripState()
+ object Loading : PreTripState()
+ data class Success(val report: PreTripReport) : PreTripState()
+ data class Error(val message: String) : PreTripState()
+}
+
+class PreTripReportViewModel(
+ private val generator: PreTripReportGenerator = PreTripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<PreTripState>(PreTripState.Idle)
+ val state: StateFlow<PreTripState> = _state.asStateFlow()
+
+ private val _boatProfile = MutableStateFlow(
+ BoatProfile("Default Sloop", 35.0, BoatType.MONOHULL, RigType.SLOOP)
+ )
+ val boatProfile: StateFlow<BoatProfile> = _boatProfile.asStateFlow()
+
+ fun updateBoatProfile(profile: BoatProfile) {
+ _boatProfile.value = profile
+ }
+
+ fun generate(
+ lat: Double,
+ lon: Double,
+ forecast: ForecastItem?,
+ conditions: MarineConditions?
+ ) {
+ viewModelScope.launch {
+ _state.value = PreTripState.Loading
+ try {
+ val report = generator.generateReport(lat, lon, forecast, conditions, _boatProfile.value)
+ _state.value = PreTripState.Success(report)
+ } catch (e: Exception) {
+ _state.value = PreTripState.Error(e.message ?: "Failed to generate pre-trip report")
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
new file mode 100644
index 0000000..e7a425f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -0,0 +1,86 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.chip.ChipGroup
+import kotlinx.coroutines.launch
+import org.terst.nav.NavApplication
+import org.terst.nav.R
+
+class TripReportFragment : Fragment() {
+
+ private val viewModel by lazy {
+ TripReportViewModel(
+ trackRepository = NavApplication.trackRepository,
+ logbookRepository = NavApplication.logbookRepository
+ )
+ }
+
+ private lateinit var tvNarrativeContent: TextView
+ private lateinit var btnRefresh: MaterialButton
+ private lateinit var chipGroup: ChipGroup
+ private lateinit var progress: ProgressBar
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_trip_report, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvNarrativeContent = view.findViewById(R.id.tv_narrative_content)
+ btnRefresh = view.findViewById(R.id.btn_refresh_report)
+ chipGroup = view.findViewById(R.id.chip_group_styles)
+ progress = view.findViewById(R.id.progress_report)
+
+ btnRefresh.setOnClickListener { viewModel.generateReport() }
+
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val style = when (checkedIds.firstOrNull()) {
+ R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS
+ R.id.chip_journal -> NarrativeStyle.JOURNAL
+ R.id.chip_pirate -> NarrativeStyle.PIRATE
+ else -> NarrativeStyle.PROFESSIONAL
+ }
+ viewModel.setStyle(style)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { state -> renderState(state) }
+ }
+
+ // Initial generation
+ viewModel.generateReport()
+ }
+
+ private fun renderState(state: TripReportState) {
+ when (state) {
+ is TripReportState.Idle -> {
+ progress.visibility = View.GONE
+ }
+ is TripReportState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnRefresh.isEnabled = false
+ }
+ is TripReportState.Success -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = state.narrative
+ }
+ is TripReportState.Error -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = "Error: ${state.message}"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
new file mode 100644
index 0000000..bbf00b1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -0,0 +1,117 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.logbook.LogEntry
+import org.terst.nav.track.TrackPoint
+
+enum class NarrativeStyle {
+ PROFESSIONAL,
+ ADVENTUROUS,
+ JOURNAL,
+ PIRATE
+}
+
+data class TripSummary(
+ val startTimeMs: Long,
+ val endTimeMs: Long,
+ val distanceNm: Double,
+ val maxSogKts: Double,
+ val avgSogKts: Double,
+ val minAirTempC: Double?,
+ val maxAirTempC: Double?,
+ val maxWaveHeightM: Double?,
+ val logEntries: List<LogEntry>,
+ val points: List<TrackPoint>
+)
+
+class TripReportGenerator {
+
+ fun generateSummary(points: List<TrackPoint>, logEntries: List<LogEntry>): TripSummary {
+ if (points.isEmpty()) {
+ return TripSummary(0, 0, 0.0, 0.0, 0.0, null, null, null, logEntries, points)
+ }
+
+ val startTime = points.first().timestampMs
+ val endTime = points.last().timestampMs
+
+ var totalDist = 0.0
+ for (i in 0 until points.size - 1) {
+ totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon)
+ }
+ val distanceNm = totalDist / 1852.0 // meters to nautical miles
+
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val airTemps = points.mapNotNull { it.airTempC }
+ val minTemp = airTemps.minOrNull()
+ val maxTemp = airTemps.maxOrNull()
+ val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull()
+
+ return TripSummary(
+ startTimeMs = startTime,
+ endTimeMs = endTime,
+ distanceNm = distanceNm,
+ maxSogKts = maxSog,
+ avgSogKts = avgSog,
+ minAirTempC = minTemp,
+ maxAirTempC = maxTemp,
+ maxWaveHeightM = maxWave,
+ logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
+ points = points
+ )
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 6371e3 // Earth radius in meters
+ val phi1 = Math.toRadians(lat1)
+ val phi2 = Math.toRadians(lat2)
+ val deltaPhi = Math.toRadians(lat2 - lat1)
+ val deltaLambda = Math.toRadians(lon2 - lon1)
+
+ val a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) *
+ Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return r * c
+ }
+
+ fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String {
+ val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0
+ val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " +
+ "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) +
+ (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") +
+ "Events: ${summary.logEntries.joinToString { it.text }}"
+
+ return when (style) {
+ NarrativeStyle.PROFESSIONAL -> {
+ "VOYAGE SUMMARY\n" +
+ "Duration: %.1f hours\n".format(durationHrs) +
+ "Total Distance: %.1f NM\n".format(summary.distanceNm) +
+ "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) +
+ "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" +
+ "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" }
+ }
+ NarrativeStyle.ADVENTUROUS -> {
+ "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) +
+ "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) +
+ (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" +
+ "During our journey, we logged some great moments:\n" +
+ summary.logEntries.joinToString("\n") { "🔥 ${it.text}" }
+ }
+ NarrativeStyle.JOURNAL -> {
+ "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) +
+ "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) +
+ "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " +
+ "It was a meaningful passage."
+ }
+ NarrativeStyle.PIRATE -> {
+ "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) +
+ "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) +
+ "Listen to the tales from the log:\n" +
+ summary.logEntries.joinToString("\n") { "🏴‍☠️ ${it.text}" } + "\n" +
+ "Arr, it was a fine voyage indeed!"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
new file mode 100644
index 0000000..e474cd2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.track.TrackRepository
+
+sealed class TripReportState {
+ object Idle : TripReportState()
+ object Loading : TripReportState()
+ data class Success(val summary: TripSummary, val narrative: String) : TripReportState()
+ data class Error(val message: String) : TripReportState()
+}
+
+class TripReportViewModel(
+ private val trackRepository: TrackRepository,
+ private val logbookRepository: InMemoryLogbookRepository,
+ private val generator: TripReportGenerator = TripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle)
+ val state: StateFlow<TripReportState> = _state.asStateFlow()
+
+ private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL)
+ val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow()
+
+ fun setStyle(style: NarrativeStyle) {
+ _selectedStyle.value = style
+ generateReport()
+ }
+
+ fun generateReport() {
+ viewModelScope.launch {
+ _state.value = TripReportState.Loading
+ try {
+ val points = trackRepository.getPoints()
+ if (points.isEmpty()) {
+ _state.value = TripReportState.Error("No track data available. Start recording a track first.")
+ return@launch
+ }
+ val logEntries = logbookRepository.getAll()
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ _state.value = TripReportState.Success(summary, narrative)
+ } catch (e: Exception) {
+ _state.value = TripReportState.Error(e.message ?: "Unknown error generating report")
+ }
+ }
+ }
+}
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/DirectionArrowView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
new file mode 100644
index 0000000..fa68b63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.View
+
+/** Normalises a bearing in degrees to [0, 360). */
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
+
+/**
+ * Small circular direction indicator — notched chevron pointing in [bearing] degrees
+ * (0 = north/up, clockwise). Two palettes: SKY (grey) and OCEAN (blue).
+ */
+class DirectionArrowView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ enum class ArrowStyle { SKY, OCEAN }
+
+ var bearing: Float = 0f
+ set(value) { field = normalizeBearing(value); invalidate() }
+
+ var arrowStyle: ArrowStyle = ArrowStyle.SKY
+ set(value) {
+ field = value
+ circlePaint.color = circleColor()
+ arrowPaint.color = arrowColor()
+ invalidate()
+ }
+
+ private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE; strokeWidth = 1.5f; color = circleColor()
+ }
+ private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL; color = arrowColor()
+ }
+ private val arrowPath = Path()
+
+ private fun circleColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#3A3640")
+ ArrowStyle.OCEAN -> Color.parseColor("#1E4A6E")
+ }
+ private fun arrowColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#9A94A0")
+ ArrowStyle.OCEAN -> Color.parseColor("#6FC3E8")
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f; val cy = height / 2f
+ val r = (minOf(width, height) / 2f) - circlePaint.strokeWidth
+ canvas.drawCircle(cx, cy, r, circlePaint)
+ val tipY = cy - r * 0.72f; val baseY = cy + r * 0.50f
+ val notchY = cy + r * 0.22f; val halfW = r * 0.42f
+ arrowPath.reset()
+ arrowPath.moveTo(cx, tipY)
+ arrowPath.lineTo(cx - halfW, baseY)
+ arrowPath.lineTo(cx, notchY)
+ arrowPath.lineTo(cx + halfW, baseY)
+ arrowPath.close()
+ canvas.save(); canvas.rotate(bearing, cx, cy)
+ canvas.drawPath(arrowPath, arrowPaint); canvas.restore()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
index 2f72153..cb59a3a 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
@@ -1,70 +1,161 @@
package org.terst.nav.ui
import android.widget.TextView
-import org.terst.nav.BarometerReading
-import org.terst.nav.BarometerTrendView
-import org.terst.nav.PolarDiagramView
-import org.terst.nav.R
import java.util.Locale
+// ── Pure formatting helpers (top-level for testability) ──────────────────────
+
+private const val M_TO_FT = 3.28084
+
+/** Converts metres to feet. */
+fun metresToFeet(metres: Double): Double = metres * M_TO_FT
+
+/** Formats a feet value to one decimal place. */
+fun formatFt(ft: Double, locale: Locale = Locale.getDefault()): String =
+ "%.1f".format(locale, ft)
+
+/** Formats a bearing to zero decimal places with a degree symbol. */
+fun formatBearing(deg: Double, locale: Locale = Locale.getDefault()): String =
+ "%.0f°".format(locale, deg)
+
+/** Formats a swell period with leading dot separator. */
+fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String =
+ "· %.0fs".format(locale, sec)
+
+// ── InstrumentHandler ────────────────────────────────────────────────────────
+
/**
- * Handles the display of instrument data in the UI.
+ * Drives all text fields, direction arrows, and the wave view in the
+ * instrument bottom sheet.
+ *
+ * Forecast [DirectionArrowView] instances are initialised with OCEAN style.
+ *
+ * Units contract:
+ * - Speed values: pre-formatted strings in knots (caller's responsibility)
+ * - Height values: caller passes raw metres; this class converts to feet
+ * - Bearing values: raw degrees as Float, rotated into arrows and formatted
+ * into bearing TextViews by this class
*/
class InstrumentHandler(
- private val valueAws: TextView,
- private val valueTws: TextView,
- private val valueHdg: TextView,
- private val valueCog: TextView,
- private val valueBsp: TextView,
- private val valueSog: TextView,
- private val valueVmg: TextView,
+ // ── Instrument section TextViews ─────────────────────────────────
+ private val valueAws: TextView,
+ private val valueTws: TextView,
+ private val valueHdg: TextView,
+ private val valueCog: TextView,
+ private val valueBsp: TextView,
+ private val valueSog: TextView,
private val valueDepth: TextView,
- private val valuePolarPct: TextView,
- private val valueBaro: TextView,
- private val labelTrend: TextView?,
- private val barometerTrendView: BarometerTrendView?,
- private val polarDiagramView: PolarDiagramView
+ private val valueBaro: TextView,
+ // ── Instrument section DirectionArrowViews ───────────────────────
+ private val arrowAws: DirectionArrowView,
+ private val arrowTws: DirectionArrowView,
+ private val arrowHdg: DirectionArrowView,
+ private val arrowCog: DirectionArrowView,
+ // ── Forecast section TextViews ───────────────────────────────────
+ private val valueCurrSpd: TextView,
+ private val valueWaveHt: TextView,
+ private val valueSwellHt: TextView,
+ private val valueSwellPer: TextView,
+ // ── Forecast section DirectionArrowViews ─────────────────────────
+ private val arrowCurr: DirectionArrowView,
+ private val arrowWaves: DirectionArrowView,
+ private val arrowSwell: DirectionArrowView,
+ // ── Forecast section bearing TextViews ───────────────────────────
+ private val bearingCurr: TextView,
+ private val bearingWaves: TextView,
+ private val bearingSwell: TextView,
+ // ── Wave view ────────────────────────────────────────────────────
+ private val waveView: WaveView
) {
+ init {
+ arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ }
+
/**
- * Updates the text displays for various instruments.
+ * Updates instrument-section text and arrows.
+ * Null arguments leave the current value unchanged.
+ * [depthM] is raw metres — converted to feet internally.
*/
fun updateDisplay(
- aws: String? = null,
- tws: String? = null,
- hdg: String? = null,
- cog: String? = null,
- bsp: String? = null,
- sog: String? = null,
- vmg: String? = null,
- depth: String? = null,
- polarPct: String? = null,
- baro: String? = null,
- trend: String? = null
+ aws: String? = null, awsBearingDeg: Float? = null,
+ tws: String? = null, twsBearingDeg: Float? = null,
+ hdg: String? = null, hdgBearingDeg: Float? = null,
+ cog: String? = null, cogBearingDeg: Float? = null,
+ bsp: String? = null,
+ sog: String? = null,
+ depthM: Double? = null,
+ baro: String? = null
) {
- aws?.let { valueAws.text = it }
- tws?.let { valueTws.text = it }
- hdg?.let { valueHdg.text = it }
- cog?.let { valueCog.text = it }
- bsp?.let { valueBsp.text = it }
- sog?.let { valueSog.text = it }
- vmg?.let { valueVmg.text = it }
- depth?.let { valueDepth.text = it }
- polarPct?.let { valuePolarPct.text = it }
- baro?.let { valueBaro.text = it }
- trend?.let { labelTrend?.text = it }
+ aws?.let { valueAws.text = it }
+ tws?.let { valueTws.text = it }
+ hdg?.let { valueHdg.text = it }
+ cog?.let { valueCog.text = it }
+ bsp?.let { valueBsp.text = it }
+ sog?.let { valueSog.text = it }
+ baro?.let { valueBaro.text = it }
+ depthM?.let { valueDepth.text = formatFt(metresToFeet(it)) }
+
+ awsBearingDeg?.let { arrowAws.bearing = it }
+ twsBearingDeg?.let { arrowTws.bearing = it }
+ hdgBearingDeg?.let { arrowHdg.bearing = it }
+ cogBearingDeg?.let { arrowCog.bearing = it }
}
/**
- * Updates the polar diagram view.
+ * Updates the forecast section.
+ * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally.
*/
- fun updatePolarDiagram(tws: Double, twa: Double, bsp: Double) {
- polarDiagramView.setCurrentPerformance(tws, twa, bsp)
+ fun updateConditions(
+ currSpd: String? = null,
+ currDirDeg: Float? = null,
+ waveHeightM: Double? = null,
+ waveDirDeg: Float? = null,
+ swellHeightM: Double? = null,
+ swellDirDeg: Float? = null,
+ swellPeriodS: Double? = null
+ ) {
+ currSpd?.let { valueCurrSpd.text = it }
+ currDirDeg?.let {
+ arrowCurr.bearing = it
+ bearingCurr.text = formatBearing(it.toDouble())
+ }
+ waveHeightM?.let { valueWaveHt.text = formatFt(metresToFeet(it)) }
+ waveDirDeg?.let {
+ arrowWaves.bearing = it
+ bearingWaves.text = formatBearing(it.toDouble())
+ }
+ swellHeightM?.let { valueSwellHt.text = formatFt(metresToFeet(it)) }
+ swellDirDeg?.let {
+ arrowSwell.bearing = it
+ bearingSwell.text = formatBearing(it.toDouble())
+ }
+ swellPeriodS?.let { valueSwellPer.text = formatPeriod(it) }
}
/**
- * Updates the barometer trend chart.
+ * Updates the WaveView with current sea state. Call once when conditions load.
+ *
+ * View height is set here to reflect swell scale: 1ft → 56dp, 8ft → 160dp.
+ * [windSpeedKt] gates whitecap rendering (Beaufort 4 threshold = 12 kt).
*/
- fun updateBarometerTrend(history: List<BarometerReading>) {
- barometerTrendView?.setHistory(history)
+ fun updateWaveState(
+ swellHeightFt: Float,
+ swellPeriodSec: Float,
+ windWaveHeightFt: Float,
+ windSpeedKt: Float = 0f
+ ) {
+ waveView.swellHeightFt = swellHeightFt
+ waveView.swellPeriodSec = swellPeriodSec
+ waveView.windWaveHeightFt = windWaveHeightFt
+ waveView.windSpeedKt = windSpeedKt
+
+ // Size the view to the swell — bigger swell = taller window
+ val density = waveView.resources.displayMetrics.density
+ val heightDp = (32f + swellHeightFt * 16f).coerceIn(56f, 160f)
+ val lp = waveView.layoutParams
+ lp.height = (heightDp * density).toInt()
+ waveView.layoutParams = lp
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
new file mode 100644
index 0000000..48dc808
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
@@ -0,0 +1,48 @@
+package org.terst.nav.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.switchmaterial.SwitchMaterial
+import org.terst.nav.R
+
+class LayerPickerSheet(
+ private val manager: MapLayerManager,
+ private val onBaseChanged: (MapBasePreset) -> Unit,
+ private val onWindChanged: (Boolean) -> Unit
+) : BottomSheetDialogFragment() {
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_layer_picker_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val chipGroup = view.findViewById<ChipGroup>(R.id.chip_group_base)
+ val windSwitch = view.findViewById<SwitchMaterial>(R.id.switch_wind)
+
+ // Set initial state from manager
+ val chipId = when (manager.basePreset) {
+ MapBasePreset.SATELLITE -> R.id.chip_satellite
+ MapBasePreset.CHARTS -> R.id.chip_charts
+ MapBasePreset.HYBRID -> R.id.chip_hybrid
+ }
+ chipGroup.check(chipId)
+ windSwitch.isChecked = manager.windEnabled
+
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val preset = when (checkedIds.firstOrNull()) {
+ R.id.chip_satellite -> MapBasePreset.SATELLITE
+ R.id.chip_charts -> MapBasePreset.CHARTS
+ R.id.chip_hybrid -> MapBasePreset.HYBRID
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onBaseChanged(preset)
+ }
+
+ windSwitch.setOnCheckedChangeListener { _, checked ->
+ onWindChanged(checked)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 4c2c555..9bd660b 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -7,19 +7,25 @@ import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.track.TrackPoint
-import org.terst.nav.track.TrackRepository
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import org.terst.nav.track.TrackSummary
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
+import kotlin.math.cos
+import kotlin.math.sin
sealed class UiState {
object Loading : UiState()
@@ -43,12 +49,15 @@ class MainViewModel(
private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
val forecast: StateFlow<List<ForecastItem>> = _forecast
+ private val _marineConditions = MutableStateFlow<MarineConditions?>(null)
+ val marineConditions: StateFlow<MarineConditions?> = _marineConditions.asStateFlow()
+
private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
private val aisRepository = AisRepository()
- private val trackRepository = TrackRepository()
+ private val trackRepository = org.terst.nav.NavApplication.trackRepository
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
@@ -56,22 +65,41 @@ class MainViewModel(
private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+ private val _pastTracks = MutableStateFlow<List<List<TrackPoint>>>(emptyList())
+ val pastTracks: StateFlow<List<List<TrackPoint>>> = _pastTracks.asStateFlow()
+
+ private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0)
+ val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow()
+
fun startTrack() {
trackRepository.startTrack()
_trackPoints.value = emptyList()
_isRecording.value = true
}
+ /** Epoch-ms when the current track started — for the summary sheet title. */
+ val trackStartMs: Long get() = trackRepository.trackStartMs
+
fun stopTrack() {
- trackRepository.stopTrack()
- _isRecording.value = false
+ viewModelScope.launch {
+ val summary = trackRepository.stopTrack()
+ _pastTracks.value = trackRepository.getPastTracks()
+ _trackPoints.value = emptyList()
+ _isRecording.value = false
+ summary?.let { _trackSummary.emit(it) }
+ }
}
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val conditions = _marineConditions.value
+ val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
lat = lat, lon = lon,
sogKnots = sogKnots, cogDeg = cogDeg,
- windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false,
+ airTempC = forecast?.tempC,
+ waveHeightM = conditions?.waveHeightM,
+ currentSpeedKts = conditions?.currentSpeedKt,
+ currentDirDeg = conditions?.currentDirDeg,
timestampMs = System.currentTimeMillis()
)
if (trackRepository.addPoint(point)) {
@@ -92,8 +120,18 @@ class MainViewModel(
}
/**
- * Fetch weather and marine data for [lat]/[lon] in parallel.
- * Called once the device location is known.
+ * Fetches current conditions snapshot for [lat]/[lon] and exposes it via [marineConditions].
+ * Silently ignored on network failure — the UI keeps showing dashes.
+ */
+ fun loadConditions(lat: Double, lon: Double) {
+ viewModelScope.launch {
+ repository.fetchCurrentConditions(lat, lon)
+ .onSuccess { _marineConditions.value = it }
+ }
+ }
+
+ /**
+ * Fetch weather and wind arrow for [lat]/[lon]. Called once on first GPS fix.
*/
fun loadWeather(lat: Double, lon: Double) {
viewModelScope.launch {
@@ -150,7 +188,6 @@ class MainViewModel(
/**
* Process a single NMEA sentence from the hardware AIS receiver.
- * Call this from MainActivity when bytes arrive from the TCP socket.
*/
fun processAisSentence(sentence: String) {
aisRepository.processSentence(sentence)
@@ -160,7 +197,6 @@ class MainViewModel(
/**
* Refresh AIS targets from AISHub for the given bounding box.
- * When username is empty, skips silently — hardware feed is primary.
*/
fun refreshAisFromInternet(
latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
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 bb73c53..1978745 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
@@ -20,18 +20,41 @@ 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.data.model.WindArrow
import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
/**
* Handles MapLibre initialization, layers, and updates.
*/
class MapHandler(private val maplibreMap: MapLibreMap) {
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ maplibreMap.addOnCameraIdleListener {
+ if (!_isFollowing.value) {
+ lastZoom = maplibreMap.cameraPosition.zoom
+ }
+ }
+ }
+
+ private val _isFollowing = MutableStateFlow(true)
+ val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+ private var lastLat: Double = 0.0
+ private var lastLon: Double = 0.0
+ private var lastZoom: Double = 14.0
+
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"
@@ -42,27 +65,34 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
- private val TRACK_SOURCE_ID = "track-source"
- private val TRACK_LAYER_ID = "track-line"
+ private val USER_POS_SOURCE_ID = "user-pos-source"
+ private val USER_POS_LAYER_ID = "user-pos-layer"
+ private val USER_ICON_ID = "user-icon"
+
+ private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
+ private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
+ private val TRACK_PAST_SOURCE_ID = "track-past-source"
+ private val TRACK_PAST_LAYER_ID = "track-line-past"
private val WIND_SOURCE_ID = "wind-source"
private val WIND_LAYER_ID = "wind-arrows"
private val WIND_ARROW_ICON_ID = "wind-arrow"
-
private val WIND_GRID_SOURCE_ID = "wind-grid-source"
private val WIND_GRID_LAYER_ID = "wind-grid-arrows"
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
- private var trackSource: GeoJsonSource? = null
+ private var userPosSource: GeoJsonSource? = null
+ private var trackActiveSource: GeoJsonSource? = null
+ private var trackPastSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
private var windGridSource: GeoJsonSource? = null
/**
- * Initializes map layers for anchor watch and tidal currents.
+ * Initializes map layers for anchor watch, tidal currents, and user position.
*/
- fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap) {
+ fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap, userBitmap: Bitmap) {
// Anchor layers
style.addImage(ANCHOR_ICON_ID, anchorBitmap)
anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
@@ -100,56 +130,29 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
PropertyFactory.iconSize(0.8f)
)
})
- }
-
- /**
- * Centers the map on the specified location.
- */
- fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
- val position = CameraPosition.Builder()
- .target(LatLng(lat, lon))
- .zoom(zoom)
- .build()
- maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
- }
- /**
- * Updates the anchor watch visualization on the map.
- */
- fun updateAnchorWatch(state: AnchorWatchState) {
- if (state.isActive && state.anchorLocation != null) {
- val point = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
- anchorPointSource?.setGeoJson(Feature.fromGeometry(point))
-
- val circlePolygon = createCirclePolygon(state.anchorLocation.latitude, state.anchorLocation.longitude, state.watchCircleRadiusMeters)
- anchorCircleSource?.setGeoJson(Feature.fromGeometry(circlePolygon))
- } else {
- anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- }
- }
-
- /**
- * Updates the tidal current arrows on the map.
- */
- fun updateTidalCurrents(state: TidalCurrentState) {
- if (state.isVisible) {
- val features = state.currents.map { current ->
- Feature.fromGeometry(Point.fromLngLat(current.longitude, current.latitude)).apply {
- addNumberProperty("rotation", current.directionDegrees.toFloat())
- }
- }
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
- } else {
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- }
+ // User Position Layer
+ style.addImage(USER_ICON_ID, userBitmap)
+ userPosSource = GeoJsonSource(USER_POS_SOURCE_ID)
+ style.addSource(userPosSource!!)
+ style.addLayer(SymbolLayer(USER_POS_LAYER_ID, USER_POS_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(USER_ICON_ID),
+ PropertyFactory.iconRotate(org.maplibre.android.style.expressions.Expression.get("rotation")),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true),
+ PropertyFactory.iconSize(1.0f)
+ )
+ })
}
/**
- * Registers the wind-arrow bitmap in the style. Call once after style loads.
+ * Registers the wind-arrow bitmap and creates the single-arrow + grid layers.
+ * Call once after style loads, after setupLayers.
*/
fun setupWindLayer(style: Style, arrowBitmap: Bitmap) {
style.addImage(WIND_ARROW_ICON_ID, arrowBitmap)
+
windSource = GeoJsonSource(WIND_SOURCE_ID)
style.addSource(windSource!!)
style.addLayer(SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).apply {
@@ -168,12 +171,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
)
)
})
- }
- /**
- * Registers the wind-grid layer. Uses the same bitmap as the single arrow — call after setupWindLayer.
- */
- fun setupWindGridLayer(style: Style) {
windGridSource = GeoJsonSource(WIND_GRID_SOURCE_ID)
style.addSource(windGridSource!!)
style.addLayer(SymbolLayer(WIND_GRID_LAYER_ID, WIND_GRID_SOURCE_ID).apply {
@@ -194,6 +192,15 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
})
}
+ /** Places or moves the single wind arrow at the GPS position. */
+ fun updateWindLayer(arrow: WindArrow) {
+ val feature = Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
+ addNumberProperty("direction", arrow.directionDeg)
+ addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ windSource?.setGeoJson(FeatureCollection.fromFeature(feature))
+ }
+
/** Replaces all wind-grid arrows with the fetched list. */
fun updateWindGridLayer(arrows: List<WindArrow>) {
val features = arrows.map { arrow ->
@@ -206,35 +213,113 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
/**
- * Places or moves the single wind arrow at the GPS position.
+ * Updates the user's position and orientation on the map.
*/
- fun updateWindLayer(arrow: WindArrow) {
- val feature = Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
- addNumberProperty("direction", arrow.directionDeg)
- addNumberProperty("speed_kt", arrow.speedKt)
+ fun updateUserPosition(lat: Double, lon: Double, headingDeg: Float) {
+ userPosSource?.setGeoJson(Feature.fromGeometry(Point.fromLngLat(lon, lat)).apply {
+ addNumberProperty("rotation", headingDeg)
+ })
+ }
+
+ /**
+ * Centers the map on the specified location.
+ */
+ fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+ val position = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(zoom)
+ .build()
+ maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+ }
+
+ /**
+ * Restores auto-follow mode and animates the camera back to the last known GPS position.
+ * No-op if no GPS fix has been received yet.
+ */
+ fun recenter() {
+ if (lastLat == 0.0 && lastLon == 0.0) return
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon, lastZoom)
+ }
+
+ /**
+ * Updates the anchor watch visualization on the map.
+ */
+ fun updateAnchorWatch(state: AnchorWatchState) {
+ if (state.isActive && state.anchorLocation != null) {
+ val point = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
+ anchorPointSource?.setGeoJson(Feature.fromGeometry(point))
+
+ val circlePolygon = createCirclePolygon(state.anchorLocation.latitude, state.anchorLocation.longitude, state.watchCircleRadiusMeters)
+ anchorCircleSource?.setGeoJson(Feature.fromGeometry(circlePolygon))
+ } else {
+ anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
- windSource?.setGeoJson(FeatureCollection.fromFeature(feature))
}
/**
- * Updates the GPS track polyline on the map. Lazily initialises the layer on first call.
+ * Updates the tidal current arrows on the map.
*/
- fun updateTrackLayer(style: Style, points: List<TrackPoint>) {
- if (trackSource == null) {
- trackSource = GeoJsonSource(TRACK_SOURCE_ID)
- style.addSource(trackSource!!)
- style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply {
+ fun updateTidalCurrents(state: TidalCurrentState) {
+ if (state.isVisible) {
+ val features = state.currents.map { current ->
+ Feature.fromGeometry(Point.fromLngLat(current.longitude, current.latitude)).apply {
+ addNumberProperty("rotation", current.directionDegrees.toFloat())
+ }
+ }
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ } else {
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layers on first call.
+ */
+ fun updateTrackLayer(style: Style, activePoints: List<TrackPoint>, pastTracks: List<List<TrackPoint>>) {
+ if (trackActiveSource == null) {
+ trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
+ style.addSource(trackActiveSource!!)
+ style.addLayer(LineLayer(TRACK_ACTIVE_LAYER_ID, TRACK_ACTIVE_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round")
+ )
+ })
+ }
+
+ if (trackPastSource == null) {
+ trackPastSource = GeoJsonSource(TRACK_PAST_SOURCE_ID)
+ style.addSource(trackPastSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_LAYER_ID, TRACK_PAST_SOURCE_ID).apply {
setProperties(
PropertyFactory.lineColor("#E53935"),
- PropertyFactory.lineWidth(3f)
+ PropertyFactory.lineWidth(3f),
+ PropertyFactory.lineDasharray(arrayOf(1f, 2f)),
+ PropertyFactory.lineCap("round")
)
})
}
- if (points.size >= 2) {
- val coords = points.map { Point.fromLngLat(it.lon, it.lat) }
- trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+
+ if (activePoints.size >= 2) {
+ val coords = activePoints.map { Point.fromLngLat(it.lon, it.lat) }
+ trackActiveSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackActiveSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+
+ if (pastTracks.isNotEmpty()) {
+ val features = pastTracks.map { track ->
+ Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
+ }
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(features))
} else {
- trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
}
@@ -249,7 +334,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
val lonOffset = radiusMeters / (111320.0 * cos(Math.toRadians(lat))) * sin(Math.toRadians(angle))
points.add(Point.fromLngLat(lon + lonOffset, lat + latOffset))
}
- points.add(points[0]) // Close the polygon
+ points.add(points[0])
return Polygon.fromLngLats(listOf(points))
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
new file mode 100644
index 0000000..cf78ec2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
@@ -0,0 +1,132 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.RasterLayer
+import org.maplibre.android.style.sources.RasterSource
+import org.maplibre.android.style.sources.TileSet
+
+enum class MapBasePreset { SATELLITE, CHARTS, HYBRID }
+
+/**
+ * Manages the raster layer stack: base imagery (satellite / NOAA charts / hybrid)
+ * and the wind overlay. Persists selections across sessions.
+ *
+ * All sources are registered at style-build time; this class only toggles
+ * layer visibility — no runtime source swapping required.
+ */
+class MapLayerManager(context: Context) {
+
+ private val prefs = context.getSharedPreferences("map_layers", Context.MODE_PRIVATE)
+
+ var basePreset: MapBasePreset = loadBasePreset()
+ private set
+
+ var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true)
+ private set
+
+ // ── Source / Layer IDs ────────────────────────────────────────────────────
+
+ companion object {
+ const val SOURCE_SATELLITE = "satellite-source"
+ const val SOURCE_CHARTS = "charts-source"
+ const val SOURCE_WIND = "wind-source"
+ const val SOURCE_SEAMARKS = "openseamap-source"
+
+ const val LAYER_SATELLITE = "satellite-layer"
+ const val LAYER_CHARTS = "charts-layer"
+ const val LAYER_WIND = "wind-layer"
+ const val LAYER_SEAMARKS = "openseamap-layer"
+
+ private const val KEY_BASE = "base_preset"
+ private const val KEY_WIND = "wind_enabled"
+
+ // Tile URLs
+ private const val URL_SATELLITE =
+ "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
+ private const val URL_CHARTS =
+ "https://tileservice.charts.noaa.gov/tiles/50000_1/{z}/{x}/{y}.png"
+ private const val URL_WIND =
+ "https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=ae2a038149aa0900d1bc74160aa2a37e"
+ private const val URL_SEAMARKS =
+ "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
+ }
+
+ /**
+ * Registers all raster sources and layers into the [Style.Builder].
+ * Call this before [Style.Builder.build]. Layers are added in the correct
+ * order; MapHandler's vector layers go on top after style loads.
+ */
+ fun addToStyleBuilder(builder: Style.Builder) {
+ // ── Sources ───────────────────────────────────────────────────────────
+ builder.withSource(RasterSource(SOURCE_SATELLITE,
+ TileSet("2.2.0", URL_SATELLITE), 256))
+
+ builder.withSource(RasterSource(SOURCE_CHARTS,
+ TileSet("2.2.0", URL_CHARTS), 256))
+
+ builder.withSource(RasterSource(SOURCE_WIND,
+ TileSet("2.2.0", URL_WIND), 256))
+
+ builder.withSource(RasterSource(SOURCE_SEAMARKS,
+ TileSet("2.2.0", URL_SEAMARKS).also { it.setMaxZoom(18f) }, 256))
+
+ // ── Layers (bottom → top within raster stack) ─────────────────────────
+ builder.withLayer(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply {
+ setProperties(PropertyFactory.rasterOpacity(1f))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply {
+ setProperties(PropertyFactory.rasterOpacity(opacityFor(basePreset)))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_CHARTS)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_WIND, SOURCE_WIND).apply {
+ setProperties(PropertyFactory.rasterOpacity(0.6f))
+ setProperties(PropertyFactory.visibility(if (windEnabled) "visible" else "none"))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS).apply {
+ setProperties(PropertyFactory.visibility("visible"))
+ })
+ }
+
+ /** Apply a new base preset to a live style. */
+ fun setBasePreset(style: Style, preset: MapBasePreset) {
+ basePreset = preset
+ prefs.edit().putString(KEY_BASE, preset.name).apply()
+
+ style.getLayer(LAYER_SATELLITE)?.setProperties(
+ PropertyFactory.visibility(visibilityFor(preset, LAYER_SATELLITE)))
+ style.getLayer(LAYER_CHARTS)?.let {
+ it.setProperties(PropertyFactory.visibility(visibilityFor(preset, LAYER_CHARTS)))
+ (it as? RasterLayer)?.setProperties(PropertyFactory.rasterOpacity(opacityFor(preset)))
+ }
+ }
+
+ /** Toggle wind overlay on a live style. */
+ fun setWindEnabled(style: Style, enabled: Boolean) {
+ windEnabled = enabled
+ prefs.edit().putBoolean(KEY_WIND, enabled).apply()
+ style.getLayer(LAYER_WIND)?.setProperties(
+ PropertyFactory.visibility(if (enabled) "visible" else "none"))
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private fun visibilityFor(preset: MapBasePreset, layerId: String): String = when (layerId) {
+ LAYER_SATELLITE -> if (preset == MapBasePreset.SATELLITE || preset == MapBasePreset.HYBRID) "visible" else "none"
+ LAYER_CHARTS -> if (preset == MapBasePreset.CHARTS || preset == MapBasePreset.HYBRID) "visible" else "none"
+ else -> "visible"
+ }
+
+ private fun opacityFor(preset: MapBasePreset): Float =
+ if (preset == MapBasePreset.HYBRID) 0.75f else 1f
+
+ private fun loadBasePreset(): MapBasePreset =
+ prefs.getString(KEY_BASE, null)?.let {
+ runCatching { MapBasePreset.valueOf(it) }.getOrNull()
+ } ?: MapBasePreset.SATELLITE
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
new file mode 100644
index 0000000..d3f9a4d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
@@ -0,0 +1,158 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Shader
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.view.View
+import kotlin.math.sin
+
+/**
+ * Draws an animated ocean-horizon scene used as the divider between the
+ * instrument section and the forecast section.
+ *
+ * Animation speed is driven by [swellPeriodSec] — a 20s period swell animates
+ * at half the speed of a 10s period swell. View height should be set by the
+ * caller to reflect actual swell height (see [InstrumentHandler.updateWaveState]).
+ *
+ * Whitecaps are only drawn when [windSpeedKt] >= 12 (Beaufort 4).
+ */
+class WaveView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ var swellHeightFt: Float = 3f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ var swellPeriodSec: Float = 10f
+ set(value) { field = value.coerceAtLeast(1f); invalidate() }
+
+ var windWaveHeightFt: Float = 1.5f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ /** Wind speed in knots. Whitecaps are suppressed below 12 kt (Beaufort 4). */
+ var windSpeedKt: Float = 0f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ private var startTimeMs = -1L
+
+ private val wavePath = Path()
+ private val skyPaint = Paint()
+ private val seaPaint = Paint()
+
+ private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = Color.argb(77, 111, 195, 232)
+ }
+
+ private val whitecapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ strokeCap = Paint.Cap.ROUND
+ color = Color.argb(128, 255, 255, 255)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ startTimeMs = SystemClock.elapsedRealtime()
+ postInvalidateOnAnimation()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ skyPaint.shader = LinearGradient(
+ 0f, 0f, 0f, h * 0.6f,
+ Color.parseColor("#1C1B1F"),
+ Color.parseColor("#162433"),
+ Shader.TileMode.CLAMP
+ )
+ seaPaint.shader = LinearGradient(
+ 0f, h * 0.4f, 0f, h.toFloat(),
+ Color.parseColor("#0B3050"),
+ Color.parseColor("#0D2137"),
+ Shader.TileMode.CLAMP
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (startTimeMs < 0) startTimeMs = SystemClock.elapsedRealtime()
+ val t = (SystemClock.elapsedRealtime() - startTimeMs) / 1000.0 // seconds
+
+ val w = width.toFloat()
+ val h = height.toFloat()
+
+ // Animation speed scales inversely with period: 10s → 0.8 rad/s, 20s → 0.4, 6s → 1.33
+ val swellSpeed = 8.0 / swellPeriodSec
+ val windSpeed = swellSpeed * 2.2
+
+ // Amplitude fills most of the view height — the view itself is sized for swell scale
+ val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.08f, h * 0.42f)
+ val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.16f)
+ val swellWlen = w * (swellPeriodSec / 14f)
+ val windWlen = swellWlen * 0.35f
+ val midY = h * 0.52f
+
+ fun waveY(x: Float): Float =
+ (midY
+ + sin((x / swellWlen) * TWO_PI - t * swellSpeed) * swellAmp
+ + sin((x / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp).toFloat()
+
+ // ── Sky fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, 0f)
+ wavePath.lineTo(0f, waveY(0f))
+ var x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, 0f)
+ wavePath.close()
+ canvas.drawPath(wavePath, skyPaint)
+
+ // ── Sea fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, h)
+ wavePath.lineTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, h)
+ wavePath.close()
+ canvas.drawPath(wavePath, seaPaint)
+
+ // ── Shimmer line ──────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ canvas.drawPath(wavePath, shimmerPaint)
+
+ // ── Whitecaps — only at Beaufort 4+ (≥12 kt) ─────────────────
+ if (windSpeedKt >= 12f) {
+ val capPath = Path()
+ x = windWlen * 0.5f
+ while (x <= w) {
+ val wv = sin((x / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp
+ val wn = sin(((x + 3) / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp
+ if (wv > windAmp * 0.55 && wv >= wn) {
+ val y = waveY(x)
+ capPath.reset()
+ capPath.moveTo(x - 7f, y + 1f)
+ capPath.quadTo(x, y - 2.5f, x + 8f, y + 1f)
+ canvas.drawPath(capPath, whitecapPaint)
+ }
+ x += windWlen * 0.9f
+ }
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ companion object {
+ private const val TWO_PI = Math.PI * 2.0
+ }
+}
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/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
index e950b5d..4bc0c7a 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
@@ -48,6 +48,13 @@ class SafetyFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener {
listener?.onConfigureAnchor()
}
+
+ view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
}
fun updateAnchorStatus(statusText: String) {
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
index ef48d37..1c797d5 100644
--- 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
@@ -16,6 +16,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch
import org.terst.nav.R
@@ -28,7 +29,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var speechRecognizer: SpeechRecognizer
private val viewModel by lazy {
- VoiceLogViewModel(repository = InMemoryLogbookRepository())
+ VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
}
private lateinit var tvStatus: TextView
@@ -38,6 +39,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnSave: Button
private lateinit var btnRetry: Button
private lateinit var tvSavedConfirmation: TextView
+ private lateinit var btnGenerateReport: MaterialButton
override fun onCreateView(
inflater: LayoutInflater,
@@ -55,12 +57,19 @@ class VoiceLogFragment : Fragment() {
btnSave = view.findViewById(R.id.btn_save)
btnRetry = view.findViewById(R.id.btn_retry)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
+ btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
btnSave.setOnClickListener { viewModel.confirmAndSave() }
btnRetry.setOnClickListener { viewModel.retry() }
+ btnGenerateReport.setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }
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)
diff --git a/android-app/app/src/main/res/drawable/ic_layers.xml b/android-app/app/src/main/res/drawable/ic_layers.xml
new file mode 100644
index 0000000..f86f83a
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_layers.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="?attr/colorOnSurface"
+ android:pathData="M11.99,18.54L4.62,12.81 3,14.07l9,7 9,-7-1.63,-1.27-8.38,5.74zM12,16l8.36,-6.54L22,8.07l-10,-7-10,7 1.63,1.39L12,16z"/>
+</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 feeb43d..1741c62 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -1,91 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout 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:orientation="vertical"
tools:context=".MainActivity">
- <!-- Main Content Area -->
- <androidx.constraintlayout.widget.ConstraintLayout
+ <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_marginBottom="56dp"> <!-- Space for BottomNav -->
+ android:layout_height="0dp"
+ android:layout_weight="1">
- <org.maplibre.android.maps.MapView
- android:id="@+id/mapView"
+ <!-- Main Content Area -->
+ <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
- android:layout_height="match_parent" />
+ android:layout_height="match_parent">
- <org.terst.nav.ui.map.ParticleWindView
- android:id="@+id/particle_wind_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:clickable="false"
- android:focusable="false" />
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/mapView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
- <!-- Overlay Fragment Container (for Log, Safety, Help) -->
- <FrameLayout
- android:id="@+id/fragment_container"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:visibility="gone"
- android:background="?attr/colorSurface" />
+ <org.terst.nav.ui.map.ParticleWindView
+ android:id="@+id/particle_wind_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clickable="false"
+ android:focusable="false" />
- </androidx.constraintlayout.widget.ConstraintLayout>
+ <!-- Overlay Fragment Container (for Log, Safety, Help) -->
+ <FrameLayout
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ android:background="?attr/colorSurface" />
- <!-- Collapsible Instrument Bottom Sheet -->
- <androidx.cardview.widget.CardView
- android:id="@+id/instrument_bottom_sheet"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- app:behavior_hideable="false"
- app:behavior_peekHeight="120dp"
- app:cardElevation="16dp"
- app:cardCornerRadius="24dp"
- app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
-
- <include
- layout="@layout/layout_instruments_sheet"
+ <!-- Quit button — stops all services and exits -->
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_quit"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:alpha="0.7"
+ app:icon="@drawable/ic_close"
+ app:iconSize="18dp"
+ app:iconGravity="textStart"
+ app:iconPadding="0dp"
+ app:cornerRadius="20dp"
+ app:elevation="4dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginTop="16dp"
+ android:layout_marginEnd="16dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginBottom="24dp" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <!-- Collapsible Instrument Bottom Sheet -->
+ <androidx.cardview.widget.CardView
+ android:id="@+id/instrument_bottom_sheet"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ app:behavior_hideable="false"
+ app:behavior_peekHeight="120dp"
+ app:cardElevation="16dp"
+ app:cardCornerRadius="24dp"
+ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
- </androidx.cardview.widget.CardView>
+ <include
+ layout="@layout/layout_instruments_sheet"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ </androidx.cardview.widget.CardView>
+
+ <!-- Persistent MOB Button (Crucial for safety, always on top) -->
+ <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="Man Overboard"
+ app:srcCompat="@android:drawable/ic_dialog_alert"
+ app:backgroundTint="@color/mob_button_background"
+ app:elevation="20dp"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|start" />
+
+ <!-- Record Track Button -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_record_track"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Record Track"
+ app:srcCompat="@drawable/ic_track_record"
+ app:elevation="20dp"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|end" />
+
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- Bottom Navigation -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_gravity="bottom"
android:background="?attr/colorSurface"
app:menu="@menu/bottom_nav_menu" />
- <!-- Persistent MOB Button (Crucial for safety, always on top) -->
- <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="Man Overboard"
- app:srcCompat="@android:drawable/ic_dialog_alert"
- app:backgroundTint="@color/mob_button_background"
- app:layout_anchor="@id/instrument_bottom_sheet"
- app:layout_anchorGravity="top|start" />
-
- <!-- Record Track Button -->
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_record_track"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="16dp"
- android:clickable="true"
- android:focusable="true"
- android:contentDescription="Record Track"
- app:srcCompat="@drawable/ic_track_record"
- app:layout_anchor="@id/instrument_bottom_sheet"
- app:layout_anchorGravity="top|end" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_pretrip_report.xml b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
new file mode 100644
index 0000000..d7ede49
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Pre-Trip Planning"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="16dp" />
+
+ <!-- Boat Config (Simple for now) -->
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ app:cardCornerRadius="12dp"
+ app:strokeWidth="1dp"
+ app:strokeColor="?attr/colorOutlineVariant">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Vessel Profile"
+ android:textStyle="bold"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_vessel_info"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="35ft Sloop (Monohull)"
+ android:textSize="14sp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- Report Content -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_report"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="4dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="20dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Weather Summary"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_weather_summary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:layout_marginBottom="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Routing Suggestion"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_routing_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:layout_marginBottom="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Sail Plan"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_sail_plan_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_pretrip"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="GENERATE PRE-TRIP REPORT" />
+
+ <ProgressBar
+ android:id="@+id/progress_pretrip"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml
index 5b2397e..f90420e 100644
--- a/android-app/app/src/main/res/layout/fragment_safety.xml
+++ b/android-app/app/src/main/res/layout/fragment_safety.xml
@@ -104,4 +104,13 @@
</com.google.android.material.card.MaterialCardView>
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_plan_trip"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="PLAN TRIP (PRE-TRIP REPORT)"
+ app:layout_constraintTop_toBottomOf="@id/card_anchor" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml
new file mode 100644
index 0000000..1ce0bde
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Trip Narrative"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Choose Narrative Style:"
+ android:textSize="14sp"
+ android:layout_marginBottom="8dp" />
+
+ <HorizontalScrollView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_styles"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_professional"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Professional"
+ android:checked="true" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_adventurous"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Adventurous" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_journal"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Journal" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_pirate"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Pirate" />
+
+ </com.google.android.material.chip.ChipGroup>
+ </HorizontalScrollView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="4dp"
+ app:strokeWidth="1dp"
+ app:strokeColor="?attr/colorOutlineVariant">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_narrative_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="Generate a report to see your story..."
+ android:textSize="16sp"
+ android:lineSpacingExtra="4dp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_refresh_report"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="REFRESH REPORT" />
+
+ <ProgressBar
+ android:id="@+id/progress_report"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+</ScrollView>
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
index e5f864c..6d136be 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -63,4 +63,18 @@
android:text=""
android:textSize="14sp"
android:layout_marginTop="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="#DDDDDD"
+ android:layout_marginTop="32dp"
+ android:layout_marginBottom="32dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_report"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:text="GENERATE TRIP REPORT" />
</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index 0a84418..33a7bd9 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -1,32 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content"
- android:padding="16dp"
- android:background="?attr/colorSurface">
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+ <!-- Drag handle -->
<View
android:id="@+id/drag_handle"
- android:layout_width="40dp"
+ android:layout_width="36dp"
android:layout_height="4dp"
+ android:layout_marginTop="12dp"
android:background="@color/md_theme_outline"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
- <!-- Grid of Instruments -->
+ <!--
+ 3×2 grid: AWS/HDG/BSP top row, TWS/COG/SOG bottom row.
+ Cells with direction: AWS, TWS (kts + unit + arrow inline)
+ HDG, COG (°-in-value + arrow inline)
+ Cells without: BSP, SOG (kts + unit, no arrow)
+ -->
<androidx.gridlayout.widget.GridLayout
android:id="@+id/instrument_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
+ android:layout_marginTop="12dp"
app:columnCount="3"
- app:rowCount="3"
- app:layout_constraintTop_toBottomOf="@id/drag_handle">
+ app:rowCount="2"
+ app:layout_constraintTop_toBottomOf="@id/drag_handle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
- <!-- Wind: AWS -->
+ <!-- AWS -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -35,10 +49,28 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="AWS" />
- <TextView android:id="@+id/value_aws" style="@style/InstrumentPrimaryValue" tools:text="12.5" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_aws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="18.2" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_aws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
</LinearLayout>
- <!-- Compass: HDG -->
+ <!-- HDG: ° is part of the value string -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -47,10 +79,27 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="HDG" />
- <TextView android:id="@+id/value_hdg" style="@style/InstrumentPrimaryValue" tools:text="225" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_hdg"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="247°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_hdg"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
</LinearLayout>
- <!-- Performance: BSP -->
+ <!-- BSP: no direction -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -59,10 +108,21 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="BSP" />
- <TextView android:id="@+id/value_bsp" style="@style/InstrumentPrimaryValue" tools:text="6.2" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_bsp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="6.8" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
</LinearLayout>
- <!-- Wind: TWS -->
+ <!-- TWS -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -71,10 +131,28 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="TWS" />
- <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="15.0" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_tws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="15.5" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_tws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
</LinearLayout>
- <!-- Compass: COG -->
+ <!-- COG: ° is part of the value string -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -83,10 +161,27 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="COG" />
- <TextView android:id="@+id/value_cog" style="@style/InstrumentPrimaryValue" tools:text="230" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_cog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="253°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_cog"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
</LinearLayout>
- <!-- Performance: SOG -->
+ <!-- SOG: no direction -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -95,72 +190,232 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="SOG" />
- <TextView android:id="@+id/value_sog" style="@style/InstrumentPrimaryValue" tools:text="6.5" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="7.1" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
</LinearLayout>
- <!-- VMG -->
+ </androidx.gridlayout.widget.GridLayout>
+
+ <!-- Depth + Baro side by side -->
+ <LinearLayout
+ android:id="@+id/expanded_instruments"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="4dp"
+ app:layout_constraintTop_toBottomOf="@id/instrument_grid"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="VMG" />
- <TextView android:id="@+id/value_vmg" style="@style/InstrumentPrimaryValue" tools:text="4.2" />
+ <TextView style="@style/InstrumentLabel" android:text="Depth" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_depth"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="42.0" />
+ <TextView style="@style/InstrumentUnit" android:text="ft" />
+ </LinearLayout>
</LinearLayout>
- <!-- Depth -->
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="#2B2930" />
+
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Depth" />
- <TextView android:id="@+id/value_depth" style="@style/InstrumentPrimaryValue" tools:text="12.0" />
+ <TextView style="@style/InstrumentLabel" android:text="Baro" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_baro"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="1013" />
+ <TextView style="@style/InstrumentUnit" android:text="hPa" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Animated wave divider -->
+ <org.terst.nav.ui.WaveView
+ android:id="@+id/wave_divider"
+ android:layout_width="match_parent"
+ android:layout_height="72dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/expanded_instruments"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Forecast section (ocean) -->
+ <LinearLayout
+ android:id="@+id/forecast_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:background="#0D2137"
+ android:paddingTop="14dp"
+ android:paddingBottom="20dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/wave_divider"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <!-- Current -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Current" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_curr_spd"
+ style="@style/ForecastValue"
+ tools:text="0.8" />
+ <TextView style="@style/ForecastUnit" android:text="kts" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_curr"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_curr"
+ style="@style/ForecastBearing"
+ tools:text="185°" />
+ </LinearLayout>
</LinearLayout>
- <!-- Polar % -->
+ <!-- Waves -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Polar %" />
- <TextView android:id="@+id/value_polar_pct" style="@style/InstrumentPrimaryValue" tools:text="95%" />
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_wave_ht"
+ style="@style/ForecastValue"
+ tools:text="3.5" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_waves"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_waves"
+ style="@style/ForecastBearing"
+ tools:text="275°" />
+ </LinearLayout>
</LinearLayout>
- </androidx.gridlayout.widget.GridLayout>
+ <!-- Swell -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Swell" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_swell_ht"
+ style="@style/ForecastValue"
+ tools:text="5.2" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_swell"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_swell"
+ style="@style/ForecastBearing"
+ tools:text="260°" />
+ <TextView
+ android:id="@+id/value_swell_per"
+ style="@style/ForecastPeriod"
+ tools:text="· 14s" />
+ </LinearLayout>
+ </LinearLayout>
- <!-- Additional Detail (Visible when expanded) -->
- <TextView
- android:id="@+id/label_baro"
- style="@style/InstrumentLabel"
- android:text="Barometer"
- android:layout_marginTop="24dp"
- app:layout_constraintTop_toBottomOf="@id/instrument_grid"
- app:layout_constraintStart_toStartOf="parent" />
-
- <TextView
- android:id="@+id/value_baro"
- style="@style/InstrumentPrimaryValue"
- tools:text="1013.2 hPa"
- app:layout_constraintTop_toBottomOf="@id/label_baro"
- app:layout_constraintStart_toStartOf="parent" />
-
- <org.terst.nav.PolarDiagramView
- android:id="@+id/polar_diagram_view"
- android:layout_width="0dp"
- android:layout_height="0dp"
- android:layout_marginTop="24dp"
- app:layout_constraintDimensionRatio="1:1"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/value_baro"
- app:layout_constraintBottom_toBottomOf="parent" />
+ </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
new file mode 100644
index 0000000..c424606
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
@@ -0,0 +1,104 @@
+<?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="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="32dp"
+ android:background="?attr/colorSurface">
+
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp"
+ android:text="MAP LAYERS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary" />
+
+ <!-- Base map selection -->
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_base"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ app:singleSelection="true"
+ app:selectionRequired="true"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_satellite"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Satellite" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_charts"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Charts" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_hybrid"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Hybrid" />
+
+ </com.google.android.material.chip.ChipGroup>
+
+ <!-- Wind divider -->
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginBottom="16dp"
+ android:background="@color/md_theme_surfaceVariant" />
+
+ <!-- Wind toggle -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind overlay"
+ android:textSize="15sp"
+ android:textColor="@color/instrument_text_normal" />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="OpenWeatherMap wind speed"
+ android:textSize="12sp"
+ android:textColor="@color/instrument_text_secondary" />
+ </LinearLayout>
+
+ <com.google.android.material.switchmaterial.SwitchMaterial
+ android:id="@+id/switch_wind"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
new file mode 100644
index 0000000..a26b76e
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="32dp"
+ android:background="?attr/colorSurface">
+
+ <!-- Drag handle -->
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <!-- Title -->
+ <TextView
+ android:id="@+id/summary_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary"
+ tools:text="Track · 06 Apr 14:32" />
+
+ <!-- Distance + Duration row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Distance" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_distance"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="12.3" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="nm" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Duration" />
+ <TextView
+ android:id="@+id/summary_duration"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2h 14m" />
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Speed row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Max Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_max_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="8.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="5.5" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Wind + Waves row (conditionally visible) -->
+ <LinearLayout
+ android:id="@+id/summary_conditions_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <LinearLayout
+ android:id="@+id/summary_wind_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Wind" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wind"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="14" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/summary_wave_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wave"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="ft" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
index b29fb08..e7fc15d 100644
--- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
@@ -5,9 +5,9 @@
android:icon="@drawable/ic_map"
android:title="Map" />
<item
- android:id="@+id/nav_instruments"
- android:icon="@drawable/ic_instruments"
- android:title="Instruments" />
+ android:id="@+id/nav_layers"
+ android:icon="@drawable/ic_layers"
+ android:title="Layers" />
<item
android:id="@+id/nav_log"
android:icon="@drawable/ic_log"
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index b380d2d..eb62cb2 100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -16,13 +16,13 @@
<color name="md_theme_errorContainer">#FFDAD6</color>
<color name="md_theme_onErrorContainer">#410002</color>
- <color name="md_theme_background">#FDFBFF</color>
- <color name="md_theme_onBackground">#1A1C1E</color>
- <color name="md_theme_surface">#FDFBFF</color>
- <color name="md_theme_onSurface">#1A1C1E</color>
- <color name="md_theme_surfaceVariant">#E0E2EC</color>
- <color name="md_theme_onSurfaceVariant">#44474E</color>
- <color name="md_theme_outline">#74777F</color>
+ <color name="md_theme_background">#1C1B1F</color>
+ <color name="md_theme_onBackground">#E6E1E5</color>
+ <color name="md_theme_surface">#1C1B1F</color>
+ <color name="md_theme_onSurface">#E6E1E5</color>
+ <color name="md_theme_surfaceVariant">#49454F</color>
+ <color name="md_theme_onSurfaceVariant">#CAC4D0</color>
+ <color name="md_theme_outline">#938F99</color>
<!-- Legacy / Functional Colors -->
<color name="black">#FF000000</color>
@@ -32,12 +32,12 @@
<color name="accent">#FF6D00</color>
<!-- Instrument Specific -->
- <color name="instrument_text_normal">#1A1C1E</color>
- <color name="instrument_text_secondary">#44474E</color>
- <color name="instrument_text_alarm">#BA1A1A</color>
- <color name="instrument_text_stale">#74777F</color>
- <color name="instrument_background">#FDFBFF</color>
- <color name="instrument_card_background">#F1F4F9</color>
+ <color name="instrument_text_normal">#E6E1E5</color>
+ <color name="instrument_text_secondary">#9A94A0</color>
+ <color name="instrument_text_alarm">#FF5449</color>
+ <color name="instrument_text_stale">#49454F</color>
+ <color name="instrument_background">#1C1B1F</color>
+ <color name="instrument_card_background">#2B2930</color>
<color name="mob_button_background">#BA1A1A</color>
<color name="anchor_button_background">#005FB0</color>
diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml
index 1b65ea9..21ff47a 100755
--- a/android-app/app/src/main/res/values/dimens.xml
+++ b/android-app/app/src/main/res/values/dimens.xml
@@ -1,9 +1,11 @@
<?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="text_size_instrument_primary">26sp</dimen>
+ <dimen name="text_size_instrument_label">10sp</dimen>
+ <dimen name="text_size_instrument_unit">10sp</dimen>
+ <dimen name="text_size_forecast_value">22sp</dimen>
+ <dimen name="text_size_forecast_label">10sp</dimen>
+ <dimen name="text_size_forecast_bearing">12sp</dimen>
<dimen name="instrument_margin">8dp</dimen>
<dimen name="instrument_padding">4dp</dimen>
</resources>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
index b18f4a8..8baa509 100755
--- a/android-app/app/src/main/res/values/themes.xml
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -24,7 +24,7 @@
<item name="colorOnErrorContainer">@color/md_theme_onErrorContainer</item>
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorSurface</item>
- <item name="android:windowLightStatusBar">true</item>
+ <item name="android:windowLightStatusBar">false</item>
</style>
<!-- Night Vision Theme (Stays Dark and Red) -->
@@ -45,8 +45,9 @@
<item name="android:textColor">@color/instrument_text_secondary</item>
<item name="android:textSize">@dimen/text_size_instrument_label</item>
<item name="android:textAllCaps">true</item>
- <item name="android:letterSpacing">0.1</item>
- <item name="android:textStyle">bold</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
</style>
<style name="InstrumentPrimaryValue" parent="android:Widget.TextView">
@@ -54,15 +55,70 @@
<item name="android:layout_height">wrap_content</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>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
<item name="android:includeFontPadding">false</item>
</style>
- <style name="InstrumentSecondaryLabel" parent="android:Widget.TextView">
- <item name="android:textColor">@color/instrument_text_secondary</item>
- <item name="android:textSize">@dimen/text_size_instrument_secondary</item>
+ <style name="InstrumentUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#6B6070</item>
+ <item name="android:textSize">@dimen/text_size_instrument_unit</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">3dp</item>
+ </style>
+
+ <style name="ForecastLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#5B9EC2</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ </style>
+
+ <style name="ForecastValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#CAE8FF</item>
+ <item name="android:textSize">@dimen/text_size_forecast_value</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
</style>
-
+
+ <style name="ForecastUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#3B6785</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">2dp</item>
+ </style>
+
+ <style name="ForecastBearing" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#6FC3E8</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+ </style>
+
+ <style name="ForecastPeriod" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#4A7FA0</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+ </style>
+
<style name="InstrumentCard" parent="Widget.Material3.CardView.Elevated">
<item name="cardBackgroundColor">@color/instrument_card_background</item>
<item name="cardCornerRadius">12dp</item>