summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-05 07:25:11 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-05 07:25:11 +0000
commit94d04a3d892b9ad3dc093417d106d806b8d48ae0 (patch)
tree40f18e6ecbddef563a43555630020a0d25681306 /docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
parentf49d261944f9426d88f72a0c2d387b86ca47fc54 (diff)
docs: add instrument sheet visual redesign spec and implementation plan
Diffstat (limited to 'docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md')
-rw-r--r--docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md1407
1 files changed, 1407 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md b/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
new file mode 100644
index 0000000..e1a086b
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
@@ -0,0 +1,1407 @@
+# 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
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+```
+
+- [ ] **Step 1.2: Remove button click listeners from MainActivity**
+
+In `MainActivity.kt`, delete lines 95–101:
+```kotlin
+// DELETE THESE:
+findViewById<MaterialButton>(R.id.btn_nav_pretrip).setOnClickListener {
+ showReport(org.terst.nav.tripreport.PreTripReportFragment())
+}
+
+findViewById<MaterialButton>(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
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <dimen name="text_size_instrument_primary">26sp</dimen>
+ <dimen name="text_size_instrument_label">10sp</dimen>
+ <dimen name="text_size_instrument_unit">10sp</dimen>
+ <dimen name="text_size_forecast_value">22sp</dimen>
+ <dimen name="text_size_forecast_label">10sp</dimen>
+ <dimen name="text_size_forecast_bearing">12sp</dimen>
+ <dimen name="instrument_margin">8dp</dimen>
+ <dimen name="instrument_padding">4dp</dimen>
+</resources>
+```
+
+- [ ] **Step 2.2: Update and add styles in themes.xml**
+
+Replace the instrument styles block (lines 41–70) with:
+```xml
+<!-- Instrument Display Styles -->
+<style name="InstrumentLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">@color/instrument_text_secondary</item>
+ <item name="android:textSize">@dimen/text_size_instrument_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+</style>
+
+<style name="InstrumentPrimaryValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">@color/instrument_text_normal</item>
+ <item name="android:textSize">@dimen/text_size_instrument_primary</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
+</style>
+
+<style name="InstrumentUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#79747E</item>
+ <item name="android:textSize">@dimen/text_size_instrument_unit</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">3dp</item>
+</style>
+
+<style name="ForecastLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#5B9EC2</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+</style>
+
+<style name="ForecastValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#CAE8FF</item>
+ <item name="android:textSize">@dimen/text_size_forecast_value</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
+</style>
+
+<style name="ForecastUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#3B6785</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">2dp</item>
+</style>
+
+<style name="ForecastBearing" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#6FC3E8</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+</style>
+
+<style name="ForecastPeriod" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#4A7FA0</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+</style>
+
+<style name="InstrumentCard" parent="Widget.Material3.CardView.Elevated">
+ <item name="cardBackgroundColor">@color/instrument_card_background</item>
+ <item name="cardCornerRadius">12dp</item>
+ <item name="cardElevation">2dp</item>
+</style>
+```
+
+- [ ] **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
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+
+ <!-- Drag handle -->
+ <View
+ android:id="@+id/drag_handle"
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_marginTop="12dp"
+ android:background="@color/md_theme_outline"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!--
+ 3×2 grid: AWS/HDG/BSP top row, TWS/COG/SOG bottom row.
+ Cells with direction: AWS, TWS (kts + unit + arrow inline)
+ HDG, COG (°-in-value + arrow inline)
+ Cells without: BSP, SOG (kts + unit, no arrow)
+ -->
+ <androidx.gridlayout.widget.GridLayout
+ android:id="@+id/instrument_grid"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ app:columnCount="3"
+ app:rowCount="2"
+ app:layout_constraintTop_toBottomOf="@id/drag_handle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
+ <!-- AWS -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="AWS" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_aws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="18.2" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_aws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- HDG: ° is part of the value string -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="HDG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_hdg"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="247°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_hdg"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- BSP: no direction -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="BSP" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_bsp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="6.8" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- TWS -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="TWS" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_tws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="15.5" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_tws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- COG: ° is part of the value string -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="COG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_cog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="253°" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_cog"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- SOG: no direction -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ app:layout_columnWeight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="SOG" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="7.1" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </androidx.gridlayout.widget.GridLayout>
+
+ <!-- Depth + Baro side by side -->
+ <LinearLayout
+ android:id="@+id/expanded_instruments"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="4dp"
+ app:layout_constraintTop_toBottomOf="@id/instrument_grid"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Depth" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_depth"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="42.0" />
+ <TextView style="@style/InstrumentUnit" android:text="ft" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="#2B2930" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Baro" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_baro"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="1013" />
+ <TextView style="@style/InstrumentUnit" android:text="hPa" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Animated wave divider -->
+ <org.terst.nav.ui.WaveView
+ android:id="@+id/wave_divider"
+ android:layout_width="match_parent"
+ android:layout_height="72dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/expanded_instruments"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Forecast section (ocean) -->
+ <LinearLayout
+ android:id="@+id/forecast_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:background="#0D2137"
+ android:paddingTop="14dp"
+ android:paddingBottom="20dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/wave_divider"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <!-- Current -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Current" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_curr_spd"
+ style="@style/ForecastValue"
+ tools:text="0.8" />
+ <TextView style="@style/ForecastUnit" android:text="kts" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_curr"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_curr"
+ style="@style/ForecastBearing"
+ tools:text="185°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- Waves -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_wave_ht"
+ style="@style/ForecastValue"
+ tools:text="3.5" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_waves"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_waves"
+ style="@style/ForecastBearing"
+ tools:text="275°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <!-- Swell -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Swell" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="baseline">
+ <TextView
+ android:id="@+id/value_swell_ht"
+ style="@style/ForecastValue"
+ tools:text="5.2" />
+ <TextView style="@style/ForecastUnit" android:text="ft" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_swell"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_swell"
+ style="@style/ForecastBearing"
+ tools:text="260°" />
+ <TextView
+ android:id="@+id/value_swell_per"
+ style="@style/ForecastPeriod"
+ tools:text="· 14s" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
+```
+
+- [ ] **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) |