From c8a1e81faec6663b258898c109db1f63e57b07eb Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 6 Apr 2026 06:49:34 +0000 Subject: feat(ui): wire redesigned instrument sheet — InstrumentHandler rewrite + MainActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstrumentHandler: direction arrows (SKY/OCEAN palettes), WaveView state, metres→feet conversion, bearing formatting, all helpers top-level for TDD. MainActivity: setupHandlers wires all new view refs; observeDataSources passes cogBearingDeg, twsBearingDeg, raw metres to handler; depth collector wired from nmeaDepthDataFlow. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 78 ++++++---- .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 172 +++++++++++++++------ .../org/terst/nav/ui/InstrumentHandlerTest.kt | 39 +++++ 3 files changed, 217 insertions(+), 72 deletions(-) create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt 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 023cb94..e5d3080 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 @@ -181,31 +181,42 @@ 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), + // Instrument TextViews + 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), - valueCurrSpd = findViewById(R.id.value_curr_spd), - valueCurrDir = null, - valueWaveHt = findViewById(R.id.value_wave_ht), - valueWaveDir = null, - valueSwellHt = findViewById(R.id.value_swell_ht), - valueSwellPer = findViewById(R.id.value_swell_per) + valueBaro = findViewById(R.id.value_baro), + // Instrument arrows + arrowAws = findViewById(R.id.arrow_aws), + arrowTws = findViewById(R.id.arrow_tws), + arrowHdg = findViewById(R.id.arrow_hdg), + arrowCog = findViewById(R.id.arrow_cog), + // Forecast TextViews + 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), + // Forecast arrows + arrowCurr = findViewById(R.id.arrow_curr), + arrowWaves = findViewById(R.id.arrow_waves), + arrowSwell = findViewById(R.id.arrow_swell), + // Forecast bearing labels + bearingCurr = findViewById(R.id.bearing_curr), + bearingWaves = findViewById(R.id.bearing_waves), + bearingSwell = findViewById(R.id.bearing_swell), + // Wave view + waveView = findViewById(R.id.wave_divider) ) instrumentHandler?.updateDisplay( aws = "—", tws = "—", hdg = "—", cog = "—", bsp = "—", sog = "—", - depth = "—", baro = "—" - ) - instrumentHandler?.updateConditions( - currSpd = "—", currDir = "—", - waveHt = "—", waveDir = "—", - swellHt = "—", swellPer = "—" + baro = "—" ) + instrumentHandler?.updateConditions(currSpd = "—") } // Helper to convert dp to px @@ -296,11 +307,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { LocationService.locationFlow.collect { gpsData -> 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) + cog = "%.0f°".format(Locale.getDefault(), gpsData.cog), + cogBearingDeg = gpsData.cog.toFloat() ) if (!conditionsLoaded) { conditionsLoaded = true @@ -319,16 +330,27 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { viewModel.marineConditions.collect { c -> if (c == null) return@collect instrumentHandler?.updateDisplay( - tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—" + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, + twsBearingDeg = c.windDirDeg?.toFloat() ) 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) } ?: "—" + 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 + instrumentHandler?.updateWaveState(swellHtFt, swellPeriod, windHtFt) + } + } + lifecycleScope.launch { + LocationService.nmeaDepthDataFlow.collect { depthData -> + instrumentHandler?.updateDisplay(depthM = depthData.depthMeters) } } lifecycleScope.launch { 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 370d8cf..84815ce 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,66 +1,150 @@ package org.terst.nav.ui import android.widget.TextView +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, + // ── 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, - private val valueCurrSpd: TextView, - private val valueCurrDir: TextView?, - private val valueWaveHt: TextView, - private val valueWaveDir: TextView?, - private val valueSwellHt: TextView, - private val valueSwellPer: 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 ─────────────────────────────────── + 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 the given instruments. Null arguments leave the current value unchanged. + * 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, - depth: String? = null, - baro: 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 } - depth?.let { valueDepth.text = it } - baro?.let { valueBaro.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 forecast conditions section (Curr, Wave, Swell). - * Null arguments leave the current value unchanged. + * Updates the forecast section. + * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally. */ fun updateConditions( - currSpd: String? = null, - currDir: String? = null, - waveHt: String? = null, - waveDir: String? = null, - swellHt: String? = null, - swellPer: String? = null + 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 } - 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 } + 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 WaveView with current sea state. Call whenever new forecast data arrives. + * All inputs in feet. + */ + fun updateWaveState( + swellHeightFt: Float, + swellPeriodSec: Float, + windWaveHeightFt: Float + ) { + waveView.swellHeightFt = swellHeightFt + waveView.swellPeriodSec = swellPeriodSec + waveView.windWaveHeightFt = windWaveHeightFt } } diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt new file mode 100644 index 0000000..a749ba2 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt @@ -0,0 +1,39 @@ +package org.terst.nav.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class InstrumentHandlerTest { + + @Test + fun `metresToFeet converts correctly`() { + assertEquals(3.28f, metresToFeet(1.0).toFloat(), 0.01f) + } + + @Test + fun `metresToFeet zero returns zero`() { + assertEquals(0f, metresToFeet(0.0).toFloat(), 0.001f) + } + + @Test + fun `formatFt formats to one decimal`() { + val result = formatFt(3.28084, Locale.US) + assertEquals("3.3", result) + } + + @Test + fun `formatBearing appends degree symbol`() { + assertEquals("275°", formatBearing(275.0, Locale.US)) + } + + @Test + fun `formatBearing rounds to zero decimals`() { + assertEquals("123°", formatBearing(123.4, Locale.US)) + } + + @Test + fun `formatPeriod appends s`() { + assertEquals("· 14s", formatPeriod(14.0, Locale.US)) + } +} -- cgit v1.2.3