diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md | 1407 |
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) | |
