summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-23 23:10:11 +0000
committerClaude <noreply@anthropic.com>2026-04-23 23:10:11 +0000
commite1db9200e9d44c10450361cc8984a45b2eda87b7 (patch)
tree21bdfca02491e6ff4cde7a22673c821c0a03e108 /docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md
parent55509f579b4d074f01237dd90791b6d25aaec3cd (diff)
parenteb78d317c722234a7ef2c501c68c9aa730ec2758 (diff)
Merge origin/main — unite all work onto one branch
Brings in dev log (NavLogger), UnitPrefs, MapLayerManager, HUD views, conditions throttling, track save/load pipeline, improved ParticleWindView (antimeridian-aware, dynamic particle count), Snackbar error surfacing, and all other main-branch work from the prior session. Combined with master's hardware source flags, vessel registry, crew management, thermal alarm, CPA collision alerts, and track stats. Also documents primary branch policy in CLAUDE.md and .agent/config.md: all work merges to main, never master. https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW
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) |