summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-03 07:38:22 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-03 07:38:22 +0000
commit9417a7c6b08da362ad97e85973b7570e05d4f0b5 (patch)
tree341837218b815bbabb6cd80c87781703b9e83c60 /android-app/app
parentbe56cf32a68ee1b0df2966ff39fd9751fd6afd7a (diff)
feat(instruments): add forecast wind, wave, swell and current from Open-Meteo
- Add swell params to MarineApiService request - Add swell fields to MarineHourly model - Add MarineConditions snapshot model - Add WeatherRepository.fetchCurrentConditions() (first forecast hour) - Add MainViewModel.loadConditions() + marineConditions StateFlow - Add Forecast section to instrument sheet: Curr / Wave / Swell - Populate TWS from forecast wind speed on first GPS fix - Trigger loadConditions() once on first GPS position received Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt34
-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/repository/WeatherRepository.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt28
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt15
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml66
8 files changed, 186 insertions, 3 deletions
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 c48cec2..7c0cd9e 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
@@ -182,13 +182,24 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
valueBsp = findViewById(R.id.value_bsp),
valueSog = findViewById(R.id.value_sog),
valueDepth = findViewById(R.id.value_depth),
- valueBaro = findViewById(R.id.value_baro)
+ valueBaro = findViewById(R.id.value_baro),
+ valueCurrSpd = findViewById(R.id.value_curr_spd),
+ valueCurrDir = findViewById(R.id.value_curr_dir),
+ valueWaveHt = findViewById(R.id.value_wave_ht),
+ valueWaveDir = findViewById(R.id.value_wave_dir),
+ valueSwellHt = findViewById(R.id.value_swell_ht),
+ valueSwellPer = findViewById(R.id.value_swell_per)
)
instrumentHandler?.updateDisplay(
aws = "—", tws = "—", hdg = "—",
cog = "—", bsp = "—", sog = "—",
depth = "—", baro = "—"
)
+ instrumentHandler?.updateConditions(
+ currSpd = "—", currDir = "—",
+ waveHt = "—", waveDir = "—",
+ swellHt = "—", swellPer = "—"
+ )
}
// Helper to convert dp to px
@@ -263,6 +274,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun observeDataSources() {
+ var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
@@ -273,6 +285,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
sog = "%.1f".format(Locale.getDefault(), sogKnots),
cog = "%.0f°".format(Locale.getDefault(), cogDeg)
)
+ if (!conditionsLoaded) {
+ conditionsLoaded = true
+ viewModel.loadConditions(gpsData.latitude, gpsData.longitude)
+ }
}
}
lifecycleScope.launch {
@@ -283,6 +299,22 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ viewModel.marineConditions.collect { c ->
+ if (c == null) return@collect
+ instrumentHandler?.updateDisplay(
+ tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—"
+ )
+ instrumentHandler?.updateConditions(
+ currSpd = c.currentSpeedKt?.let { "%.1f kn".format(Locale.getDefault(), it) } ?: "—",
+ currDir = c.currentDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—",
+ waveHt = c.waveHeightM?.let { "%.1f m".format(Locale.getDefault(), it) } ?: "—",
+ waveDir = c.waveDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—",
+ swellHt = c.swellHeightM?.let { "%.1f m".format(Locale.getDefault(), it) } ?: "—",
+ swellPer = c.swellPeriodS?.let { "%.0f s".format(Locale.getDefault(), it) } ?: "—"
+ )
+ }
+ }
+ lifecycleScope.launch {
LocationService.anchorWatchState.collect { state ->
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
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/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
index ee586a5..b70ea8c 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(
@@ -47,6 +48,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()
+ )
+ }
+
companion object {
/** Factory using the shared ApiClient singletons. */
fun create(): WeatherRepository {
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 7e09756..91582c0 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
@@ -13,7 +13,13 @@ class InstrumentHandler(
private val valueBsp: TextView,
private val valueSog: TextView,
private val valueDepth: TextView,
- private val valueBaro: TextView
+ private val valueBaro: TextView,
+ private val valueCurrSpd: TextView,
+ private val valueCurrDir: TextView,
+ private val valueWaveHt: TextView,
+ private val valueWaveDir: TextView,
+ private val valueSwellHt: TextView,
+ private val valueSwellPer: TextView
) {
/**
* Updates the text displays for the given instruments. Null arguments leave the current value unchanged.
@@ -37,4 +43,24 @@ class InstrumentHandler(
depth?.let { valueDepth.text = it }
baro?.let { valueBaro.text = it }
}
+
+ /**
+ * Updates the forecast conditions section (Curr, Wave, Swell).
+ * Null arguments leave the current value unchanged.
+ */
+ fun updateConditions(
+ currSpd: String? = null,
+ currDir: String? = null,
+ waveHt: String? = null,
+ waveDir: String? = null,
+ swellHt: String? = null,
+ swellPer: String? = null
+ ) {
+ currSpd?.let { valueCurrSpd.text = it }
+ currDir?.let { valueCurrDir.text = it }
+ waveHt?.let { valueWaveHt.text = it }
+ waveDir?.let { valueWaveDir.text = it }
+ swellHt?.let { valueSwellHt.text = it }
+ swellPer?.let { valueSwellPer.text = it }
+ }
}
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 0efff52..0431f31 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
@@ -9,6 +9,7 @@ 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
@@ -40,6 +41,9 @@ 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()
@@ -89,6 +93,17 @@ class MainViewModel(
}
/**
+ * 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 marine data for [lat]/[lon] in parallel.
* Called once the device location is known.
*/
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 c651ba2..a6b74b0 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
@@ -131,4 +131,70 @@
</LinearLayout>
+ <!-- Divider -->
+ <View
+ android:id="@+id/conditions_divider"
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="@color/md_theme_outline"
+ android:layout_marginTop="20dp"
+ app:layout_constraintTop_toBottomOf="@id/expanded_instruments"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <TextView
+ android:id="@+id/label_conditions"
+ style="@style/InstrumentLabel"
+ android:text="FORECAST"
+ android:layout_marginTop="12dp"
+ app:layout_constraintTop_toBottomOf="@id/conditions_divider"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <!-- Conditions: Curr | Wave | Swell (3 columns, value + sub-label) -->
+ <LinearLayout
+ android:id="@+id/conditions_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="8dp"
+ app:layout_constraintTop_toBottomOf="@id/label_conditions"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView style="@style/InstrumentLabel" android:text="Curr" />
+ <TextView android:id="@+id/value_curr_spd" style="@style/InstrumentPrimaryValue" tools:text="—" />
+ <TextView android:id="@+id/value_curr_dir" style="@style/InstrumentLabel" tools:text="—" />
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView style="@style/InstrumentLabel" android:text="Wave" />
+ <TextView android:id="@+id/value_wave_ht" style="@style/InstrumentPrimaryValue" tools:text="—" />
+ <TextView android:id="@+id/value_wave_dir" style="@style/InstrumentLabel" tools:text="—" />
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView style="@style/InstrumentLabel" android:text="Swell" />
+ <TextView android:id="@+id/value_swell_ht" style="@style/InstrumentPrimaryValue" tools:text="—" />
+ <TextView android:id="@+id/value_swell_per" style="@style/InstrumentLabel" tools:text="—" />
+ </LinearLayout>
+
+ </LinearLayout>
+
</androidx.constraintlayout.widget.ConstraintLayout>