# Instrument Sheet Visual Redesign — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Redesign the instrument bottom sheet with an animated wave divider, direction arrows, imperial units, updated typography, and touch-event containment. **Architecture:** Two new custom Views (`DirectionArrowView`, `WaveView`) are wired through an expanded `InstrumentHandler`. The layout is fully restructured; `MainActivity` is updated to pass numeric bearings and feet-converted heights. No new dependencies required. **Tech Stack:** Kotlin, Android View system, `Canvas`/`Paint`, `ValueAnimator`, Material3, ConstraintLayout, GridLayout --- ## File Map | File | Action | Responsibility | |---|---|---| | `layout_instruments_sheet.xml` | Rewrite | Cell structure, WaveView, forecast layout, touch containment | | `DirectionArrowView.kt` | Create | Draws rotating chevron arrow in a circle | | `WaveView.kt` | Create | Animated swell + wind wave canvas | | `InstrumentHandler.kt` | Rewrite | Drives all text, arrows, and wave view | | `MainActivity.kt` | Modify | Remove report buttons, convert units, wire new views | | `themes.xml` | Modify | Typography styles: weight 300, unit label, forecast styles | | `dimens.xml` | Modify | Updated font sizes | | `InstrumentHandlerTest.kt` | Create | Unit tests for formatting/conversion helpers | --- ### Task 1: Remove Report Buttons and Fix Touch-Through **Files:** - Modify: `android-app/app/src/main/res/layout/layout_instruments_sheet.xml` - Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` - [ ] **Step 1.1: Remove the three report views from the layout** In `layout_instruments_sheet.xml`, delete the entire `reports_divider` View, `label_reports` TextView, and `reports_row` LinearLayout (lines 199–248). Also add `android:clickable="true"` and `android:focusable="true"` to the root `ConstraintLayout` to prevent touch-through to the map. The root element should look like: ```xml ``` - [ ] **Step 1.2: Remove button click listeners from MainActivity** In `MainActivity.kt`, delete lines 95–101: ```kotlin // DELETE THESE: findViewById(R.id.btn_nav_pretrip).setOnClickListener { showReport(org.terst.nav.tripreport.PreTripReportFragment()) } findViewById(R.id.btn_nav_tripreport).setOnClickListener { showReport(org.terst.nav.tripreport.TripReportFragment()) } ``` Also remove the `MaterialButton` import if it becomes unused after this change. - [ ] **Step 1.3: Build and verify no compile errors** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` - [ ] **Step 1.4: Commit** ```bash git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml \ android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt git commit -m "feat(ui): remove report section from instrument sheet, fix touch-through" ``` --- ### Task 2: Update Typography Styles **Files:** - Modify: `android-app/app/src/main/res/values/themes.xml` - Modify: `android-app/app/src/main/res/values/dimens.xml` - [ ] **Step 2.1: Update dimens.xml** Replace the contents of `dimens.xml` with: ```xml 26sp 10sp 10sp 22sp 10sp 12sp 8dp 4dp ``` - [ ] **Step 2.2: Update and add styles in themes.xml** Replace the instrument styles block (lines 41–70) with: ```xml ``` - [ ] **Step 2.3: Build** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` - [ ] **Step 2.4: Commit** ```bash git add android-app/app/src/main/res/values/themes.xml \ android-app/app/src/main/res/values/dimens.xml git commit -m "feat(ui): update instrument sheet typography — weight 300, unit labels, forecast styles" ``` --- ### Task 3: Create DirectionArrowView **Files:** - Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt` - Create: `android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt` - [ ] **Step 3.1: Write the failing test** Create `android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt`: ```kotlin package org.terst.nav.ui import org.junit.Assert.assertEquals import org.junit.Test class DirectionArrowViewTest { @Test fun `bearing is normalised — values over 360 wrap`() { assertEquals(10f, normalizeBearing(370f), 0.001f) } @Test fun `bearing is normalised — negative values wrap`() { assertEquals(350f, normalizeBearing(-10f), 0.001f) } @Test fun `bearing is normalised — exactly 360 becomes 0`() { assertEquals(0f, normalizeBearing(360f), 0.001f) } } // Top-level helper extracted from DirectionArrowView for testability fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f ``` - [ ] **Step 3.2: Run test to verify it fails** ```bash cd android-app && ./gradlew test --tests "org.terst.nav.ui.DirectionArrowViewTest" 2>&1 | tail -20 ``` Expected: FAIL — `normalizeBearing` does not exist yet. - [ ] **Step 3.3: Create DirectionArrowView.kt** Create `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt`: ```kotlin 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 /** * A small circular direction indicator. Draws a notched chevron arrow * pointing in [bearing] degrees (0 = north/up, clockwise). * * Two pre-defined palettes: * - [ArrowStyle.SKY] — grey, for the instrument section * - [ArrowStyle.OCEAN] — blue, for the forecast section */ 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 // Circle outline canvas.drawCircle(cx, cy, r, circlePaint) // Notched chevron: tip at top in unrotated space 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() } } ``` - [ ] **Step 3.4: Run test to verify it passes** ```bash cd android-app && ./gradlew test --tests "org.terst.nav.ui.DirectionArrowViewTest" 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` and 3 tests passing. - [ ] **Step 3.5: Commit** ```bash git add android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt \ android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt git commit -m "feat(ui): add DirectionArrowView — rotating chevron compass indicator" ``` --- ### Task 4: Create WaveView **Files:** - Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt` WaveView is a canvas-drawn View; its correctness is verified visually. No pure-logic unit tests apply here. - [ ] **Step 4.1: Create WaveView.kt** Create `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt`: ```kotlin 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. * * The primary wave is driven by swell height and period; a secondary * high-frequency wave represents wind chop. Whitecaps appear at wind-wave * crests. The view self-animates via [postInvalidateOnAnimation]. * * Set [swellHeightFt], [swellPeriodSec], and [windWaveHeightFt] to update * the wave state when new forecast data arrives. */ 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() } 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() // Amplitudes scale with wave data, capped relative to view height val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.04f, h * 0.22f) val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.10f) 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 * 0.8) * swellAmp + sin((x / windWlen) * TWO_PI - t * 1.8 + 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 at wind-wave crests ───────────────────────────── val capPath = Path() x = windWlen * 0.5f while (x <= w) { val wv = sin((x / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp val wn = sin(((x + 3) / windWlen) * TWO_PI - t * 1.8 + 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 } } ``` - [ ] **Step 4.2: Build** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` - [ ] **Step 4.3: Commit** ```bash git add android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt git commit -m "feat(ui): add WaveView — animated swell/wind-wave canvas divider" ``` --- ### Task 5: Restructure layout_instruments_sheet.xml **Files:** - Rewrite: `android-app/app/src/main/res/layout/layout_instruments_sheet.xml` This is a full rewrite. The outer ConstraintLayout chains remain; the inner cell structure changes to use `DirectionArrowView` inline, and the forecast section is restructured. - [ ] **Step 5.1: Replace the layout file** Write the complete new `layout_instruments_sheet.xml`: ```xml ``` - [ ] **Step 5.2: Build** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | tail -30 ``` Expected: `BUILD SUCCESSFUL`. Expect resource ID errors for the old IDs that `InstrumentHandler` still references (`value_curr_dir`, `value_wave_dir`) — those are fixed in Task 6. - [ ] **Step 5.3: Commit** ```bash git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml git commit -m "feat(ui): restructure instrument sheet layout — inline arrows, WaveView, ocean forecast section" ``` --- ### Task 6: Refactor InstrumentHandler **Files:** - Rewrite: `android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt` - Create: `android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt` - [ ] **Step 6.1: Write failing tests for formatting helpers** Create `android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt`: ```kotlin 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)) } } ``` - [ ] **Step 6.2: Run tests to verify they fail** ```bash cd android-app && ./gradlew test --tests "org.terst.nav.ui.InstrumentHandlerTest" 2>&1 | tail -20 ``` Expected: FAIL — helpers not defined yet. - [ ] **Step 6.3: Rewrite InstrumentHandler.kt** Replace the entire contents of `InstrumentHandler.kt`: ```kotlin 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 ──────────────────────────────────────────────────────── /** * Drives all text fields, direction arrows, and the wave view in the * instrument bottom sheet. * * All [DirectionArrowView] instances in the forecast section are initialised * with [DirectionArrowView.ArrowStyle.OCEAN] style automatically. * * Units contract: * - Speed values: formatted strings in knots, already formatted by caller * - 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( // ── 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 ─────────────────────────────────── 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 instrument-section text and arrows. * Null arguments leave the current value unchanged. * * Heights should already be formatted strings (caller's responsibility * for baro/depth). Bearings are raw degrees; pass null to leave arrow * position 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. Speed strings are pre-formatted by the caller. Bearings * are raw degrees. */ 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 WaveView with current sea state data. * All inputs in feet. Call whenever new marine forecast data arrives. */ fun updateWaveState( swellHeightFt: Float, swellPeriodSec: Float, windWaveHeightFt: Float ) { waveView.swellHeightFt = swellHeightFt waveView.swellPeriodSec = swellPeriodSec waveView.windWaveHeightFt = windWaveHeightFt } } ``` - [ ] **Step 6.4: Run tests to verify they pass** ```bash cd android-app && ./gradlew test --tests "org.terst.nav.ui.InstrumentHandlerTest" 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL`, 6 tests passing. - [ ] **Step 6.5: Build (will fail until MainActivity is updated)** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | grep -E "error:|BUILD" | head -20 ``` Expected: compile errors in `MainActivity.kt` referencing old `InstrumentHandler` constructor and old `updateConditions` signature. That is expected — fixed in Task 7. - [ ] **Step 6.6: Commit** ```bash git add android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt \ android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt git commit -m "feat(ui): refactor InstrumentHandler — direction arrows, WaveView, feet conversion" ``` --- ### Task 7: Update MainActivity — Wire New Views and Fix Units **Files:** - Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` - [ ] **Step 7.1: Update setupHandlers() to wire all new view references** Replace the `setupHandlers()` function in `MainActivity.kt`: ```kotlin private fun setupHandlers() { instrumentHandler = InstrumentHandler( // 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), // 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 bearings 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 = "—", baro = "—" ) instrumentHandler?.updateConditions( currSpd = "—" ) } ``` - [ ] **Step 7.2: Update observeDataSources() — GPS collector** In `observeDataSources()`, update the GPS collector to pass bearings as floats and format COG with `°` embedded in the value. Replace the `locationFlow` collector block: ```kotlin lifecycleScope.launch { 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), cogBearingDeg = gpsData.cog.toFloat() ) if (!conditionsLoaded) { conditionsLoaded = true viewModel.loadConditions(gpsData.latitude, gpsData.longitude) } } } ``` - [ ] **Step 7.3: Update observeDataSources() — marine conditions collector** Replace the `viewModel.marineConditions` collector block: ```kotlin 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 ) // Drive wave animation with forecast data (convert M→ft here) 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) } } ``` - [ ] **Step 7.4: Wire depth display from NMEA flow** Add a new collector inside `observeDataSources()` (after the barometer collector): ```kotlin lifecycleScope.launch { LocationService.nmeaDepthDataFlow.collect { depthData -> instrumentHandler?.updateDisplay(depthM = depthData.depthMeters) } } ``` - [ ] **Step 7.5: Build — expect clean compile** ```bash cd android-app && ./gradlew assembleDebug 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` with no errors. - [ ] **Step 7.6: Run all unit tests** ```bash cd android-app && ./gradlew test 2>&1 | tail -30 ``` Expected: All tests pass. Verify `InstrumentHandlerTest` (6 tests) and `DirectionArrowViewTest` (3 tests) are listed. - [ ] **Step 7.7: Commit** ```bash git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt git commit -m "feat(ui): wire redesigned instrument sheet — direction arrows, wave state, depth display, feet units" ``` --- ## Self-Review Checklist | Spec requirement | Task | |---|---| | Wave divider — swell sine + wind noise + whitecaps | Task 4 (WaveView) | | Wave divider — sky gradient above, ocean below | Task 4 (WaveView.onSizeChanged) | | Wave divider data-driven (height, period) | Task 6 updateWaveState + Task 7 | | Direction arrows inline with numeric values | Task 5 layout + Task 6 InstrumentHandler | | Mini arrows in forecast section, ocean-tinted | Task 6 InstrumentHandler.init | | `°` as part of HDG/COG value, not unit label | Task 5 layout (no unit TextView for these), Task 7 COG format | | AWS/TWS direction arrows driven from forecast | Task 7 (twsBearingDeg from windDirDeg) | | Forecast cell layout: value / [arrow] bearing | Task 5 layout | | Swell period on direction line | Task 5 layout (value_swell_per in dir row) | | Depth in feet | Task 6 (metresToFeet in updateDisplay) | | Wave/swell heights in feet | Task 6 (metresToFeet in updateConditions) | | Remove reports section | Task 1 | | Prevent map touch-through | Task 1 (clickable/focusable on root) | | Depth now actually displayed (was unconnected) | Task 7 Step 7.4 | | "Waves" label (not "Wave") | Task 5 layout | | "Current" label (not "Curr") | Task 5 layout | | No "FORECAST" section header | Task 5 layout (absent) |