From 4daf9dfcd00844075768e5b0d1dd7a17002a26d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 09:30:55 +0000 Subject: Area conditions HUD redesign: map-center conditions refresh + boat HUD strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persistent top-of-map HUD strip (SOG/COG/BSP/Depth) replaces instrument grid in bottom sheet - Bottom sheet now shows area conditions only (Wind/Temp/Baro + wave forecast) — always visible - loadConditions() fires on every camera idle event so panning the map refreshes conditions - Crosshair appears at map center while panning; hides when following GPS - Added tempC field to MarineConditions (already fetched from Open-Meteo hourly) - InstrumentHandler slimmed to area conditions only; updateDisplay() removed - LocationService pipes nmeaBoatSpeedData from NmeaStreamManager to companion flow https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../main/kotlin/org/terst/nav/LocationService.kt | 11 ++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 86 ++++++---- .../org/terst/nav/data/model/MarineConditions.kt | 1 + .../terst/nav/data/repository/WeatherRepository.kt | 1 + .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 86 ++++------ .../app/src/main/res/drawable/ic_crosshair.xml | 28 +++ .../app/src/main/res/layout/activity_main.xml | 24 +++ .../main/res/layout/layout_instruments_sheet.xml | 187 +++------------------ .../app/src/main/res/layout/layout_nav_hud.xml | 161 ++++++++++++++++++ 9 files changed, 329 insertions(+), 256 deletions(-) create mode 100644 android-app/app/src/main/res/drawable/ic_crosshair.xml create mode 100644 android-app/app/src/main/res/layout/layout_nav_hud.xml (limited to 'android-app/app/src/main') 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 b18db8d..41fb2ec 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 @@ -23,6 +23,7 @@ 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.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData @@ -128,6 +129,13 @@ class LocationService : Service() { } } + // Collect NMEA Boat Speed Data + serviceScope.launch { + nmeaStreamManager.nmeaBoatSpeedData.collectLatest { bspData -> + _nmeaBoatSpeedDataFlow.emit(bspData) + } + } + locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> @@ -393,6 +401,9 @@ class LocationService : Service() { private val _nmeaHeadingDataFlow = MutableSharedFlow(replay = 1) val nmeaHeadingDataFlow: SharedFlow get() = _nmeaHeadingDataFlow + private val _nmeaBoatSpeedDataFlow = MutableSharedFlow(replay = 1) + val nmeaBoatSpeedData: SharedFlow get() = _nmeaBoatSpeedDataFlow + private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) val currentPowerMode: StateFlow 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 6d02014..21aa55b 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 @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout +import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -57,6 +58,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView + private lateinit var mapCrosshair: View + + // HUD TextViews — wired to layout_nav_hud.xml + private lateinit var hudSog: TextView + private lateinit var hudCog: TextView + private lateinit var hudBsp: TextView + private lateinit var hudDepth: TextView + private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -88,6 +97,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) + mapCrosshair = findViewById(R.id.map_crosshair) + + // HUD views (inside the included layout_nav_hud) + hudSog = findViewById(R.id.hud_sog) + hudCog = findViewById(R.id.hud_cog) + hudBsp = findViewById(R.id.hud_bsp) + hudDepth = findViewById(R.id.hud_depth) + + // Initialise HUD with dashes + hudSog.text = "—" + hudCog.text = "—" + hudBsp.text = "—" + hudDepth.text = "—" setupBottomSheet() setupBottomNavigation() @@ -184,12 +206,6 @@ 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) @@ -222,18 +238,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { 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), - valueDepth = findViewById(R.id.value_depth), - 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), + valueTws = findViewById(R.id.value_tws), + arrowTws = findViewById(R.id.arrow_tws), + bearingTws = findViewById(R.id.bearing_tws), + valueTemp = findViewById(R.id.value_temp), + valueBaro = findViewById(R.id.value_baro), valueCurrSpd = findViewById(R.id.value_curr_spd), valueWaveHt = findViewById(R.id.value_wave_ht), valueSwellHt = findViewById(R.id.value_swell_ht), @@ -246,12 +255,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bearingSwell = findViewById(R.id.bearing_swell), waveView = findViewById(R.id.wave_divider) ) - instrumentHandler?.updateDisplay( - aws = "—", tws = "—", hdg = "—", - cog = "—", bsp = "—", sog = "—", - baro = "—" + instrumentHandler?.updateConditions( + tws = "—", baro = "—", currSpd = "—" ) - instrumentHandler?.updateConditions(currSpd = "—") } private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() @@ -302,11 +308,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { mapHandler!!.isFollowing.collect { following -> + mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE if (following) { fadeOut(fabRecenter, gone = true) - fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack) + fadeIn(bottomNav, fabMob, fabRecordTrack) } else { - fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true) + fadeOut(bottomNav, fabMob, fabRecordTrack, gone = true) fadeIn(fabRecenter) } } @@ -339,6 +346,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { corners.minOf { it.longitude }, corners.maxOf { it.longitude } ) } + // Refresh conditions for current map center (works in both follow + pan mode) + maplibreMap.cameraPosition.target?.let { center -> + viewModel.loadConditions(center.latitude, center.longitude) + } } maplibreMap.addOnMapLongClickListener { _ -> @@ -360,11 +371,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) 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() - ) + // HUD — SOG and COG come from GPS + hudSog.text = "%.1f".format(Locale.getDefault(), gpsData.sog) + hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) if (!conditionsLoaded) { conditionsLoaded = true viewModel.loadConditions(gpsData.latitude, gpsData.longitude) @@ -391,18 +400,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.barometerStatus.collect { status -> if (status.history.isNotEmpty()) { - instrumentHandler?.updateDisplay(baro = status.formatPressure()) + instrumentHandler?.updateConditions(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( + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, + twsBearingDeg = c.windDirDeg?.toFloat(), + tempC = c.tempC, currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—", currDirDeg = c.currentDirDeg?.toFloat(), waveHeightM = c.waveHeightM, @@ -420,7 +428,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } lifecycleScope.launch { LocationService.nmeaDepthDataFlow.collect { depthData -> - instrumentHandler?.updateDisplay(depthM = depthData.depthMeters) + // Update HUD depth (converted to feet) + hudDepth.text = "%.1f".format(Locale.getDefault(), depthData.depthMeters * 3.28084) + } + } + lifecycleScope.launch { + LocationService.nmeaBoatSpeedData.collect { bspData -> + hudBsp.text = "%.1f".format(Locale.getDefault(), bspData.bspKnots) } } lifecycleScope.launch { 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 index 3cde023..557d6a2 100644 --- 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 @@ -7,6 +7,7 @@ package org.terst.nav.data.model data class MarineConditions( val windSpeedKt: Double?, val windDirDeg: Double?, + val tempC: Double?, val waveHeightM: Double?, val waveDirDeg: Double?, val swellHeightM: 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 c79366d..dc47a20 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 @@ -59,6 +59,7 @@ class WeatherRepository( MarineConditions( windSpeedKt = w.windspeed10m.firstOrNull(), windDirDeg = w.winddirection10m.firstOrNull(), + tempC = w.temperature2m.firstOrNull(), waveHeightM = m.waveHeight.firstOrNull(), waveDirDeg = m.waveDirection.firstOrNull(), swellHeightM = m.swellWaveHeight.firstOrNull(), 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 cb59a3a..48ebb3b 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 @@ -25,10 +25,11 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = // ── InstrumentHandler ──────────────────────────────────────────────────────── /** - * Drives all text fields, direction arrows, and the wave view in the - * instrument bottom sheet. + * Drives the area-conditions bottom sheet: wind header row, wave view, + * and the forecast section (current / waves / swell). * - * Forecast [DirectionArrowView] instances are initialised with OCEAN style. + * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly + * in MainActivity via the HUD strip. * * Units contract: * - Speed values: pre-formatted strings in knots (caller's responsibility) @@ -37,34 +38,26 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = * into bearing TextViews by this class */ class InstrumentHandler( - // ── 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 valueBaro: TextView, - // ── Instrument section DirectionArrowViews ─────────────────────── - private val arrowAws: DirectionArrowView, - private val arrowTws: DirectionArrowView, - private val arrowHdg: DirectionArrowView, - private val arrowCog: DirectionArrowView, - // ── Forecast section TextViews ─────────────────────────────────── + // ── Conditions header ──────────────────────────────────────────────── + private val valueTws: TextView, + private val arrowTws: DirectionArrowView, + private val bearingTws: TextView, + private val valueTemp: TextView, + private val valueBaro: TextView, + // ── Forecast section TextViews ─────────────────────────────────────── private val valueCurrSpd: TextView, private val valueWaveHt: TextView, private val valueSwellHt: TextView, private val valueSwellPer: TextView, - // ── Forecast section DirectionArrowViews ───────────────────────── + // ── Forecast section DirectionArrowViews ───────────────────────────── private val arrowCurr: DirectionArrowView, private val arrowWaves: DirectionArrowView, private val arrowSwell: DirectionArrowView, - // ── Forecast section bearing TextViews ─────────────────────────── + // ── Forecast section bearing TextViews ─────────────────────────────── private val bearingCurr: TextView, private val bearingWaves: TextView, private val bearingSwell: TextView, - // ── Wave view ──────────────────────────────────────────────────── + // ── Wave view ──────────────────────────────────────────────────────── private val waveView: WaveView ) { init { @@ -74,48 +67,27 @@ class InstrumentHandler( } /** - * Updates instrument-section text and arrows. + * Updates all area-conditions fields. * Null arguments leave the current value unchanged. - * [depthM] is raw metres — converted to feet internally. - */ - fun updateDisplay( - 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 } - 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 forecast section. * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally. */ fun updateConditions( - currSpd: String? = null, - currDirDeg: Float? = null, - waveHeightM: Double? = null, - waveDirDeg: Float? = null, - swellHeightM: Double? = null, - swellDirDeg: Float? = null, + tws: String? = null, twsBearingDeg: Float? = null, + tempC: Double? = null, + baro: String? = null, + currSpd: String? = null, currDirDeg: Float? = null, + waveHeightM: Double? = null, waveDirDeg: Float? = null, + swellHeightM: Double? = null, swellDirDeg: Float? = null, swellPeriodS: Double? = null ) { + tws?.let { valueTws.text = it } + baro?.let { valueBaro.text = it } + tempC?.let { valueTemp.text = "%.0f".format(Locale.getDefault(), it) } + twsBearingDeg?.let { + arrowTws.bearing = it + bearingTws.text = formatBearing(it.toDouble()) + } + currSpd?.let { valueCurrSpd.text = it } currDirDeg?.let { arrowCurr.bearing = it diff --git a/android-app/app/src/main/res/drawable/ic_crosshair.xml b/android-app/app/src/main/res/drawable/ic_crosshair.xml new file mode 100644 index 0000000..609538e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_crosshair.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + 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 1741c62..5147506 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -29,6 +29,30 @@ android:clickable="false" android:focusable="false" /> + + + + + + - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - + + - + - + tools:text="18" /> + + android:background="@color/md_theme_outline" /> + diff --git a/android-app/app/src/main/res/layout/layout_nav_hud.xml b/android-app/app/src/main/res/layout/layout_nav_hud.xml new file mode 100644 index 0000000..4623bb1 --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- cgit v1.2.3