diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-03 07:38:22 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-03 07:38:22 +0000 |
| commit | 9417a7c6b08da362ad97e85973b7570e05d4f0b5 (patch) | |
| tree | 341837218b815bbabb6cd80c87781703b9e83c60 /android-app/app/src | |
| parent | be56cf32a68ee1b0df2966ff39fd9751fd6afd7a (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/src')
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> |
