summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-09 09:39:20 +0000
committerClaude <noreply@anthropic.com>2026-04-09 09:39:20 +0000
commite109271f563327e461191c7c2988243c75cd58ab (patch)
treee8401f41bb7f96421175d242bee77245eb68c738
parent3cddcd3b0a8719da5e828734a1d3054cef803235 (diff)
parent5ee2dd8925afa858f466ae63db3f7df5c7516953 (diff)
merge: reconcile master particle-wind work with origin/main redesign
Merged divergent branches (common ancestor a9d87b6). Took origin/main as base (layer manager, recenter, quit button, GPX tracks, marine conditions, dark theme, user position layer, active/past track layers) and layered in particle wind simulation work: - MapHandler: added setupWindLayer, updateWindLayer, updateWindGridLayer; kept their user-pos layer, follow/recenter state, active+past tracks - WeatherRepository: kept their fetchCurrentConditions, added fetchWindGrid - MainViewModel: kept their track/marine/summary changes, added windGrid StateFlow, loadWindGrid, buildGrid - MainActivity: kept their full UI (layer picker, quit, recenter, fade animations), added particleWindView wiring, weatherLoaded guard, windArrow/windGrid observers - activity_main.xml: took their LinearLayout redesign, added ParticleWindView overlay inside ConstraintLayout https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX
-rw-r--r--.agent/worklog.md209
-rw-r--r--.claude/settings.local.json5
-rw-r--r--.github/workflows/android.yml6
-rw-r--r--.gitignore1
-rw-r--r--.remember/tmp/save-session.pid1
-rw-r--r--android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt18
-rw-r--r--android-app/app/src/main/AndroidManifest.xml3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt290
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt309
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt26
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt96
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt153
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt95
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt43
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt102
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt66
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt51
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt86
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt117
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt183
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt48
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt231
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt132
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt158
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt3
-rw-r--r--android-app/app/src/main/res/drawable/ic_layers.xml9
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml174
-rw-r--r--android-app/app/src/main/res/layout/fragment_pretrip_report.xml144
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml9
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml114
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml14
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml375
-rw-r--r--android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml104
-rw-r--r--android-app/app/src/main/res/layout/layout_track_summary_sheet.xml199
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml6
-rwxr-xr-xandroid-app/app/src/main/res/values/colors.xml26
-rwxr-xr-xandroid-app/app/src/main/res/values/dimens.xml10
-rwxr-xr-xandroid-app/app/src/main/res/values/themes.xml72
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt31
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt83
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt56
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt25
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt39
-rw-r--r--docs/superpowers/plans/2026-04-03-map-interaction.md256
-rw-r--r--docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md1407
-rw-r--r--docs/superpowers/specs/2026-04-03-map-interaction-design.md59
-rw-r--r--docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md156
-rw-r--r--scripts/.claude/ct-fix-weather-fallback.yaml64
-rw-r--r--scripts/.claude/ct-remove-mock-tidal.yaml83
-rw-r--r--scripts/.claude/ct-show-swell-direction.yaml75
-rw-r--r--scripts/.claude/ct-show-wind-direction.yaml92
-rw-r--r--scripts/.claude/ct-wind-wiring.txt156
-rw-r--r--scripts/.claude/ct-wire-anchor-to-map.yaml82
-rw-r--r--scripts/.claude/ct-wire-tidal-to-map.yaml75
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt14
86 files changed, 7021 insertions, 856 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index 7a4467f..deefddb 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -1,131 +1,95 @@
# SESSION_STATE.md
## Current Task Goal
-Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel, MapFragment, and MainActivity
-
-## Verified (2026-03-15)
-- All 41 tests GREEN: 22 GPS/NMEA + 16 AIS + 3 ViewModel AIS (via /tmp/ais-vm-test-runner/)
-- NmeaParser extended with MWV (wind), DBT (depth), HDG/HDM (heading) parsers
-- Sensor data classes added: WindData, DepthData, HeadingData
-- NmeaStreamManager added for TCP stream management
+Instrument Sheet Visual Redesign — IN PROGRESS (2026-04-06)
+
+**Plan:** `docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md`
+**Spec:** `docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md`
+
+### Progress
+- [x] Task 1: Remove report buttons + fix touch-through — **DONE** (commit `2d86c0b`)
+- [x] Task 2: Update typography styles — **DONE** (commit `e79b678`)
+- [ ] Task 3: Create `DirectionArrowView` — pending
+- [ ] Task 4: Create `WaveView` — pending
+- [ ] Task 5: Restructure `layout_instruments_sheet.xml` — pending
+- [ ] Task 6: Refactor `InstrumentHandler` — pending
+- [ ] Task 7: Update `MainActivity` — wire new views and fix units — pending
+
+### What's being built
+A visual redesign of the instrument bottom sheet:
+- Animated wave canvas divider (swell height/period + wind waves + whitecaps) replacing the plain divider between instruments and forecast
+- Sky-blue gradient above wave, ocean blue below
+- `DirectionArrowView`: 14dp inline chevron compass indicator in two palettes (SKY grey / OCEAN blue)
+- Direction arrows inline with numeric values in all relevant cells
+- `°` baked into HDG/COG values (not a separate unit label)
+- Forecast cells: value on line 1, [arrow] bearing on line 2 (swell adds period)
+- Imperial units: feet for depth, wave height, swell height (all converted from metres)
+- Forecast labels: "Current" (not "Curr"), "Waves" (not "Wave"), no section header
+- Touch-through prevention: clickable/focusable on root layout
+- Reports section removed
+
+### Key files
+| File | Status |
+|---|---|
+| `layout_instruments_sheet.xml` | Partially done (Task 1 applied; full restructure in Task 5) |
+| `DirectionArrowView.kt` | Not created yet |
+| `WaveView.kt` | Not created yet |
+| `InstrumentHandler.kt` | Not refactored yet |
+| `MainActivity.kt` | Task 1 applied; Task 7 changes pending |
+| `themes.xml` / `dimens.xml` | Done |
+
+---
+
+## Previous Task Goal
+Section 7.4 Trip Reporting & Enhanced Tracks — COMPLETE (2026-04-04)
+
+## Verified (2026-04-04)
+- Build Successful: `android-app/gradlew assembleDebug` passed.
+- UI Layout: `activity_main.xml` refactored to LinearLayout to prevent BottomNav overlap.
+- Track Differentiation: Active (solid) vs Past (dotted) tracks verified in `MapHandler.kt`.
+- Pre-Trip Logic: Sail suggestions reefing logic verified in `PreTripReportGenerator.kt`.
## Completed Items
-### [APPROVED] GpsPosition data class
+### [APPROVED] Trip Narrative Generator + Multi-Style AI (2026-04-04)
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt`: Logic for distance, speed, and stylized narratives (Professional, Adventurous, Journal, Pirate).
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt`: State management for narrative generation.
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt`: UI for viewing and selecting narrative styles.
+
+### [APPROVED] Pre-Trip Planning & Sail Suggestions (2026-04-04)
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt`: Routing and reefing suggestions based on wind/waves.
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt`: Domain models for boat profiles and suggestions.
+- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt`: UI for generating and viewing pre-trip plans.
+
+### [APPROVED] Enhanced Map & Track Visualization (2026-04-04)
+- `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`: `updateTrackLayer` now supports active and past tracks with distinct styles.
+- `android-app/app/src/main/res/layout/activity_main.xml`: Refactored to vertical LinearLayout root to stabilize BottomNav and FAB placement.
+- `android-app/app/src/main/res/layout/layout_instruments_sheet.xml`: Added "TRIP REPORTS & PLANNING" section for direct access from main view.
+- `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`: Wired Pre-Trip and Trip Report buttons from instrument sheet; added `showReport` navigation helper.
+- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Added `pastTracks` storage.
+- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Synchronized with main app implementation.
+
+### [APPROVED] GpsPosition data class (2026-03-15)
- File: `app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt`
-- Package: `org.terst.nav.gps`
- Fields: latitude, longitude, sog (knots), cog (degrees true), timestampMs
-### [APPROVED] GpsProvider / GpsListener interfaces
-- File: `app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt`
-- `GpsProvider`: start/stop, position property, addListener/removeListener
-- `GpsListener`: onPositionUpdate(GpsPosition), onFixLost()
-
-### [APPROVED] DeviceGpsProvider
-- File: `app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt`
+### [APPROVED] DeviceGpsProvider (2026-03-15)
- Wraps `LocationManager` with `GPS_PROVIDER`
-- Default interval: 1000ms (1 Hz); configurable via constructor
-- SOG: Location.speed (m/s) × 1.94384 → knots
-- COG: Location.bearing (degrees true, no conversion)
- Fix-lost timer: fires `onFixLost()` after 10s with no update
-- Thread-safe listener list (synchronized)
-- Android dependency: Context, LocationManager, Handler — device only
-
-### [APPROVED] FakeGpsProvider + GpsProviderTest (9 tests — all GREEN)
-- File: `app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt`
-- No Android dependencies — pure JVM
-- Tests:
- - `start sets started to true`
- - `stop sets started to false`
- - `listener receives position update`
- - `listener notified of fix lost`
- - `multiple listeners all receive position update`
- - `multiple listeners all notified of fix lost`
- - `removing listener stops notifications`
- - `position property reflects last simulated position`
- - `SOG conversion sanity check - 1 mps is approximately 1_94384 knots`
-
-## Build Notes
-- `app/build` and `app/.kotlin/sessions` are root-owned from a prior run.
- Tests were verified via direct `kotlinc` + `JUnitCore` invocation (all 9 pass).
- Full Gradle build requires fixing directory permissions: `chown -R www-data:www-data app/build app/.kotlin`
-- Pre-existing XML layout error in `activity_main.xml:300` (unrelated to GPS work)
-
-### [APPROVED] NmeaParser — RMC parser
-- File: `app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt`
+
+### [APPROVED] NmeaParser — RMC parser (2026-03-15)
- Parses any `*RMC` sentence (GP, GN, etc.)
-- Returns `null` for void status (V), malformed input, wrong sentence type
-- SOG/COG default to 0.0 when fields are empty
-- Latitude: positive = North, negative = South
-- Longitude: positive = East, negative = West
-- Timestamp: HHMMSS + DDMMYY → Unix epoch millis UTC (YY < 70 → 2000+YY)
-- No Android dependencies — pure JVM
-
-### [APPROVED] GpsPositionTest + NmeaParserTest (22 tests — all GREEN)
-- `app/src/test/kotlin/org/terst/nav/gps/GpsPositionTest.kt` (2 tests)
-- `app/src/test/kotlin/org/terst/nav/nmea/NmeaParserTest.kt` (11 tests)
-- `app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt` (9 tests, pre-existing)
-- All verified via direct `kotlinc` (1.9.22) + `JUnitCore` invocation
-
-### [APPROVED] AisVessel data class
-- File: `app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt`
-- Package: `org.terst.nav.ais`
-- Fields: mmsi, name, callsign, lat, lon, sog, cog, heading, vesselType, timestampMs
-- Note: `com/example/androidapp` tree is root-owned; files placed under `org/terst/nav/` (actual project package)
-
-### [APPROVED] CpaCalculator object
-- File: `app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt`
-- Flat-earth CPA/TCPA algorithm; returns (cpa_nm, tcpa_min)
-- Zero-relative-velocity guard: returns (currentDist, 0.0)
-
-### [APPROVED] AisVdmParser class
-- File: `app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt`
-- Parses !AIVDM/!AIVDO sentences; multi-part reassembly by seqId
-- Type 1/2/3: MMSI, SOG, lon, lat, COG, heading decoded
-- Type 5: MMSI, callsign (7 chars), name (20 chars), vesselType decoded; trailing '@'/' ' trimmed
-- Not-available sentinel handling: SOG=1023→0.0, COG=3600→0.0, lon=0x6791AC0→0.0, lat=0x3412140→0.0
-
-### [APPROVED] AIS Tests (16 tests — all GREEN)
-- `test-runner/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt` (4 tests)
-- `test-runner/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt` (4 tests)
-- `test-runner/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt` (8 tests)
-- Verification harness: `/tmp/ais-test-runner/` (JUnit5, com.example.androidapp package)
-- Production test files also in `android-app/app/src/test/kotlin/org/terst/nav/ais/`
-
-### [APPROVED] AisRepository class
-- File: `app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt`
-- Upserts position targets by MMSI; merges type-5 static data (name/callsign/vesselType)
-- Pending static map: holds type-5 data until position arrives for same MMSI
-- `evictStale(nowMs)`: removes vessels older than `staleTimeoutMs` (default 10 min)
-- `AisDataSource` interface (with Flow<String>) defined in same file
-
-### [APPROVED] AisHubApiService + AisHubVessel
-- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt`
-- Note: placed in `ais/` directory (writable) with package `org.terst.nav.data.api`
-- `AisHubVessel` data class with Moshi `@Json` and `@JsonClass(generateAdapter=true)` annotations
-- `AisHubApiService` Retrofit interface: GET /0/down with lat/lon bounding box params
-
-### [APPROVED] AisHubSource object
-- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt`
-- Converts `AisHubVessel` REST response to `AisVessel` domain objects
-- Returns null for non-numeric MMSI, lat, or lon; defaults sog/cog=0.0, heading=511, vesselType=0
-
-### [APPROVED] AIS Repository Tests (11 tests — all GREEN)
-- `app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt` (7 tests)
-- `app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt` (4 tests)
-- Harness: `/tmp/ais-repo-test-runner/` (JUnit5, org.terst.nav package, stub annotations for offline build)
-
-### [APPROVED] Section 7.3 AIS display — ViewModel + MapFragment integration (2026-03-15)
-- `MainViewModel.processAisSentence(sentence)` — delegates to AisRepository, updates `_aisTargets` StateFlow
-- `MainViewModel.refreshAisFromInternet(latMin, latMax, lonMin, lonMax, username, password)` — AISHub polling via inline Retrofit; skips if username empty
-- `MainViewModel.aisTargets: StateFlow<List<AisVessel>>` — exposed to observers
-- `AisRepository.ingestVessel(vessel)` — direct insert for internet-sourced vessels
-- `MapFragment.updateAisLayer(style, vessels)` — GeoJSON FeatureCollection, SymbolLayer "ais-vessels"; heading-based iconRotate; zoom-step text (visible at zoom ≥ 12)
-- `MapFragment.addShipArrowImage(style)` — rasterises ic_ship_arrow.xml into style
-- `ic_ship_arrow.xml` — pink arrow vector drawable for AIS icons
-- `MainActivity.startAisHardwareFeed(host, port)` — TCP stub reads !AIVDM lines, forwards to viewModel
-- `MainViewModelTest` — 3 new tests: valid type-1 adds vessel, same MMSI deduped, non-AIS stays empty
-- JVM test harness: `/tmp/ais-vm-test-runner/` (3 tests — all GREEN)
+- HHMMSS + DDMMYY → Unix epoch millis UTC
+
+### [APPROVED] AIS Integration (2026-03-15)
+- `AisVdmParser.kt`: Parses !AIVDM/!AIVDO (Types 1, 2, 3, 5).
+- `AisRepository.kt`: Upserts vessels and merges static data.
+- `AisHubSource.kt`: Integration with AISHub REST API.
+- `MapFragment`: GeoJSON layer for AIS vessels with rotation and labels.
+
+### [APPROVED] Track Recording (2026-03-25)
+- `MainViewModel`: TrackRepository wired; `addGpsPoint` enriches with environmental data.
+- `MainActivity`: FAB for toggling recording; MapHandler for rendering.
### [APPROVED] TrackRepository (2026-03-25)
- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`
@@ -141,14 +105,13 @@ Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel
- `drawable/ic_track_record.xml`: red dot record icon
## Next 3 Specific Steps
-1. **Persist track to GPX/Room** — export recorded track as GPX file or store in Room DB
-2. **Track stats in Log tab** — show elapsed time, distance, avg SOG while recording
-3. **AnchorWatchHandler UI** — wire `AnchorWatchHandler` fully into SafetyFragment (currently stub)
+1. **Unit Tests for Trip Generators** — Add JVM tests in `test-runner` for `TripReportGenerator` and `PreTripReportGenerator`.
+2. **Persist track to Room** — Replace in-memory `pastTracks` with a Room database for persistence across sessions.
+3. **AnchorWatchHandler UI** — Re-integrate `AnchorWatchHandler` UI into `activity_main.xml` and wire it to the Safety Dashboard.
## Scripts Added
-- `test-runner/` — standalone Kotlin/JVM Gradle project; runs all 22 GPS/NMEA tests without Android SDK
- - Command: `cd /workspace/nav/test-runner && GRADLE_USER_HOME=/tmp/gradle-home ./gradlew test`
+- `test-runner/` — standalone Kotlin/JVM Gradle project for fast test cycles.
## Process Improvements
-- Gradle builds blocked by Android SDK requirement; added `test-runner/` JVM-only subproject as reliable test runner
-- All 22 tests verified GREEN via `test-runner/` JVM project (2026-03-14) \ No newline at end of file
+- Stabilized UI layout by moving navigation out of the `CoordinatorLayout`.
+- Duplicated core logic into `test-runner` to bypass Android SDK requirements for logic testing.
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 1e2b206..e581baf 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -58,7 +58,10 @@
"Bash(adb logcat:*)",
"Bash(grep -v \"^--$\")",
"Bash(./gradlew assembleDebug)",
- "Skill(commit-commands:commit-push-pr)"
+ "Bash(gh:*)",
+ "Skill(commit-commands:commit-push-pr)",
+ "Skill(code-review:code-review)",
+ "Bash(python3 -m json.tool)"
]
}
}
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 6dd744d..2c28410 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -2,9 +2,9 @@ name: Android CI/CD
on:
push:
- branches: [ master ]
+ branches: [ main ]
pull_request:
- branches: [ master ]
+ branches: [ main ]
jobs:
build:
@@ -36,7 +36,7 @@ jobs:
android-app/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
- name: upload artifact to Firebase App Distribution
- if: github.ref == 'refs/heads/master'
+ if: github.ref == 'refs/heads/main'
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
diff --git a/.gitignore b/.gitignore
index 0511807..3238dc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.superpowers/
# Agent artifacts (generated per-task by Claudomator executor)
.agent-home/
.claudomator-env
diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid
new file mode 100644
index 0000000..3970283
--- /dev/null
+++ b/.remember/tmp/save-session.pid
@@ -0,0 +1 @@
+3631697
diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
index fecd9cc..2d75cf4 100644
--- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
+++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
@@ -24,11 +24,20 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest {
+ companion object {
+ @org.junit.BeforeClass
+ @JvmStatic
+ fun setUpClass() {
+ NavApplication.isTesting = true
+ }
+ }
+
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun setup() {
+ // Redundant but safe
NavApplication.isTesting = true
}
@@ -56,7 +65,7 @@ class MainActivitySmokeTest {
onView(withText("Safety")).perform(click())
onView(withText("Safety Dashboard")).check(matches(isDisplayed()))
onView(withText("ACTIVATE MOB")).check(matches(isDisplayed()))
- onView(withText("ANCHOR WATCH")).check(matches(isDisplayed()))
+ onView(withText("CONFIGURE ANCHOR WATCH")).check(matches(isDisplayed()))
}
@Test
@@ -72,6 +81,13 @@ class MainActivitySmokeTest {
}
@Test
+ fun instrumentSheet_surfacedReportButtons_areDisplayed() {
+ onView(withText("Instruments")).perform(click())
+ onView(withText("PRE-TRIP PLAN")).check(matches(isDisplayed()))
+ onView(withText("GENERATE REPORT")).check(matches(isDisplayed()))
+ }
+
+ @Test
fun bottomNav_mapTab_returnsFromOverlay() {
onView(withText("Safety")).perform(click())
onView(withText("Map")).perform(click())
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index e2e311d..2f23e87 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <!-- Documents/ write access — not needed on API 29+ (MediaStore handles it) -->
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28" />
<application
android:name=".NavApplication"
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
deleted file mode 100644
index 0c63662..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.terst.nav
-
-import android.location.Location
-import kotlin.math.*
-
-data class AnchorWatchState(
- val anchorLocation: Location? = null,
- val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
- val setTimeMillis: Long = 0L,
- val isActive: Boolean = false
-) {
- companion object {
- const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
-
- /**
- * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
- * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out))
- *
- * @param depthMeters Depth from surface to seabed in meters.
- * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters.
- * @param rodeOutMeters Length of chain/rode deployed in meters.
- * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid.
- */
- fun calculateRecommendedWatchCircleRadius(
- depthMeters: Double,
- freeboardMeters: Double,
- rodeOutMeters: Double
- ): Double {
- if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) {
- return 0.0 // Invalid inputs
- }
-
- val totalVerticalDistance = depthMeters + freeboardMeters
-
- // Ensure we don't take asin of a value > 1 or < -1
- if (totalVerticalDistance > rodeOutMeters) {
- // Rode is too short for the depth+freeboard, effectively boat is directly above anchor
- // In this case, the watch circle radius is 0, or very small.
- return 0.0
- }
-
- // angle = asin( (Depth + Freeboard) / Rode Out )
- val angle = asin(totalVerticalDistance / rodeOutMeters)
-
- // Watch circle radius = Rode Out * cos(angle)
- return rodeOutMeters * cos(angle)
- }
- }
-
- fun isDragging(currentLocation: Location): Boolean {
- anchorLocation ?: return false // Cannot drag if anchor not set
- if (!isActive) return false // Not active, so not dragging
-
- val distance = anchorLocation.distanceTo(currentLocation)
- return distance > watchCircleRadiusMeters
- }
-}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 138fc6c..b18db8d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -20,12 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.asStateFlow
import org.terst.nav.nmea.NmeaParser
import org.terst.nav.nmea.NmeaStreamManager
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.data.model.SensorData
+import org.terst.nav.safety.AnchorWatchState
+import org.terst.nav.wind.TrueWindCalculator
+import org.terst.nav.wind.ApparentWind
+import org.terst.nav.wind.TrueWindData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.CoroutineScope
@@ -34,22 +40,23 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
-data class GpsData(
- val latitude: Double,
- val longitude: Double,
- val speedOverGround: Float, // m/s
- val courseOverGround: Float // degrees
-) {
- fun toLocation(): Location {
- val location = Location("GpsData")
- location.latitude = latitude
- location.longitude = longitude
- location.speed = speedOverGround
- location.bearing = courseOverGround
- return location
- }
-}
-
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the navigation subsystem.
+ */
class LocationService : Service() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
@@ -60,22 +67,30 @@ class LocationService : Service() {
private lateinit var nmeaStreamManager: NmeaStreamManager
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val windCalculator = TrueWindCalculator()
+
+ // GPS sensor fusion state
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+ private val nmeaStalenessThresholdMs: Long = 5_000L
+ private val nmeaExtendedThresholdMs: Long = 10_000L
+
private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
private val NOTIFICATION_ID = 123
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
+ private var isAlarmTriggered = false
override fun onCreate() {
super.onCreate()
Log.d("LocationService", "Service created")
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
- anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ anchorAlarmManager = AnchorAlarmManager(this)
barometerSensorManager = BarometerSensorManager(this)
nmeaParser = NmeaParser()
nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
createNotificationChannel()
- // Observe barometer status and update our public state
+ // Observe barometer status
serviceScope.launch {
barometerSensorManager.barometerStatus.collect { status ->
_barometerStatus.value = status
@@ -84,14 +99,17 @@ class LocationService : Service() {
// Collect NMEA GPS positions
serviceScope.launch {
- nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
- _nmeaGpsPositionFlow.emit(gpsPosition)
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { position ->
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ _nmeaGpsPositionFlow.emit(position)
}
}
// Collect NMEA Wind Data
serviceScope.launch {
nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ updateTrueWindFromNmea(windData)
_nmeaWindDataFlow.emit(windData)
}
}
@@ -110,26 +128,22 @@ class LocationService : Service() {
}
}
- // Mock tidal current data generator
- serviceScope.launch {
- while (true) {
- val currents = MockTidalCurrentGenerator.generateMockCurrents()
- _tidalCurrentState.update { it.copy(currents = currents) }
- kotlinx.coroutines.delay(60000) // Update every minute
- }
- }
-
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
+ val position = GpsPosition(
latitude = location.latitude,
longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
+ sog = location.speed * 1.94384, // m/s to knots
+ cog = location.bearing.toDouble(),
+ timestampMs = location.time,
+ accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null
)
+ lastAndroidPosition = position
+ recomputeBestPosition()
+
serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
+ _locationFlow.emit(position)
}
// Check for anchor drag if anchor watch is active
@@ -139,32 +153,71 @@ class LocationService : Service() {
}
}
- /**
- * Checks if the current location is outside the anchor watch circle.
- */
+ private fun recomputeBestPosition() {
+ val now = System.currentTimeMillis()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ private fun updateTrueWindFromNmea(wind: WindData) {
+ val sog = _bestPosition.value?.sog
+ val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue
+
+ if (sog != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle),
+ bsp = sog, // Use SOG as proxy for BSP if BSP is not available
+ hdgDeg = hdg
+ )
+ }
+ }
+
private fun checkAnchorDrag(location: Location) {
_anchorWatchState.update { currentState ->
if (currentState.isActive && currentState.anchorLocation != null) {
val isDragging = currentState.isDragging(location)
if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!")
if (!isAlarmTriggered) {
anchorAlarmManager.startAlarm()
isAlarmTriggered = true
}
} else {
- Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
if (isAlarmTriggered) {
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
}
- } else {
- // If anchor watch is not active, ensure alarm is stopped
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
+ } else if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
}
currentState
}
@@ -173,24 +226,21 @@ class LocationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Starting foreground service")
startForeground(NOTIFICATION_ID, createNotification())
serviceScope.launch {
- _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL
+ _currentPowerMode.emit(PowerMode.FULL)
startLocationUpdatesInternal(PowerMode.FULL)
}
barometerSensorManager.start()
nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
}
ACTION_STOP_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Stopping foreground service")
stopLocationUpdatesInternal()
barometerSensorManager.stop()
nmeaStreamManager.stop()
stopSelf()
}
ACTION_START_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
serviceScope.launch {
startAnchorWatch(radius)
@@ -198,45 +248,34 @@ class LocationService : Service() {
}
}
ACTION_STOP_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
stopAnchorWatch()
- setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch
+ setPowerMode(PowerMode.FULL)
}
ACTION_UPDATE_WATCH_RADIUS -> {
- Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
updateWatchCircleRadius(radius)
}
- ACTION_TOGGLE_TIDAL_VISIBILITY -> {
- val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
- _tidalCurrentState.update { it.copy(isVisible = isVisible) }
- }
}
return START_NOT_STICKY
}
- override fun onBind(intent: Intent?): IBinder? {
- return null // Not a bound service
- }
+ override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
- Log.d("LocationService", "Service destroyed")
stopLocationUpdatesInternal()
anchorAlarmManager.stopAlarm()
barometerSensorManager.stop()
- nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
+ nmeaStreamManager.stop()
_anchorWatchState.value = AnchorWatchState(isActive = false)
- isAlarmTriggered = false // Reset alarm trigger state
- serviceScope.cancel() // Cancel the coroutine scope
+ isAlarmTriggered = false
+ serviceScope.cancel()
}
-
@SuppressLint("MissingPermission")
private fun startLocationUpdatesInternal(powerMode: PowerMode) {
- Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms")
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
- .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
@@ -246,22 +285,15 @@ class LocationService : Service() {
}
private fun stopLocationUpdatesInternal() {
- Log.d("LocationService", "Removing location updates")
fusedLocationClient.removeLocationUpdates(locationCallback)
}
fun setPowerMode(powerMode: PowerMode) {
serviceScope.launch {
if (_currentPowerMode.value != powerMode) {
- // Emit the new power mode first
_currentPowerMode.emit(powerMode)
- Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.")
- // Stop current updates if running
stopLocationUpdatesInternal()
- // Start new updates with the new power mode's interval
startLocationUpdatesInternal(powerMode)
- } else {
- Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.")
}
}
}
@@ -278,25 +310,15 @@ class LocationService : Service() {
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_IMMUTABLE
- )
-
+ val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Sailing Companion")
- .setContentText("Tracking your location in the background...")
+ .setContentText("Tracking your location...")
.setSmallIcon(R.drawable.ic_anchor)
.setContentIntent(pendingIntent)
.build()
}
- /**
- * Starts the anchor watch with the current location as the anchor point.
- * @param radiusMeters The watch circle radius in meters.
- */
@SuppressLint("MissingPermission")
suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
val lastLocation = fusedLocationClient.lastLocation.await()
@@ -307,29 +329,27 @@ class LocationService : Service() {
setTimeMillis = System.currentTimeMillis(),
isActive = true
) }
- Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
- } ?: run {
- Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
- // Handle error, e.g., show a toast to the user
}
}
- /**
- * Stops the anchor watch.
- */
fun stopAnchorWatch() {
_anchorWatchState.update { AnchorWatchState(isActive = false) }
- Log.i("AnchorWatch", "Anchor watch stopped.")
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
- /**
- * Updates the watch circle radius.
- */
fun updateWatchCircleRadius(radiusMeters: Double) {
_anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
- Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
+ }
+
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = null, // TODO: Pull from latest forecast
+ currentDirectionDeg = null
+ )
}
companion object {
@@ -338,56 +358,42 @@ class LocationService : Service() {
const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
- const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
- const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
-
- // NMEA Gateway configuration (example values - these should ideally be configurable by the user)
- private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address
- private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port
-
- // Publicly accessible flows
- val locationFlow: SharedFlow<GpsData>
- get() = _locationFlow
- val anchorWatchState: StateFlow<AnchorWatchState>
- get() = _anchorWatchState
- val tidalCurrentState: StateFlow<TidalCurrentState>
- get() = _tidalCurrentState
- val barometerStatus: StateFlow<BarometerStatus>
- get() = _barometerStatus
-
- // NMEA Data Flows
- val nmeaGpsPositionFlow: SharedFlow<GpsPosition>
- get() = _nmeaGpsPositionFlow
- val nmeaWindDataFlow: SharedFlow<WindData>
- get() = _nmeaWindDataFlow
- val nmeaDepthDataFlow: SharedFlow<DepthData>
- get() = _nmeaDepthDataFlow
- val nmeaHeadingDataFlow: SharedFlow<HeadingData>
- get() = _nmeaHeadingDataFlow
-
- private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
+
+ private const val NMEA_GATEWAY_IP = "192.168.1.1"
+ private const val NMEA_GATEWAY_PORT = 10110
+
+ private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState
+
private val _barometerStatus = MutableStateFlow(BarometerStatus())
+ val barometerStatus: StateFlow<BarometerStatus> get() = _barometerStatus
- // Private NMEA Data Flows
- private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow
+
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1)
+ val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow
+
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1)
+ val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow
+
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1)
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
- val currentPowerMode: StateFlow<PowerMode>
- get() = _currentPowerMode
+ val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode
}
}
-
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 7046b23..f84e5fb 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -8,39 +8,35 @@ import android.graphics.Canvas
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
-import android.util.Log
+import android.view.HapticFeedbackConstants
import android.view.View
import android.widget.FrameLayout
-import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.button.MaterialButton
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import java.util.Locale
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
-import org.maplibre.android.style.layers.RasterLayer
-import org.maplibre.android.style.sources.RasterSource
-import org.maplibre.android.style.sources.TileSet
import org.terst.nav.ui.*
import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.map.ParticleWindView
import org.terst.nav.ui.safety.SafetyFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
+import org.terst.nav.safety.AnchorWatchState
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
@@ -48,15 +44,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var mobHandler: MobHandler? = null
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
- private var anchorWatchHandler: AnchorWatchHandler? = null
private val loadedStyleFlow = MutableStateFlow<Style?>(null)
- private var weatherLoaded = false
- private var mapLibreMapRef: org.maplibre.android.maps.MapLibreMap? = null
+ private lateinit var layerManager: MapLayerManager
private var particleWindView: ParticleWindView? = null
+ private var weatherLoaded = false
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
private lateinit var fabRecordTrack: FloatingActionButton
+ private lateinit var fabMob: FloatingActionButton
+ private lateinit var fabRecenter: MaterialButton
+ private lateinit var btnQuit: MaterialButton
+ private lateinit var bottomSheet: CardView
+ private lateinit var bottomNav: BottomNavigationView
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -64,7 +64,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onResume() {
super.onResume()
- mapView?.onResume()
+ if (!NavApplication.isTesting) mapView?.onResume()
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
@@ -82,20 +82,37 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun initializeUI() {
fragmentContainer = findViewById(R.id.fragment_container)
- setupMap()
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabMob = findViewById(R.id.fab_mob)
+ fabRecenter = findViewById(R.id.fab_recenter)
+ btnQuit = findViewById(R.id.btn_quit)
+ bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+ bottomNav = findViewById(R.id.bottom_navigation)
+
setupBottomSheet()
setupBottomNavigation()
setupHandlers()
-
- findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
- onActivateMob()
- }
+ setupMap()
- fabRecordTrack = findViewById(R.id.fab_record_track)
+ // Single tap starts; long press stops (requires deliberate intent mid-sail)
fabRecordTrack.setOnClickListener {
- if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
+ if (!viewModel.isRecording.value) viewModel.startTrack()
}
- // Observe immediately — pure UI state, not gated on GPS permission
+ fabRecordTrack.setOnLongClickListener {
+ if (viewModel.isRecording.value) {
+ it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ viewModel.stopTrack()
+ true
+ } else false
+ }
+
+ fabMob.setOnClickListener { onActivateMob() }
+
+ fabRecenter.setOnClickListener {
+ mapHandler?.recenter()
+ }
+
+ btnQuit.setOnClickListener { onQuitRequested() }
lifecycleScope.launch {
viewModel.isRecording.collect { recording ->
val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
@@ -103,17 +120,22 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
}
}
+ lifecycleScope.launch {
+ viewModel.trackSummary.collect { summary ->
+ org.terst.nav.track.TrackSummarySheet
+ .from(summary, viewModel.trackStartMs)
+ .show(supportFragmentManager, "track_summary")
+ }
+ }
}
private fun setupBottomSheet() {
- val sheet = findViewById<View>(R.id.instrument_bottom_sheet)
- bottomSheetBehavior = BottomSheetBehavior.from(sheet)
+ bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
private fun setupBottomNavigation() {
- val nav = findViewById<BottomNavigationView>(R.id.bottom_navigation)
- nav.setOnItemSelectedListener { item ->
+ bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_map -> {
hideOverlays()
@@ -122,10 +144,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
true
}
- R.id.nav_instruments -> {
- hideOverlays()
- bottomSheetBehavior.isHideable = false
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
+ R.id.nav_layers -> {
+ val currentStyle = loadedStyleFlow.value
+ if (currentStyle != null) {
+ LayerPickerSheet(
+ manager = layerManager,
+ onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) },
+ onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) }
+ ).show(supportFragmentManager, "layer_picker")
+ }
+ bottomNav.post { bottomNav.selectedItemId = R.id.nav_map }
true
}
R.id.nav_log -> {
@@ -156,6 +184,29 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
fragmentContainer.visibility = View.GONE
}
+ private fun showReport(fragment: androidx.fragment.app.Fragment) {
+ bottomSheetBehavior.isHideable = true
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+ showOverlay(fragment)
+ }
+
+ private fun onQuitRequested() {
+ if (viewModel.isRecording.value) {
+ androidx.appcompat.app.AlertDialog.Builder(this)
+ .setMessage("Recording in progress. Quit and discard the current track?")
+ .setPositiveButton("Quit") { _, _ -> exitApp() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ } else {
+ exitApp()
+ }
+ }
+
+ private fun exitApp() {
+ stopService(Intent(this, LocationService::class.java))
+ finishAffinity()
+ }
+
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
@@ -166,38 +217,45 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onConfigureAnchor() {
- anchorWatchHandler?.toggleVisibility()
+ showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler())
}
private fun setupHandlers() {
instrumentHandler = InstrumentHandler(
- valueAws = findViewById(R.id.value_aws),
- valueTws = findViewById(R.id.value_tws),
- valueHdg = findViewById(R.id.value_hdg),
- valueCog = findViewById(R.id.value_cog),
- valueBsp = findViewById(R.id.value_bsp),
- valueSog = findViewById(R.id.value_sog),
- valueVmg = findViewById(R.id.value_vmg),
+ 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),
- valuePolarPct = findViewById(R.id.value_polar_pct),
- valueBaro = findViewById(R.id.value_baro),
- labelTrend = null, // simplified
- barometerTrendView = null, // simplified
- polarDiagramView = findViewById(R.id.polar_diagram_view)
+ valueBaro = findViewById(R.id.value_baro),
+ arrowAws = findViewById(R.id.arrow_aws),
+ arrowTws = findViewById(R.id.arrow_tws),
+ arrowHdg = findViewById(R.id.arrow_hdg),
+ arrowCog = findViewById(R.id.arrow_cog),
+ 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),
+ arrowCurr = findViewById(R.id.arrow_curr),
+ arrowWaves = findViewById(R.id.arrow_waves),
+ arrowSwell = findViewById(R.id.arrow_swell),
+ bearingCurr = findViewById(R.id.bearing_curr),
+ bearingWaves = findViewById(R.id.bearing_waves),
+ bearingSwell = findViewById(R.id.bearing_swell),
+ waveView = findViewById(R.id.wave_divider)
)
-
- // anchorWatchHandler is initialized when the anchor config UI is available
-
- val mockPolarTable = createMockPolarTable()
- findViewById<PolarDiagramView>(R.id.polar_diagram_view).setPolarTable(mockPolarTable)
- startInstrumentSimulation(mockPolarTable)
+ instrumentHandler?.updateDisplay(
+ aws = "—", tws = "—", hdg = "—",
+ cog = "—", bsp = "—", sog = "—",
+ baro = "—"
+ )
+ instrumentHandler?.updateConditions(currSpd = "—")
}
- // Helper to convert dp to px
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
- // ... (Keep existing permission and service logic)
-
private fun checkForegroundPermissions() {
val fineLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
val coarseLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -232,29 +290,40 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun setupMap() {
+ layerManager = MapLayerManager(this)
mapView = findViewById(R.id.mapView)
particleWindView = findViewById(R.id.particle_wind_view)
+ if (NavApplication.isTesting) return
+
mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
- mapLibreMapRef = maplibreMap
mapHandler = MapHandler(maplibreMap)
particleWindView?.attachMap(maplibreMap)
- val style = Style.Builder()
- .fromUri("https://tiles.openfreemap.org/styles/liberty")
- .withSource(RasterSource("openseamap-source",
- TileSet("2.2.0", "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png").also {
- it.setMaxZoom(18f)
- }, 256))
- .withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
-
- maplibreMap.setStyle(style) { style ->
+
+ lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack)
+ } else {
+ fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+ }
+
+ val styleBuilder = Style.Builder()
+ .fromUri("https://tiles.openfreemap.org/styles/bright")
+ layerManager.addToStyleBuilder(styleBuilder)
+
+ maplibreMap.setStyle(styleBuilder) { style ->
loadedStyleFlow.value = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
+ val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow)
val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow)
- mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap)
mapHandler?.setupWindLayer(style, windArrowBitmap)
- mapHandler?.setupWindGridLayer(style)
}
maplibreMap.addOnCameraIdleListener {
@@ -264,15 +333,35 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bounds.lonWest, bounds.lonEast
)
}
+
+ maplibreMap.addOnMapLongClickListener { _ ->
+ val currentStyle = loadedStyleFlow.value ?: return@addOnMapLongClickListener true
+ LayerPickerSheet(
+ manager = layerManager,
+ onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) },
+ onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) }
+ ).show(supportFragmentManager, "layer_picker")
+ true
+ }
}
}
private fun observeDataSources() {
+ var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
- val sogKnots = gpsData.speedOverGround * 1.94384
- viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.toDouble())
+ 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)
+ }
if (!weatherLoaded) {
weatherLoaded = true
viewModel.loadWeather(gpsData.latitude, gpsData.longitude)
@@ -293,6 +382,41 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ LocationService.barometerStatus.collect { status ->
+ if (status.history.isNotEmpty()) {
+ instrumentHandler?.updateDisplay(baro = status.formatPressure())
+ }
+ }
+ }
+ 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
+ )
+ 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
+ val windKt = c.windSpeedKt?.toFloat() ?: 0f
+ instrumentHandler?.updateWaveState(swellHtFt, swellPeriod, windHtFt, windKt)
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.nmeaDepthDataFlow.collect { depthData ->
+ instrumentHandler?.updateDisplay(depthM = depthData.depthMeters)
+ }
+ }
+ lifecycleScope.launch {
LocationService.anchorWatchState.collect { state ->
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
@@ -300,29 +424,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
loadedStyleFlow.filterNotNull()
.combine(viewModel.trackPoints) { style, points -> style to points }
- .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) }
- }
- }
-
- private fun startInstrumentSimulation(polarTable: PolarTable) {
- lifecycleScope.launch {
- var simulatedTws = 8.0
- var simulatedTwa = 40.0
- while (true) {
- val bsp = polarTable.interpolateBsp(simulatedTws, simulatedTwa)
- instrumentHandler?.updateDisplay(
- aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1),
- tws = "%.1f".format(Locale.getDefault(), simulatedTws),
- bsp = "%.1f".format(Locale.getDefault(), bsp),
- sog = "%.1f".format(Locale.getDefault(), bsp * 0.95),
- vmg = "%.1f".format(Locale.getDefault(), polarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, bsp) ?: 0.0),
- polarPct = "%.0f%%".format(Locale.getDefault(), polarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, bsp)),
- baro = "1013.2"
- )
- instrumentHandler?.updatePolarDiagram(simulatedTws, simulatedTwa, bsp)
- simulatedTwa = (simulatedTwa + 0.5).let { if (it > 170) 40.0 else it }
- delay(1000)
- }
+ .combine(viewModel.pastTracks) { (style, active), past -> Triple(style, active, past) }
+ .collect { (style, active, past) -> mapHandler?.updateTrackLayer(style, active, past) }
}
}
@@ -335,18 +438,26 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
return bitmap
}
- private fun createMockPolarTable(): PolarTable {
- val curves = listOf(6.0, 8.0, 10.0).map { tws ->
- PolarCurve(tws, listOf(30.0, 45.0, 60.0, 90.0, 120.0, 150.0, 180.0).map { twa ->
- PolarPoint(twa, tws * (0.4 + twa / 200.0))
- })
+ private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+ }
+
+ private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ if (v.visibility == View.VISIBLE && v.alpha == 1f) return@forEach
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
}
- return PolarTable(curves)
}
- override fun onStart() { super.onStart(); mapView?.onStart() }
- override fun onPause() { super.onPause(); mapView?.onPause() }
- override fun onStop() { super.onStop(); mapView?.onStop() }
- override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
- override fun onLowMemory() { super.onLowMemory(); mapView?.onLowMemory() }
+ override fun onStart() { super.onStart(); if (!NavApplication.isTesting) mapView?.onStart() }
+ override fun onPause() { super.onPause(); if (!NavApplication.isTesting) mapView?.onPause() }
+ override fun onStop() { super.onStop(); if (!NavApplication.isTesting) mapView?.onStop() }
+ override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy() }
+ override fun onLowMemory() { super.onLowMemory(); if (!NavApplication.isTesting) mapView?.onLowMemory() }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0b507d2..7c43dd5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -12,11 +12,23 @@ import java.util.Locale
class NavApplication : Application() {
companion object {
+ val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ lateinit var trackRepository: org.terst.nav.track.TrackRepository
var isTesting: Boolean = false
+ get() {
+ if (field) return true
+ return try {
+ Class.forName("androidx.test.espresso.Espresso")
+ true
+ } catch (e: ClassNotFoundException) {
+ false
+ }
+ }
}
override fun onCreate() {
super.onCreate()
+ trackRepository = org.terst.nav.track.TrackRepository(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
index 67c14f8..5a7d2e2 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
@@ -11,7 +11,7 @@ interface MarineApiService {
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("hourly") hourly: String =
- "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction",
+ "wave_height,wave_direction,swell_wave_height,swell_wave_direction,swell_wave_period,ocean_current_velocity,ocean_current_direction",
@Query("forecast_days") forecastDays: Int = 7
): MarineResponse
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt
new file mode 100644
index 0000000..3cde023
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt
@@ -0,0 +1,17 @@
+package org.terst.nav.data.model
+
+/**
+ * Snapshot of current marine conditions derived from the first forecast hour.
+ * All fields are nullable — null means the model returned no data for that parameter.
+ */
+data class MarineConditions(
+ val windSpeedKt: Double?,
+ val windDirDeg: Double?,
+ val waveHeightM: Double?,
+ val waveDirDeg: Double?,
+ val swellHeightM: Double?,
+ val swellDirDeg: Double?,
+ val swellPeriodS: Double?,
+ val currentSpeedKt: Double?, // ocean_current_velocity converted from m/s to knots
+ val currentDirDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
index ab9799b..cf5216d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
@@ -15,6 +15,9 @@ data class MarineHourly(
@Json(name = "time") val time: List<String>,
@Json(name = "wave_height") val waveHeight: List<Double?>,
@Json(name = "wave_direction") val waveDirection: List<Double?>,
+ @Json(name = "swell_wave_height") val swellWaveHeight: List<Double?>,
+ @Json(name = "swell_wave_direction") val swellWaveDirection: List<Double?>,
+ @Json(name = "swell_wave_period") val swellWavePeriod: List<Double?>,
@Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List<Double?>,
@Json(name = "ocean_current_direction") val oceanCurrentDirection: List<Double?>
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
new file mode 100644
index 0000000..fc1d79d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package org.terst.nav.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
index 3459d7b..c79366d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
@@ -3,6 +3,7 @@ package org.terst.nav.data.repository
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
class WeatherRepository(
@@ -12,8 +13,6 @@ class WeatherRepository(
/**
* Fetch 7-day hourly forecast items for the given position.
- * Both weather and marine data are requested; only weather fields are needed for ForecastItem,
- * but marine is fetched here to prime the cache for wind-arrow use.
*/
suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> =
runCatching {
@@ -48,6 +47,29 @@ class WeatherRepository(
}
/**
+ * Fetches current marine conditions (first forecast hour) for [lat]/[lon].
+ * Ocean current velocity is converted from m/s to knots.
+ */
+ suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result<MarineConditions> =
+ runCatching {
+ val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1)
+ val marine = marineApi.getMarineForecast(lat, lon)
+ val w = weather.hourly
+ val m = marine.hourly
+ MarineConditions(
+ windSpeedKt = w.windspeed10m.firstOrNull(),
+ windDirDeg = w.winddirection10m.firstOrNull(),
+ waveHeightM = m.waveHeight.firstOrNull(),
+ waveDirDeg = m.waveDirection.firstOrNull(),
+ swellHeightM = m.swellWaveHeight.firstOrNull(),
+ swellDirDeg = m.swellWaveDirection.firstOrNull(),
+ swellPeriodS = m.swellWavePeriod.firstOrNull(),
+ currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 },
+ currentDirDeg = m.oceanCurrentDirection.firstOrNull()
+ )
+ }
+
+ /**
* Fetch a current wind arrow for each point in [points] using Open-Meteo's batch endpoint.
* Points are split into chunks of 10 (API limit per call).
*/
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
index e17e5ca..6a976f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion
import java.time.Instant
interface GribFileManager {
- /** Save metadata for a newly-downloaded GRIB file. */
fun saveMetadata(file: GribFile)
- /** Return all stored GRIB files for [region], newest first. */
fun listFiles(region: GribRegion): List<GribFile>
- /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
fun latestFile(region: GribRegion): GribFile?
- /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
fun delete(file: GribFile): Boolean
- /** Delete all GRIB files older than [before]. Returns count of deleted files. */
fun purgeOlderThan(before: Instant): Int
- /** Total size in bytes of all stored GRIB files. */
fun totalSizeBytes(): Long
}
class InMemoryGribFileManager : GribFileManager {
private val files = mutableListOf<GribFile>()
-
override fun saveMetadata(file: GribFile) { files.add(file) }
-
- override fun listFiles(region: GribRegion): List<GribFile> =
- files.filter { it.region.name == region.name }
- .sortedByDescending { it.downloadedAt }
-
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
-
override fun delete(file: GribFile): Boolean = files.remove(file)
-
- override fun purgeOlderThan(before: Instant): Int {
- val toRemove = files.filter { it.downloadedAt.isBefore(before) }
- files.removeAll(toRemove)
- return toRemove.size
- }
-
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..f39957b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..875d971
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import org.terst.nav.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
index 5faf30c..99cef2d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -1,9 +1,20 @@
package org.terst.nav.gps
+/**
+ * Represents a single GPS fix.
+ *
+ * @param latitude Degrees, positive = North, negative = South.
+ * @param longitude Degrees, positive = East, negative = West.
+ * @param sog Speed Over Ground in knots.
+ * @param cog Course Over Ground in degrees true (0-360).
+ * @param timestampMs Unix epoch milliseconds UTC.
+ * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown.
+ */
data class GpsPosition(
val latitude: Double,
val longitude: Double,
- val sog: Double, // knots
- val cog: Double, // degrees true
- val timestampMs: Long
+ val sog: Double,
+ val cog: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Double? = null
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..67cfcce
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package org.terst.nav.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..6417db9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package org.terst.nav.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
index 453c758..6a470b8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -273,8 +273,9 @@ class NmeaParser {
val hours = timeStr.substring(0, 2).toInt()
val minutes = timeStr.substring(2, 4).toInt()
val seconds = timeStr.substring(4, 6).toInt()
- val millis = if (timeStr.length > 7) {
- (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ val millis = if (timeStr.contains('.')) {
+ val fracStr = timeStr.substringAfter('.')
+ ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
} else 0
cal.set(Calendar.HOUR_OF_DAY, hours)
cal.set(Calendar.MINUTE, minutes)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
new file mode 100644
index 0000000..13fb132
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..8ac73cf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package org.terst.nav.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
new file mode 100644
index 0000000..a6562d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package org.terst.nav.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..9121ce6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.safety
+
+import android.location.Location
+import kotlin.math.*
+
+/**
+ * Holds state for the anchor watch and provides the suggested watch-circle radius.
+ */
+data class AnchorWatchState(
+ val anchorLocation: Location? = null,
+ val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
+ val setTimeMillis: Long = 0L,
+ val isActive: Boolean = false
+) {
+ companion object {
+ const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0
+
+ /**
+ * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
+ */
+ fun calculateRecommendedWatchCircleRadius(
+ depthMeters: Double,
+ freeboardMeters: Double,
+ rodeOutMeters: Double
+ ): Double {
+ if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0
+ val totalVerticalDistance = depthMeters + freeboardMeters
+ if (totalVerticalDistance > rodeOutMeters) return 0.0
+ val angle = asin(totalVerticalDistance / rodeOutMeters)
+ return rodeOutMeters * cos(angle)
+ }
+ }
+
+ fun isDragging(currentLocation: Location): Boolean {
+ anchorLocation ?: return false
+ if (!isActive) return false
+ val distance = anchorLocation.distanceTo(currentLocation)
+ return distance > watchCircleRadiusMeters
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
new file mode 100644
index 0000000..b1e5652
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
@@ -0,0 +1,88 @@
+package org.terst.nav.tide
+
+import com.example.androidapp.data.model.TidePrediction
+import com.example.androidapp.data.model.TideStation
+import kotlin.math.cos
+
+/**
+ * Computes harmonic tide predictions using the standard formula:
+ * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
+ *
+ * where:
+ * Z0 = datum offset (mean water level above chart datum, metres)
+ * Hi = amplitude of constituent i (metres)
+ * ωi = angular speed of constituent i (degrees / hour)
+ * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC)
+ * φi = phase lag (degrees)
+ */
+object HarmonicTideCalculator {
+
+ /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */
+ internal const val EPOCH_MS = 946_684_800_000L
+
+ /**
+ * Predict the tide height at a single moment.
+ *
+ * @param station Tide station with harmonic constituents.
+ * @param timestampMs Unix epoch milliseconds for the desired time.
+ * @return Predicted height in metres above chart datum.
+ */
+ fun predictHeight(station: TideStation, timestampMs: Long): Double {
+ val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0
+ var height = station.datumOffsetMeters
+ for (c in station.constituents) {
+ val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg
+ height += c.amplitudeMeters * cos(Math.toRadians(angleDeg))
+ }
+ return height
+ }
+
+ /**
+ * Predict tide heights over a time range at regular intervals.
+ *
+ * @param station Tide station.
+ * @param fromMs Start of range (Unix milliseconds, inclusive).
+ * @param toMs End of range (Unix milliseconds, inclusive).
+ * @param intervalMs Time step in milliseconds (must be positive).
+ * @return List of [TidePrediction] ordered by ascending timestamp.
+ */
+ fun predictRange(
+ station: TideStation,
+ fromMs: Long,
+ toMs: Long,
+ intervalMs: Long
+ ): List<TidePrediction> {
+ require(intervalMs > 0) { "intervalMs must be positive" }
+ require(fromMs <= toMs) { "fromMs must not exceed toMs" }
+ val predictions = mutableListOf<TidePrediction>()
+ var t = fromMs
+ while (t <= toMs) {
+ predictions += TidePrediction(t, predictHeight(station, t))
+ t += intervalMs
+ }
+ return predictions
+ }
+
+ /**
+ * Find high and low water events from a pre-computed prediction series.
+ *
+ * Detects local maxima (high water) and minima (low water) by comparing
+ * each interior sample with its immediate neighbours.
+ *
+ * @param predictions Ordered list of tide predictions (at least 3 points).
+ * @return Subset list containing only high/low turning points.
+ */
+ fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> {
+ if (predictions.size < 3) return emptyList()
+ val result = mutableListOf<TidePrediction>()
+ for (i in 1 until predictions.size - 1) {
+ val prev = predictions[i - 1].heightMeters
+ val curr = predictions[i].heightMeters
+ val next = predictions[i + 1].heightMeters
+ val isMax = curr >= prev && curr >= next
+ val isMin = curr <= prev && curr <= next
+ if (isMax || isMin) result += predictions[i]
+ }
+ return result
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
new file mode 100644
index 0000000..3287f1d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
@@ -0,0 +1,96 @@
+package org.terst.nav.track
+
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.time.Instant
+
+/**
+ * Parses a GPX 1.1 file produced by [GpxSerializer] back into a list of [TrackPoint]s.
+ *
+ * Uses XmlPullParser (built into Android, also available on JVM via kxml2) so
+ * no additional XML dependencies are required.
+ */
+object GpxParser {
+
+ fun parse(input: InputStream): List<TrackPoint> {
+ val points = mutableListOf<TrackPoint>()
+ val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware = true }
+ val xpp = factory.newPullParser()
+ xpp.setInput(input, "UTF-8")
+
+ var lat = 0.0; var lon = 0.0; var timeMs = 0L
+ var sog = 0.0; var cog = 0.0
+ var hdg: Double? = null; var bsp: Double? = null
+ var depth: Double? = null; var baro: Double? = null
+ var windSpd: Double? = null; var windAng: Double? = null
+ var trueWind = false; var airTemp: Double? = null
+ var waveHt: Double? = null; var currSpd: Double? = null; var currDir: Double? = null
+ var inExtensions = false; var currentTag = ""
+
+ var event = xpp.eventType
+ while (event != XmlPullParser.END_DOCUMENT) {
+ when (event) {
+ XmlPullParser.START_TAG -> {
+ currentTag = xpp.name ?: ""
+ when (currentTag) {
+ "trkpt" -> {
+ lat = xpp.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: 0.0
+ lon = xpp.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: 0.0
+ // reset fields for this point
+ timeMs = 0L; sog = 0.0; cog = 0.0
+ hdg = null; bsp = null; depth = null; baro = null
+ windSpd = null; windAng = null; trueWind = false
+ airTemp = null; waveHt = null; currSpd = null; currDir = null
+ }
+ "extensions" -> inExtensions = true
+ }
+ }
+ XmlPullParser.TEXT -> {
+ val text = xpp.text?.trim() ?: ""
+ if (text.isEmpty()) { event = xpp.next(); continue }
+ when {
+ currentTag == "time" -> timeMs = runCatching {
+ Instant.parse(text).toEpochMilli()
+ }.getOrDefault(0L)
+ inExtensions -> when (currentTag) {
+ "sog" -> sog = text.toDoubleOrNull() ?: sog
+ "cog" -> cog = text.toDoubleOrNull() ?: cog
+ "hdg" -> hdg = text.toDoubleOrNull()
+ "bsp" -> bsp = text.toDoubleOrNull()
+ "depth" -> depth = text.toDoubleOrNull()
+ "baro" -> baro = text.toDoubleOrNull()
+ "windSpd" -> windSpd = text.toDoubleOrNull()
+ "windAng" -> windAng = text.toDoubleOrNull()
+ "trueWind" -> trueWind = text == "true"
+ "airTemp" -> airTemp = text.toDoubleOrNull()
+ "waveHt" -> waveHt = text.toDoubleOrNull()
+ "currSpd" -> currSpd = text.toDoubleOrNull()
+ "currDir" -> currDir = text.toDoubleOrNull()
+ }
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ val tag = xpp.name ?: ""
+ when (tag) {
+ "trkpt" -> points.add(TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sog, cogDeg = cog,
+ headingDeg = hdg, waterSpeedKnots = bsp,
+ depthMeters = depth, baroHpa = baro,
+ windSpeedKnots = windSpd, windAngleDeg = windAng,
+ isTrueWind = trueWind, airTempC = airTemp,
+ waveHeightM = waveHt, currentSpeedKts = currSpd,
+ currentDirDeg = currDir,
+ timestampMs = if (timeMs > 0) timeMs else System.currentTimeMillis()
+ ))
+ "extensions" -> inExtensions = false
+ }
+ currentTag = ""
+ }
+ }
+ event = xpp.next()
+ }
+ return points
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
new file mode 100644
index 0000000..e4b9448
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
@@ -0,0 +1,62 @@
+package org.terst.nav.track
+
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+/**
+ * Serializes a list of [TrackPoint]s to a GPX 1.1 XML string.
+ *
+ * Nav-specific fields (SOG, COG, depth, baro, wind) are stored in a
+ * `<extensions>` block under the `nav:` namespace so the file remains
+ * valid GPX while preserving full fidelity for round-trip reload.
+ */
+object GpxSerializer {
+
+ private val ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
+ .withZone(ZoneOffset.UTC)
+
+ fun serialize(points: List<TrackPoint>, trackName: String): String = buildString {
+ appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
+ appendLine(
+ """<gpx version="1.1" creator="Nav" """ +
+ """xmlns="http://www.topografix.com/GPX/1/1" """ +
+ """xmlns:nav="https://terst.org/nav/gpx/1">"""
+ )
+ appendLine(" <trk>")
+ appendLine(" <name>${escapeXml(trackName)}</name>")
+ appendLine(" <trkseg>")
+
+ for (pt in points) {
+ appendLine(""" <trkpt lat="${pt.lat}" lon="${pt.lon}">""")
+ appendLine(" <ele>0</ele>")
+ appendLine(" <time>${ISO8601.format(Instant.ofEpochMilli(pt.timestampMs))}</time>")
+ appendLine(" <extensions>")
+ appendLine(" <nav:sog>${pt.sogKnots}</nav:sog>")
+ appendLine(" <nav:cog>${pt.cogDeg}</nav:cog>")
+ pt.headingDeg?.let { appendLine(" <nav:hdg>$it</nav:hdg>") }
+ pt.waterSpeedKnots?.let { appendLine(" <nav:bsp>$it</nav:bsp>") }
+ pt.depthMeters?.let { appendLine(" <nav:depth>$it</nav:depth>") }
+ pt.baroHpa?.let { appendLine(" <nav:baro>$it</nav:baro>") }
+ pt.windSpeedKnots?.let { appendLine(" <nav:windSpd>$it</nav:windSpd>") }
+ pt.windAngleDeg?.let { appendLine(" <nav:windAng>$it</nav:windAng>") }
+ if (pt.isTrueWind) { appendLine(" <nav:trueWind>true</nav:trueWind>") }
+ pt.airTempC?.let { appendLine(" <nav:airTemp>$it</nav:airTemp>") }
+ pt.waveHeightM?.let { appendLine(" <nav:waveHt>$it</nav:waveHt>") }
+ pt.currentSpeedKts?.let { appendLine(" <nav:currSpd>$it</nav:currSpd>") }
+ pt.currentDirDeg?.let { appendLine(" <nav:currDir>$it</nav:currDir>") }
+ appendLine(" </extensions>")
+ appendLine(" </trkpt>")
+ }
+
+ appendLine(" </trkseg>")
+ appendLine(" </trk>")
+ append("</gpx>")
+ }
+
+ private fun escapeXml(s: String) = s
+ .replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;")
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
index d803c8c..ed38e5e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -5,8 +5,16 @@ data class TrackPoint(
val lon: Double,
val sogKnots: Double,
val cogDeg: Double,
- val windSpeedKnots: Double,
- val windAngleDeg: Double,
- val isTrueWind: Boolean,
- val timestampMs: Long
+ val headingDeg: Double? = null,
+ val waterSpeedKnots: Double? = null,
+ val depthMeters: Double? = null,
+ val baroHpa: Double? = null,
+ val windSpeedKnots: Double? = null,
+ val windAngleDeg: Double? = null,
+ val isTrueWind: Boolean = false,
+ val airTempC: Double? = null,
+ val waveHeightM: Double? = null,
+ val currentSpeedKts: Double? = null,
+ val currentDirDeg: Double? = null,
+ val timestampMs: Long = System.currentTimeMillis()
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index 7953822..7ef67af 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,26 +1,76 @@
package org.terst.nav.track
-class TrackRepository {
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class TrackRepository(context: Context) {
+
+ private val storage = TrackStorage(context)
var isRecording: Boolean = false
private set
- private val points = mutableListOf<TrackPoint>()
+ private val activePoints = mutableListOf<TrackPoint>()
+
+ /** Epoch-ms when the current (or last) track started. Exposed for the summary sheet title. */
+ var trackStartMs: Long = 0L
+ private set
+
+ // Loaded lazily from Documents/Nav/ on first access
+ private val _pastTracks = mutableListOf<List<TrackPoint>>()
+ private var pastTracksLoaded = false
fun startTrack() {
- points.clear()
+ activePoints.clear()
+ trackStartMs = System.currentTimeMillis()
isRecording = true
}
- fun stopTrack() {
+ /**
+ * Stops the active track, computes a [TrackSummary], persists the track
+ * to shared storage, and returns the summary. Returns null if the track
+ * was too short (< 2 minutes) or had no points.
+ */
+
+ suspend fun stopTrack(): TrackSummary? = withContext(Dispatchers.IO) {
+ if (!isRecording) return@withContext null
isRecording = false
+ val points = activePoints.toList()
+ activePoints.clear()
+ if (points.size < 2) return@withContext null
+
+ val summary = summarise(points)
+ // Discard tracks shorter than 2 minutes — likely accidental taps
+ if (summary.durationMs < 2 * 60_000L) return@withContext null
+
+ _pastTracks.add(0, points)
+ val saved = storage.saveTrack(points, trackStartMs)
+ if (!saved) Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart")
+ summary
}
fun addPoint(point: TrackPoint): Boolean {
if (!isRecording) return false
- points.add(point)
+ activePoints.add(point)
return true
}
- fun getPoints(): List<TrackPoint> = points.toList()
+ fun getPoints(): List<TrackPoint> = activePoints.toList()
+
+ /**
+ * Returns all completed tracks, loading from Documents/Nav/ on first call.
+ * Subsequent calls return the in-memory list (storage is source of truth
+ * only at startup).
+ */
+ suspend fun getPastTracks(): List<List<TrackPoint>> = withContext(Dispatchers.IO) {
+ if (!pastTracksLoaded) {
+ pastTracksLoaded = true
+ val stored = storage.loadAllTracks()
+ // Merge: put stored tracks behind any in-memory tracks from this session
+ _pastTracks.addAll(stored)
+ }
+ _pastTracks.toList()
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
new file mode 100644
index 0000000..08e1dc9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
@@ -0,0 +1,153 @@
+package org.terst.nav.track
+
+import android.content.ContentValues
+import android.content.Context
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Log
+import java.io.File
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+private const val TAG = "TrackStorage"
+
+/**
+ * Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
+ *
+ * Files written here survive app uninstall because they live in user-owned
+ * shared storage rather than app-private storage.
+ *
+ * API 29+: uses MediaStore (no permission required for Documents/).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
+ * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ */
+class TrackStorage(private val context: Context) {
+
+ private val fileTimestamp = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd_HH-mm-ss")
+ .withZone(ZoneOffset.UTC)
+
+ private val trackName = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd HH:mm")
+ .withZone(ZoneOffset.UTC)
+
+ /** Write a completed track to Documents/Nav/. Returns true on success. */
+ fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
+ if (points.isEmpty()) return false
+ val name = trackName.format(Instant.ofEpochMilli(startMs))
+ val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
+ val gpx = GpxSerializer.serialize(points, name)
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveViaMediaStore(fileName, gpx)
+ } else {
+ saveViaFile(fileName, gpx)
+ }
+ }
+
+ /** Load all tracks previously saved to Documents/Nav/. */
+ fun loadAllTracks(): List<List<TrackPoint>> {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ loadViaMediaStore()
+ } else {
+ loadViaFile()
+ }
+ }
+
+ // ── API 29+ ──────────────────────────────────────────────────────────────
+
+ private fun saveViaMediaStore(fileName: String, gpx: String): Boolean {
+ // Guard: external storage must be mounted before touching MediaStore
+ val storageState = Environment.getExternalStorageState()
+ if (storageState != Environment.MEDIA_MOUNTED) {
+ Log.e(TAG, "External storage not mounted (state=$storageState) — cannot save $fileName")
+ return false
+ }
+
+ // IS_PENDING marks the entry as in-progress, preventing a race condition on
+ // Android 10-11 where insert() succeeds but openOutputStream() returns null
+ // because the file hasn't been physically created on disk yet.
+ val values = ContentValues().apply {
+ put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml")
+ put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/")
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val uri = context.contentResolver.insert(
+ MediaStore.Files.getContentUri("external"), values
+ ) ?: run {
+ Log.e(TAG, "MediaStore insert returned null for $fileName")
+ return false
+ }
+
+ return runCatching {
+ val stream = context.contentResolver.openOutputStream(uri)
+ if (stream == null) {
+ context.contentResolver.delete(uri, null, null)
+ Log.e(TAG, "openOutputStream null for $fileName — deleted orphan entry")
+ return@runCatching false
+ }
+ stream.use { it.write(gpx.toByteArray()) }
+
+ // Clear IS_PENDING so the file is visible to other apps and file managers
+ val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
+ context.contentResolver.update(uri, update, null, null)
+
+ Log.d(TAG, "Saved $fileName (${gpx.length} bytes) → $uri")
+ true
+ }.getOrElse { e ->
+ Log.e(TAG, "Write failed for $fileName: ${e.message}")
+ runCatching { context.contentResolver.delete(uri, null, null) }
+ false
+ }
+ }
+
+ private fun loadViaMediaStore(): List<List<TrackPoint>> {
+ val tracks = mutableListOf<List<TrackPoint>>()
+ val uri = MediaStore.Files.getContentUri("external")
+ val projection = arrayOf(MediaStore.Files.FileColumns._ID)
+ val selection = "${MediaStore.Files.FileColumns.RELATIVE_PATH} = ? " +
+ "AND ${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?"
+ val args = arrayOf("Documents/Nav/", "nav_%.gpx")
+
+ context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor ->
+ val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ while (cursor.moveToNext()) {
+ val fileUri = android.net.Uri.withAppendedPath(uri, cursor.getLong(idCol).toString())
+ runCatching {
+ context.contentResolver.openInputStream(fileUri)?.use { stream ->
+ val points = GpxParser.parse(stream)
+ if (points.isNotEmpty()) tracks.add(points)
+ }
+ }
+ }
+ }
+ return tracks
+ }
+
+ // ── API < 29 ─────────────────────────────────────────────────────────────
+
+ private fun navDir(): File {
+ val docs = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
+ return File(docs, "Nav").also { it.mkdirs() }
+ }
+
+ private fun saveViaFile(fileName: String, gpx: String): Boolean = runCatching {
+ File(navDir(), fileName).writeText(gpx)
+ true
+ }.getOrDefault(false)
+
+ private fun loadViaFile(): List<List<TrackPoint>> {
+ val dir = navDir()
+ if (!dir.exists()) return emptyList()
+ return dir.listFiles { f -> f.name.startsWith("nav_") && f.name.endsWith(".gpx") }
+ ?.sortedBy { it.name }
+ ?.mapNotNull { file ->
+ runCatching { GpxParser.parse(file.inputStream()) }
+ .getOrNull()
+ ?.takeIf { it.isNotEmpty() }
+ } ?: emptyList()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
new file mode 100644
index 0000000..3f2f3be
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.track
+
+import kotlin.math.*
+
+data class TrackSummary(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val maxSogKt: Double,
+ val avgSogKt: Double,
+ val avgWindKt: Double?, // null if no wind data in track
+ val avgWaveHeightM: Double? // null if no wave data in track
+) {
+ val durationMinutes: Long get() = durationMs / 60_000
+}
+
+/** Computes a [TrackSummary] from a completed list of [TrackPoint]s. */
+fun summarise(points: List<TrackPoint>): TrackSummary {
+ require(points.isNotEmpty())
+
+ var distanceNm = 0.0
+ for (i in 1 until points.size) {
+ distanceNm += haversineNm(points[i - 1], points[i])
+ }
+
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val windReadings = points.mapNotNull { it.windSpeedKnots }
+ val avgWind = if (windReadings.isNotEmpty()) windReadings.average() else null
+
+ val waveReadings = points.mapNotNull { it.waveHeightM }
+ val avgWave = if (waveReadings.isNotEmpty()) waveReadings.average() else null
+
+ return TrackSummary(
+ distanceNm = distanceNm,
+ durationMs = durationMs,
+ maxSogKt = maxSog,
+ avgSogKt = avgSog,
+ avgWindKt = avgWind,
+ avgWaveHeightM = avgWave
+ )
+}
+
+private fun haversineNm(a: TrackPoint, b: TrackPoint): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(b.lat - a.lat)
+ val dLon = Math.toRadians(b.lon - a.lon)
+ val sinDLat = sin(dLat / 2)
+ val sinDLon = sin(dLon / 2)
+ val h = sinDLat * sinDLat +
+ cos(Math.toRadians(a.lat)) * cos(Math.toRadians(b.lat)) * sinDLon * sinDLon
+ return 2 * r * asin(sqrt(h))
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
new file mode 100644
index 0000000..8d9d7c7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
@@ -0,0 +1,95 @@
+package org.terst.nav.track
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.terst.nav.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+class TrackSummarySheet : BottomSheetDialogFragment() {
+
+ companion object {
+ private const val ARG_DISTANCE = "distance_nm"
+ private const val ARG_DURATION = "duration_ms"
+ private const val ARG_MAX_SOG = "max_sog"
+ private const val ARG_AVG_SOG = "avg_sog"
+ private const val ARG_AVG_WIND = "avg_wind" // -1 if absent
+ private const val ARG_AVG_WAVE = "avg_wave_m" // -1 if absent
+ private const val ARG_START_MS = "start_ms"
+
+ fun from(summary: TrackSummary, startMs: Long) = TrackSummarySheet().apply {
+ arguments = Bundle().apply {
+ putDouble(ARG_DISTANCE, summary.distanceNm)
+ putLong(ARG_DURATION, summary.durationMs)
+ putDouble(ARG_MAX_SOG, summary.maxSogKt)
+ putDouble(ARG_AVG_SOG, summary.avgSogKt)
+ putDouble(ARG_AVG_WIND, summary.avgWindKt ?: -1.0)
+ putDouble(ARG_AVG_WAVE, summary.avgWaveHeightM ?: -1.0)
+ putLong(ARG_START_MS, startMs)
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_track_summary_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val args = requireArguments()
+ val distanceNm = args.getDouble(ARG_DISTANCE)
+ val durationMs = args.getLong(ARG_DURATION)
+ val maxSog = args.getDouble(ARG_MAX_SOG)
+ val avgSog = args.getDouble(ARG_AVG_SOG)
+ val avgWind = args.getDouble(ARG_AVG_WIND).takeIf { it >= 0 }
+ val avgWaveM = args.getDouble(ARG_AVG_WAVE).takeIf { it >= 0 }
+ val startMs = args.getLong(ARG_START_MS)
+
+ val titleFmt = DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ view.findViewById<TextView>(R.id.summary_title).text =
+ "Track · ${titleFmt.format(Instant.ofEpochMilli(startMs))}"
+
+ view.findViewById<TextView>(R.id.summary_distance).text =
+ "%.1f".format(Locale.getDefault(), distanceNm)
+
+ view.findViewById<TextView>(R.id.summary_duration).text = formatDuration(durationMs)
+
+ view.findViewById<TextView>(R.id.summary_max_sog).text =
+ "%.1f".format(Locale.getDefault(), maxSog)
+
+ view.findViewById<TextView>(R.id.summary_avg_sog).text =
+ "%.1f".format(Locale.getDefault(), avgSog)
+
+ val conditionsRow = view.findViewById<LinearLayout>(R.id.summary_conditions_row)
+ val windCell = view.findViewById<LinearLayout>(R.id.summary_wind_cell)
+ val waveCell = view.findViewById<LinearLayout>(R.id.summary_wave_cell)
+
+ if (avgWind != null) {
+ windCell.visibility = View.VISIBLE
+ view.findViewById<TextView>(R.id.summary_avg_wind).text =
+ "%.0f".format(Locale.getDefault(), avgWind)
+ }
+ if (avgWaveM != null) {
+ waveCell.visibility = View.VISIBLE
+ val waveFt = avgWaveM * 3.28084
+ view.findViewById<TextView>(R.id.summary_avg_wave).text =
+ "%.1f".format(Locale.getDefault(), waveFt)
+ }
+ if (avgWind != null || avgWaveM != null) {
+ conditionsRow.visibility = View.VISIBLE
+ }
+ }
+
+ private fun formatDuration(ms: Long): String {
+ val totalMinutes = ms / 60_000
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+ return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
new file mode 100644
index 0000000..2362079
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
@@ -0,0 +1,43 @@
+package org.terst.nav.tripreport
+
+enum class BoatType {
+ MONOHULL,
+ MULTIHULL
+}
+
+enum class RigType {
+ SLOOP,
+ CUTTER,
+ KETCH
+}
+
+data class BoatProfile(
+ val name: String,
+ val lengthFt: Double,
+ val type: BoatType,
+ val rig: RigType,
+ val hasSpinnaker: Boolean = false,
+ val hasGennaker: Boolean = false
+)
+
+data class PreTripSummary(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val windSpeedKt: Double,
+ val windDirDeg: Double,
+ val waveHeightM: Double?,
+ val weatherDescription: String,
+ val boatProfile: BoatProfile
+)
+
+data class SailSuggestion(
+ val sailName: String,
+ val action: String // e.g., "Full Main", "1 Reef", "Furl"
+)
+
+data class PreTripReport(
+ val summary: PreTripSummary,
+ val routingSuggestion: String,
+ val sailPlan: List<SailSuggestion>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
new file mode 100644
index 0000000..819485f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
@@ -0,0 +1,102 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import org.terst.nav.R
+import org.terst.nav.ui.MainViewModel
+import java.util.Locale
+
+class PreTripReportFragment : Fragment() {
+
+ private val viewModel: PreTripReportViewModel by activityViewModels()
+ private val mainViewModel: MainViewModel by activityViewModels()
+
+ private lateinit var tvWeatherSummary: TextView
+ private lateinit var tvRoutingContent: TextView
+ private lateinit var tvSailPlanContent: TextView
+ private lateinit var cardReport: MaterialCardView
+ private lateinit var btnGenerate: MaterialButton
+ private lateinit var progress: ProgressBar
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_pretrip_report, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvWeatherSummary = view.findViewById(R.id.tv_weather_summary)
+ tvRoutingContent = view.findViewById(R.id.tv_routing_content)
+ tvSailPlanContent = view.findViewById(R.id.tv_sail_plan_content)
+ cardReport = view.findViewById(R.id.card_report)
+ btnGenerate = view.findViewById(R.id.btn_generate_pretrip)
+ progress = view.findViewById(R.id.progress_pretrip)
+
+ btnGenerate.setOnClickListener {
+ generateReport()
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { renderState(it) }
+ }
+ }
+
+ private fun generateReport() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val forecast = mainViewModel.forecast.value.firstOrNull()
+ val conditions = mainViewModel.marineConditions.value
+ // For now, use 0,0 if no location, but ideally we'd have last known
+ // In a real app, we'd get this from a LocationProvider
+ viewModel.generate(0.0, 0.0, forecast, conditions)
+ }
+ }
+
+ private fun renderState(state: PreTripState) {
+ when (state) {
+ is PreTripState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnGenerate.isEnabled = false
+ }
+ is PreTripState.Success -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ cardReport.visibility = View.VISIBLE
+
+ val r = state.report
+ tvWeatherSummary.text = "Wind: %.1f kts from %.0f°\nWaves: %s\nSky: %s".format(
+ Locale.getDefault(),
+ r.summary.windSpeedKt,
+ r.summary.windDirDeg,
+ r.summary.waveHeightM?.let { "%.1fm".format(it) } ?: "N/A",
+ r.summary.weatherDescription
+ )
+
+ tvRoutingContent.text = r.routingSuggestion
+
+ val sailPlanText = r.sailPlan.joinToString("\n") {
+ "• ${it.sailName}: ${it.action}"
+ }
+ tvSailPlanContent.text = sailPlanText
+ }
+ is PreTripState.Error -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ // Show toast or error message
+ }
+ else -> {}
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt
new file mode 100644
index 0000000..2ccabfb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt
@@ -0,0 +1,66 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+
+class PreTripReportGenerator {
+
+ fun generateReport(
+ lat: Double,
+ lon: Double,
+ forecast: ForecastItem?,
+ conditions: MarineConditions?,
+ boatProfile: BoatProfile
+ ): PreTripReport {
+ val summary = PreTripSummary(
+ timestampMs = System.currentTimeMillis(),
+ lat = lat,
+ lon = lon,
+ windSpeedKt = forecast?.windKt ?: 0.0,
+ windDirDeg = forecast?.windDirDeg ?: 0.0,
+ waveHeightM = conditions?.waveHeightM,
+ weatherDescription = forecast?.weatherDescription() ?: "Unknown",
+ boatProfile = boatProfile
+ )
+
+ val routing = suggestRouting(summary)
+ val sailPlan = suggestSailPlan(summary)
+
+ return PreTripReport(summary, routing, sailPlan)
+ }
+
+ private fun suggestRouting(summary: PreTripSummary): String {
+ val wind = summary.windSpeedKt
+ val waves = summary.waveHeightM ?: 0.0
+
+ return when {
+ wind > 35.0 -> "STORM WARNING: Winds exceed 35kts. Consider remaining in port or seeking shelter immediately."
+ wind > 25.0 && waves > 2.5 -> "HEAVY WEATHER: Expect challenging conditions. Coastal routing advised to minimize fetch."
+ wind < 5.0 -> "LIGHT WINDS: Motor-sailing likely required for efficient passage."
+ else -> "FAVORABLE CONDITIONS: Standard routing based on destination bearing should be effective."
+ }
+ }
+
+ private fun suggestSailPlan(summary: PreTripSummary): List<SailSuggestion> {
+ val wind = summary.windSpeedKt
+ val suggestions = mutableListOf<SailSuggestion>()
+
+ // Main sail
+ suggestions.add(when {
+ wind > 30.0 -> SailSuggestion("Main", "Deep Reef / Trysail")
+ wind > 22.0 -> SailSuggestion("Main", "2nd Reef")
+ wind > 16.0 -> SailSuggestion("Main", "1st Reef")
+ else -> SailSuggestion("Main", "Full Main")
+ })
+
+ // Headsail
+ suggestions.add(when {
+ wind > 25.0 -> SailSuggestion("Headsail", "Storm Jib / Furl 50%")
+ wind > 18.0 -> SailSuggestion("Headsail", "Working Jib / Furl 30%")
+ wind < 10.0 && summary.boatProfile.hasGennaker -> SailSuggestion("Gennaker", "Deploy for light air reach")
+ else -> SailSuggestion("Headsail", "Full Genoa")
+ })
+
+ return suggestions
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
new file mode 100644
index 0000000..9fd32c7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
@@ -0,0 +1,51 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+
+sealed class PreTripState {
+ object Idle : PreTripState()
+ object Loading : PreTripState()
+ data class Success(val report: PreTripReport) : PreTripState()
+ data class Error(val message: String) : PreTripState()
+}
+
+class PreTripReportViewModel(
+ private val generator: PreTripReportGenerator = PreTripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<PreTripState>(PreTripState.Idle)
+ val state: StateFlow<PreTripState> = _state.asStateFlow()
+
+ private val _boatProfile = MutableStateFlow(
+ BoatProfile("Default Sloop", 35.0, BoatType.MONOHULL, RigType.SLOOP)
+ )
+ val boatProfile: StateFlow<BoatProfile> = _boatProfile.asStateFlow()
+
+ fun updateBoatProfile(profile: BoatProfile) {
+ _boatProfile.value = profile
+ }
+
+ fun generate(
+ lat: Double,
+ lon: Double,
+ forecast: ForecastItem?,
+ conditions: MarineConditions?
+ ) {
+ viewModelScope.launch {
+ _state.value = PreTripState.Loading
+ try {
+ val report = generator.generateReport(lat, lon, forecast, conditions, _boatProfile.value)
+ _state.value = PreTripState.Success(report)
+ } catch (e: Exception) {
+ _state.value = PreTripState.Error(e.message ?: "Failed to generate pre-trip report")
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
new file mode 100644
index 0000000..e7a425f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -0,0 +1,86 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.chip.ChipGroup
+import kotlinx.coroutines.launch
+import org.terst.nav.NavApplication
+import org.terst.nav.R
+
+class TripReportFragment : Fragment() {
+
+ private val viewModel by lazy {
+ TripReportViewModel(
+ trackRepository = NavApplication.trackRepository,
+ logbookRepository = NavApplication.logbookRepository
+ )
+ }
+
+ private lateinit var tvNarrativeContent: TextView
+ private lateinit var btnRefresh: MaterialButton
+ private lateinit var chipGroup: ChipGroup
+ private lateinit var progress: ProgressBar
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_trip_report, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvNarrativeContent = view.findViewById(R.id.tv_narrative_content)
+ btnRefresh = view.findViewById(R.id.btn_refresh_report)
+ chipGroup = view.findViewById(R.id.chip_group_styles)
+ progress = view.findViewById(R.id.progress_report)
+
+ btnRefresh.setOnClickListener { viewModel.generateReport() }
+
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val style = when (checkedIds.firstOrNull()) {
+ R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS
+ R.id.chip_journal -> NarrativeStyle.JOURNAL
+ R.id.chip_pirate -> NarrativeStyle.PIRATE
+ else -> NarrativeStyle.PROFESSIONAL
+ }
+ viewModel.setStyle(style)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { state -> renderState(state) }
+ }
+
+ // Initial generation
+ viewModel.generateReport()
+ }
+
+ private fun renderState(state: TripReportState) {
+ when (state) {
+ is TripReportState.Idle -> {
+ progress.visibility = View.GONE
+ }
+ is TripReportState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnRefresh.isEnabled = false
+ }
+ is TripReportState.Success -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = state.narrative
+ }
+ is TripReportState.Error -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = "Error: ${state.message}"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
new file mode 100644
index 0000000..bbf00b1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -0,0 +1,117 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.logbook.LogEntry
+import org.terst.nav.track.TrackPoint
+
+enum class NarrativeStyle {
+ PROFESSIONAL,
+ ADVENTUROUS,
+ JOURNAL,
+ PIRATE
+}
+
+data class TripSummary(
+ val startTimeMs: Long,
+ val endTimeMs: Long,
+ val distanceNm: Double,
+ val maxSogKts: Double,
+ val avgSogKts: Double,
+ val minAirTempC: Double?,
+ val maxAirTempC: Double?,
+ val maxWaveHeightM: Double?,
+ val logEntries: List<LogEntry>,
+ val points: List<TrackPoint>
+)
+
+class TripReportGenerator {
+
+ fun generateSummary(points: List<TrackPoint>, logEntries: List<LogEntry>): TripSummary {
+ if (points.isEmpty()) {
+ return TripSummary(0, 0, 0.0, 0.0, 0.0, null, null, null, logEntries, points)
+ }
+
+ val startTime = points.first().timestampMs
+ val endTime = points.last().timestampMs
+
+ var totalDist = 0.0
+ for (i in 0 until points.size - 1) {
+ totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon)
+ }
+ val distanceNm = totalDist / 1852.0 // meters to nautical miles
+
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val airTemps = points.mapNotNull { it.airTempC }
+ val minTemp = airTemps.minOrNull()
+ val maxTemp = airTemps.maxOrNull()
+ val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull()
+
+ return TripSummary(
+ startTimeMs = startTime,
+ endTimeMs = endTime,
+ distanceNm = distanceNm,
+ maxSogKts = maxSog,
+ avgSogKts = avgSog,
+ minAirTempC = minTemp,
+ maxAirTempC = maxTemp,
+ maxWaveHeightM = maxWave,
+ logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
+ points = points
+ )
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 6371e3 // Earth radius in meters
+ val phi1 = Math.toRadians(lat1)
+ val phi2 = Math.toRadians(lat2)
+ val deltaPhi = Math.toRadians(lat2 - lat1)
+ val deltaLambda = Math.toRadians(lon2 - lon1)
+
+ val a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) *
+ Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return r * c
+ }
+
+ fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String {
+ val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0
+ val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " +
+ "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) +
+ (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") +
+ "Events: ${summary.logEntries.joinToString { it.text }}"
+
+ return when (style) {
+ NarrativeStyle.PROFESSIONAL -> {
+ "VOYAGE SUMMARY\n" +
+ "Duration: %.1f hours\n".format(durationHrs) +
+ "Total Distance: %.1f NM\n".format(summary.distanceNm) +
+ "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) +
+ "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" +
+ "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" }
+ }
+ NarrativeStyle.ADVENTUROUS -> {
+ "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) +
+ "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) +
+ (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" +
+ "During our journey, we logged some great moments:\n" +
+ summary.logEntries.joinToString("\n") { "🔥 ${it.text}" }
+ }
+ NarrativeStyle.JOURNAL -> {
+ "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) +
+ "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) +
+ "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " +
+ "It was a meaningful passage."
+ }
+ NarrativeStyle.PIRATE -> {
+ "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) +
+ "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) +
+ "Listen to the tales from the log:\n" +
+ summary.logEntries.joinToString("\n") { "🏴‍☠️ ${it.text}" } + "\n" +
+ "Arr, it was a fine voyage indeed!"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
new file mode 100644
index 0000000..e474cd2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.track.TrackRepository
+
+sealed class TripReportState {
+ object Idle : TripReportState()
+ object Loading : TripReportState()
+ data class Success(val summary: TripSummary, val narrative: String) : TripReportState()
+ data class Error(val message: String) : TripReportState()
+}
+
+class TripReportViewModel(
+ private val trackRepository: TrackRepository,
+ private val logbookRepository: InMemoryLogbookRepository,
+ private val generator: TripReportGenerator = TripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle)
+ val state: StateFlow<TripReportState> = _state.asStateFlow()
+
+ private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL)
+ val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow()
+
+ fun setStyle(style: NarrativeStyle) {
+ _selectedStyle.value = style
+ generateReport()
+ }
+
+ fun generateReport() {
+ viewModelScope.launch {
+ _state.value = TripReportState.Loading
+ try {
+ val points = trackRepository.getPoints()
+ if (points.isEmpty()) {
+ _state.value = TripReportState.Error("No track data available. Start recording a track first.")
+ return@launch
+ }
+ val logEntries = logbookRepository.getAll()
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ _state.value = TripReportState.Success(summary, narrative)
+ } catch (e: Exception) {
+ _state.value = TripReportState.Error(e.message ?: "Unknown error generating report")
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
deleted file mode 100644
index d55de90..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.terst.nav.ui
-
-import android.content.Context
-import android.content.Intent
-import android.view.View
-import android.widget.Button
-import android.widget.TextView
-import android.widget.Toast
-import androidx.constraintlayout.widget.ConstraintLayout
-import org.terst.nav.AnchorWatchState
-import org.terst.nav.LocationService
-import java.util.Locale
-
-/**
- * Handles the Anchor Watch UI interactions and state updates.
- */
-class AnchorWatchHandler(
- private val context: Context,
- private val container: ConstraintLayout,
- private val statusText: TextView,
- private val radiusText: TextView,
- private val buttonDecrease: Button,
- private val buttonIncrease: Button,
- private val buttonSet: Button,
- private val buttonStop: Button
-) {
- private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
-
- init {
- updateRadiusDisplay()
-
- buttonDecrease.setOnClickListener {
- updateRadius((currentRadius - 5).coerceAtLeast(10.0))
- }
-
- buttonIncrease.setOnClickListener {
- updateRadius((currentRadius + 5).coerceAtMost(200.0))
- }
-
- buttonSet.setOnClickListener {
- startWatch()
- }
-
- buttonStop.setOnClickListener {
- stopWatch()
- }
- }
-
- private fun updateRadius(newRadius: Double) {
- currentRadius = newRadius
- updateRadiusDisplay()
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- }
-
- private fun updateRadiusDisplay() {
- radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius)
- }
-
- private fun startWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_START_ANCHOR_WATCH
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
-
- private fun stopWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_ANCHOR_WATCH
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
- }
-
- /**
- * Updates the UI based on the current anchor watch state.
- */
- fun updateUI(state: AnchorWatchState) {
- statusText.text = if (state.isActive) {
- "STATUS: ACTIVE" // Simple status for UI
- } else {
- "STATUS: INACTIVE"
- }
- currentRadius = state.watchCircleRadiusMeters
- updateRadiusDisplay()
- }
-
- /**
- * Toggles the visibility of the anchor configuration container.
- */
- fun toggleVisibility() {
- container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE
- }
-}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
new file mode 100644
index 0000000..fa68b63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
@@ -0,0 +1,69 @@
+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
+
+/**
+ * Small circular direction indicator — notched chevron pointing in [bearing] degrees
+ * (0 = north/up, clockwise). Two palettes: SKY (grey) and OCEAN (blue).
+ */
+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
+ canvas.drawCircle(cx, cy, r, circlePaint)
+ 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()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
index 2f72153..cb59a3a 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt
@@ -1,70 +1,161 @@
package org.terst.nav.ui
import android.widget.TextView
-import org.terst.nav.BarometerReading
-import org.terst.nav.BarometerTrendView
-import org.terst.nav.PolarDiagramView
-import org.terst.nav.R
import java.util.Locale
+// ── Pure formatting helpers (top-level for testability) ──────────────────────
+
+private const val M_TO_FT = 3.28084
+
+/** Converts metres to feet. */
+fun metresToFeet(metres: Double): Double = metres * M_TO_FT
+
+/** Formats a feet value to one decimal place. */
+fun formatFt(ft: Double, locale: Locale = Locale.getDefault()): String =
+ "%.1f".format(locale, ft)
+
+/** Formats a bearing to zero decimal places with a degree symbol. */
+fun formatBearing(deg: Double, locale: Locale = Locale.getDefault()): String =
+ "%.0f°".format(locale, deg)
+
+/** Formats a swell period with leading dot separator. */
+fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String =
+ "· %.0fs".format(locale, sec)
+
+// ── InstrumentHandler ────────────────────────────────────────────────────────
+
/**
- * Handles the display of instrument data in the UI.
+ * Drives all text fields, direction arrows, and the wave view in the
+ * instrument bottom sheet.
+ *
+ * Forecast [DirectionArrowView] instances are initialised with OCEAN style.
+ *
+ * Units contract:
+ * - Speed values: pre-formatted strings in knots (caller's responsibility)
+ * - Height values: caller passes raw metres; this class converts to feet
+ * - Bearing values: raw degrees as Float, rotated into arrows and formatted
+ * into bearing TextViews by this class
*/
class InstrumentHandler(
- private val valueAws: TextView,
- private val valueTws: TextView,
- private val valueHdg: TextView,
- private val valueCog: TextView,
- private val valueBsp: TextView,
- private val valueSog: TextView,
- private val valueVmg: TextView,
+ // ── Instrument section TextViews ─────────────────────────────────
+ private val valueAws: TextView,
+ private val valueTws: TextView,
+ private val valueHdg: TextView,
+ private val valueCog: TextView,
+ private val valueBsp: TextView,
+ private val valueSog: TextView,
private val valueDepth: TextView,
- private val valuePolarPct: TextView,
- private val valueBaro: TextView,
- private val labelTrend: TextView?,
- private val barometerTrendView: BarometerTrendView?,
- private val polarDiagramView: PolarDiagramView
+ private val valueBaro: TextView,
+ // ── Instrument section DirectionArrowViews ───────────────────────
+ private val arrowAws: DirectionArrowView,
+ private val arrowTws: DirectionArrowView,
+ private val arrowHdg: DirectionArrowView,
+ private val arrowCog: DirectionArrowView,
+ // ── Forecast section TextViews ───────────────────────────────────
+ private val valueCurrSpd: TextView,
+ private val valueWaveHt: TextView,
+ private val valueSwellHt: TextView,
+ private val valueSwellPer: TextView,
+ // ── Forecast section DirectionArrowViews ─────────────────────────
+ private val arrowCurr: DirectionArrowView,
+ private val arrowWaves: DirectionArrowView,
+ private val arrowSwell: DirectionArrowView,
+ // ── Forecast section bearing TextViews ───────────────────────────
+ private val bearingCurr: TextView,
+ private val bearingWaves: TextView,
+ private val bearingSwell: TextView,
+ // ── Wave view ────────────────────────────────────────────────────
+ private val waveView: WaveView
) {
+ init {
+ arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ }
+
/**
- * Updates the text displays for various instruments.
+ * Updates instrument-section text and arrows.
+ * Null arguments leave the current value unchanged.
+ * [depthM] is raw metres — converted to feet internally.
*/
fun updateDisplay(
- aws: String? = null,
- tws: String? = null,
- hdg: String? = null,
- cog: String? = null,
- bsp: String? = null,
- sog: String? = null,
- vmg: String? = null,
- depth: String? = null,
- polarPct: String? = null,
- baro: String? = null,
- trend: String? = null
+ aws: String? = null, awsBearingDeg: Float? = null,
+ tws: String? = null, twsBearingDeg: Float? = null,
+ hdg: String? = null, hdgBearingDeg: Float? = null,
+ cog: String? = null, cogBearingDeg: Float? = null,
+ bsp: String? = null,
+ sog: String? = null,
+ depthM: Double? = null,
+ baro: String? = null
) {
- aws?.let { valueAws.text = it }
- tws?.let { valueTws.text = it }
- hdg?.let { valueHdg.text = it }
- cog?.let { valueCog.text = it }
- bsp?.let { valueBsp.text = it }
- sog?.let { valueSog.text = it }
- vmg?.let { valueVmg.text = it }
- depth?.let { valueDepth.text = it }
- polarPct?.let { valuePolarPct.text = it }
- baro?.let { valueBaro.text = it }
- trend?.let { labelTrend?.text = it }
+ aws?.let { valueAws.text = it }
+ tws?.let { valueTws.text = it }
+ hdg?.let { valueHdg.text = it }
+ cog?.let { valueCog.text = it }
+ bsp?.let { valueBsp.text = it }
+ sog?.let { valueSog.text = it }
+ baro?.let { valueBaro.text = it }
+ depthM?.let { valueDepth.text = formatFt(metresToFeet(it)) }
+
+ awsBearingDeg?.let { arrowAws.bearing = it }
+ twsBearingDeg?.let { arrowTws.bearing = it }
+ hdgBearingDeg?.let { arrowHdg.bearing = it }
+ cogBearingDeg?.let { arrowCog.bearing = it }
}
/**
- * Updates the polar diagram view.
+ * Updates the forecast section.
+ * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally.
*/
- fun updatePolarDiagram(tws: Double, twa: Double, bsp: Double) {
- polarDiagramView.setCurrentPerformance(tws, twa, bsp)
+ 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 barometer trend chart.
+ * Updates the WaveView with current sea state. Call once when conditions load.
+ *
+ * View height is set here to reflect swell scale: 1ft → 56dp, 8ft → 160dp.
+ * [windSpeedKt] gates whitecap rendering (Beaufort 4 threshold = 12 kt).
*/
- fun updateBarometerTrend(history: List<BarometerReading>) {
- barometerTrendView?.setHistory(history)
+ fun updateWaveState(
+ swellHeightFt: Float,
+ swellPeriodSec: Float,
+ windWaveHeightFt: Float,
+ windSpeedKt: Float = 0f
+ ) {
+ waveView.swellHeightFt = swellHeightFt
+ waveView.swellPeriodSec = swellPeriodSec
+ waveView.windWaveHeightFt = windWaveHeightFt
+ waveView.windSpeedKt = windSpeedKt
+
+ // Size the view to the swell — bigger swell = taller window
+ val density = waveView.resources.displayMetrics.density
+ val heightDp = (32f + swellHeightFt * 16f).coerceIn(56f, 160f)
+ val lp = waveView.layoutParams
+ lp.height = (heightDp * density).toInt()
+ waveView.layoutParams = lp
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
new file mode 100644
index 0000000..48dc808
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
@@ -0,0 +1,48 @@
+package org.terst.nav.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.switchmaterial.SwitchMaterial
+import org.terst.nav.R
+
+class LayerPickerSheet(
+ private val manager: MapLayerManager,
+ private val onBaseChanged: (MapBasePreset) -> Unit,
+ private val onWindChanged: (Boolean) -> Unit
+) : BottomSheetDialogFragment() {
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_layer_picker_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val chipGroup = view.findViewById<ChipGroup>(R.id.chip_group_base)
+ val windSwitch = view.findViewById<SwitchMaterial>(R.id.switch_wind)
+
+ // Set initial state from manager
+ val chipId = when (manager.basePreset) {
+ MapBasePreset.SATELLITE -> R.id.chip_satellite
+ MapBasePreset.CHARTS -> R.id.chip_charts
+ MapBasePreset.HYBRID -> R.id.chip_hybrid
+ }
+ chipGroup.check(chipId)
+ windSwitch.isChecked = manager.windEnabled
+
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val preset = when (checkedIds.firstOrNull()) {
+ R.id.chip_satellite -> MapBasePreset.SATELLITE
+ R.id.chip_charts -> MapBasePreset.CHARTS
+ R.id.chip_hybrid -> MapBasePreset.HYBRID
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onBaseChanged(preset)
+ }
+
+ windSwitch.setOnCheckedChangeListener { _, checked ->
+ onWindChanged(checked)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 4c2c555..9bd660b 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -7,19 +7,25 @@ import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.track.TrackPoint
-import org.terst.nav.track.TrackRepository
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import org.terst.nav.track.TrackSummary
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
+import kotlin.math.cos
+import kotlin.math.sin
sealed class UiState {
object Loading : UiState()
@@ -43,12 +49,15 @@ class MainViewModel(
private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
val forecast: StateFlow<List<ForecastItem>> = _forecast
+ private val _marineConditions = MutableStateFlow<MarineConditions?>(null)
+ val marineConditions: StateFlow<MarineConditions?> = _marineConditions.asStateFlow()
+
private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
private val aisRepository = AisRepository()
- private val trackRepository = TrackRepository()
+ private val trackRepository = org.terst.nav.NavApplication.trackRepository
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
@@ -56,22 +65,41 @@ class MainViewModel(
private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+ private val _pastTracks = MutableStateFlow<List<List<TrackPoint>>>(emptyList())
+ val pastTracks: StateFlow<List<List<TrackPoint>>> = _pastTracks.asStateFlow()
+
+ private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0)
+ val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow()
+
fun startTrack() {
trackRepository.startTrack()
_trackPoints.value = emptyList()
_isRecording.value = true
}
+ /** Epoch-ms when the current track started — for the summary sheet title. */
+ val trackStartMs: Long get() = trackRepository.trackStartMs
+
fun stopTrack() {
- trackRepository.stopTrack()
- _isRecording.value = false
+ viewModelScope.launch {
+ val summary = trackRepository.stopTrack()
+ _pastTracks.value = trackRepository.getPastTracks()
+ _trackPoints.value = emptyList()
+ _isRecording.value = false
+ summary?.let { _trackSummary.emit(it) }
+ }
}
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val conditions = _marineConditions.value
+ val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
lat = lat, lon = lon,
sogKnots = sogKnots, cogDeg = cogDeg,
- windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false,
+ airTempC = forecast?.tempC,
+ waveHeightM = conditions?.waveHeightM,
+ currentSpeedKts = conditions?.currentSpeedKt,
+ currentDirDeg = conditions?.currentDirDeg,
timestampMs = System.currentTimeMillis()
)
if (trackRepository.addPoint(point)) {
@@ -92,8 +120,18 @@ class MainViewModel(
}
/**
- * Fetch weather and marine data for [lat]/[lon] in parallel.
- * Called once the device location is known.
+ * Fetches current conditions snapshot for [lat]/[lon] and exposes it via [marineConditions].
+ * Silently ignored on network failure — the UI keeps showing dashes.
+ */
+ fun loadConditions(lat: Double, lon: Double) {
+ viewModelScope.launch {
+ repository.fetchCurrentConditions(lat, lon)
+ .onSuccess { _marineConditions.value = it }
+ }
+ }
+
+ /**
+ * Fetch weather and wind arrow for [lat]/[lon]. Called once on first GPS fix.
*/
fun loadWeather(lat: Double, lon: Double) {
viewModelScope.launch {
@@ -150,7 +188,6 @@ class MainViewModel(
/**
* Process a single NMEA sentence from the hardware AIS receiver.
- * Call this from MainActivity when bytes arrive from the TCP socket.
*/
fun processAisSentence(sentence: String) {
aisRepository.processSentence(sentence)
@@ -160,7 +197,6 @@ class MainViewModel(
/**
* Refresh AIS targets from AISHub for the given bounding box.
- * When username is empty, skips silently — hardware feed is primary.
*/
fun refreshAisFromInternet(
latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index bb73c53..1978745 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -20,18 +20,41 @@ import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
-import org.terst.nav.AnchorWatchState
+import org.terst.nav.safety.AnchorWatchState
import org.terst.nav.TidalCurrentState
import org.terst.nav.data.model.WindArrow
import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
/**
* Handles MapLibre initialization, layers, and updates.
*/
class MapHandler(private val maplibreMap: MapLibreMap) {
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ maplibreMap.addOnCameraIdleListener {
+ if (!_isFollowing.value) {
+ lastZoom = maplibreMap.cameraPosition.zoom
+ }
+ }
+ }
+
+ private val _isFollowing = MutableStateFlow(true)
+ val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+ private var lastLat: Double = 0.0
+ private var lastLon: Double = 0.0
+ private var lastZoom: Double = 14.0
+
private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
@@ -42,27 +65,34 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
- private val TRACK_SOURCE_ID = "track-source"
- private val TRACK_LAYER_ID = "track-line"
+ private val USER_POS_SOURCE_ID = "user-pos-source"
+ private val USER_POS_LAYER_ID = "user-pos-layer"
+ private val USER_ICON_ID = "user-icon"
+
+ private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
+ private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
+ private val TRACK_PAST_SOURCE_ID = "track-past-source"
+ private val TRACK_PAST_LAYER_ID = "track-line-past"
private val WIND_SOURCE_ID = "wind-source"
private val WIND_LAYER_ID = "wind-arrows"
private val WIND_ARROW_ICON_ID = "wind-arrow"
-
private val WIND_GRID_SOURCE_ID = "wind-grid-source"
private val WIND_GRID_LAYER_ID = "wind-grid-arrows"
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
- private var trackSource: GeoJsonSource? = null
+ private var userPosSource: GeoJsonSource? = null
+ private var trackActiveSource: GeoJsonSource? = null
+ private var trackPastSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
private var windGridSource: GeoJsonSource? = null
/**
- * Initializes map layers for anchor watch and tidal currents.
+ * Initializes map layers for anchor watch, tidal currents, and user position.
*/
- fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap) {
+ fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap, userBitmap: Bitmap) {
// Anchor layers
style.addImage(ANCHOR_ICON_ID, anchorBitmap)
anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
@@ -100,56 +130,29 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
PropertyFactory.iconSize(0.8f)
)
})
- }
-
- /**
- * Centers the map on the specified location.
- */
- fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
- val position = CameraPosition.Builder()
- .target(LatLng(lat, lon))
- .zoom(zoom)
- .build()
- maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
- }
- /**
- * Updates the anchor watch visualization on the map.
- */
- fun updateAnchorWatch(state: AnchorWatchState) {
- if (state.isActive && state.anchorLocation != null) {
- val point = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
- anchorPointSource?.setGeoJson(Feature.fromGeometry(point))
-
- val circlePolygon = createCirclePolygon(state.anchorLocation.latitude, state.anchorLocation.longitude, state.watchCircleRadiusMeters)
- anchorCircleSource?.setGeoJson(Feature.fromGeometry(circlePolygon))
- } else {
- anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- }
- }
-
- /**
- * Updates the tidal current arrows on the map.
- */
- fun updateTidalCurrents(state: TidalCurrentState) {
- if (state.isVisible) {
- val features = state.currents.map { current ->
- Feature.fromGeometry(Point.fromLngLat(current.longitude, current.latitude)).apply {
- addNumberProperty("rotation", current.directionDegrees.toFloat())
- }
- }
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
- } else {
- tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
- }
+ // User Position Layer
+ style.addImage(USER_ICON_ID, userBitmap)
+ userPosSource = GeoJsonSource(USER_POS_SOURCE_ID)
+ style.addSource(userPosSource!!)
+ style.addLayer(SymbolLayer(USER_POS_LAYER_ID, USER_POS_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(USER_ICON_ID),
+ PropertyFactory.iconRotate(org.maplibre.android.style.expressions.Expression.get("rotation")),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true),
+ PropertyFactory.iconSize(1.0f)
+ )
+ })
}
/**
- * Registers the wind-arrow bitmap in the style. Call once after style loads.
+ * Registers the wind-arrow bitmap and creates the single-arrow + grid layers.
+ * Call once after style loads, after setupLayers.
*/
fun setupWindLayer(style: Style, arrowBitmap: Bitmap) {
style.addImage(WIND_ARROW_ICON_ID, arrowBitmap)
+
windSource = GeoJsonSource(WIND_SOURCE_ID)
style.addSource(windSource!!)
style.addLayer(SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).apply {
@@ -168,12 +171,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
)
)
})
- }
- /**
- * Registers the wind-grid layer. Uses the same bitmap as the single arrow — call after setupWindLayer.
- */
- fun setupWindGridLayer(style: Style) {
windGridSource = GeoJsonSource(WIND_GRID_SOURCE_ID)
style.addSource(windGridSource!!)
style.addLayer(SymbolLayer(WIND_GRID_LAYER_ID, WIND_GRID_SOURCE_ID).apply {
@@ -194,6 +192,15 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
})
}
+ /** Places or moves the single wind arrow at the GPS position. */
+ fun updateWindLayer(arrow: WindArrow) {
+ val feature = Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
+ addNumberProperty("direction", arrow.directionDeg)
+ addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ windSource?.setGeoJson(FeatureCollection.fromFeature(feature))
+ }
+
/** Replaces all wind-grid arrows with the fetched list. */
fun updateWindGridLayer(arrows: List<WindArrow>) {
val features = arrows.map { arrow ->
@@ -206,35 +213,113 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
/**
- * Places or moves the single wind arrow at the GPS position.
+ * Updates the user's position and orientation on the map.
*/
- fun updateWindLayer(arrow: WindArrow) {
- val feature = Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
- addNumberProperty("direction", arrow.directionDeg)
- addNumberProperty("speed_kt", arrow.speedKt)
+ fun updateUserPosition(lat: Double, lon: Double, headingDeg: Float) {
+ userPosSource?.setGeoJson(Feature.fromGeometry(Point.fromLngLat(lon, lat)).apply {
+ addNumberProperty("rotation", headingDeg)
+ })
+ }
+
+ /**
+ * Centers the map on the specified location.
+ */
+ fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+ val position = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(zoom)
+ .build()
+ maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+ }
+
+ /**
+ * Restores auto-follow mode and animates the camera back to the last known GPS position.
+ * No-op if no GPS fix has been received yet.
+ */
+ fun recenter() {
+ if (lastLat == 0.0 && lastLon == 0.0) return
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon, lastZoom)
+ }
+
+ /**
+ * Updates the anchor watch visualization on the map.
+ */
+ fun updateAnchorWatch(state: AnchorWatchState) {
+ if (state.isActive && state.anchorLocation != null) {
+ val point = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
+ anchorPointSource?.setGeoJson(Feature.fromGeometry(point))
+
+ val circlePolygon = createCirclePolygon(state.anchorLocation.latitude, state.anchorLocation.longitude, state.watchCircleRadiusMeters)
+ anchorCircleSource?.setGeoJson(Feature.fromGeometry(circlePolygon))
+ } else {
+ anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
- windSource?.setGeoJson(FeatureCollection.fromFeature(feature))
}
/**
- * Updates the GPS track polyline on the map. Lazily initialises the layer on first call.
+ * Updates the tidal current arrows on the map.
*/
- fun updateTrackLayer(style: Style, points: List<TrackPoint>) {
- if (trackSource == null) {
- trackSource = GeoJsonSource(TRACK_SOURCE_ID)
- style.addSource(trackSource!!)
- style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply {
+ fun updateTidalCurrents(state: TidalCurrentState) {
+ if (state.isVisible) {
+ val features = state.currents.map { current ->
+ Feature.fromGeometry(Point.fromLngLat(current.longitude, current.latitude)).apply {
+ addNumberProperty("rotation", current.directionDegrees.toFloat())
+ }
+ }
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ } else {
+ tidalCurrentSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layers on first call.
+ */
+ fun updateTrackLayer(style: Style, activePoints: List<TrackPoint>, pastTracks: List<List<TrackPoint>>) {
+ if (trackActiveSource == null) {
+ trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
+ style.addSource(trackActiveSource!!)
+ style.addLayer(LineLayer(TRACK_ACTIVE_LAYER_ID, TRACK_ACTIVE_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round")
+ )
+ })
+ }
+
+ if (trackPastSource == null) {
+ trackPastSource = GeoJsonSource(TRACK_PAST_SOURCE_ID)
+ style.addSource(trackPastSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_LAYER_ID, TRACK_PAST_SOURCE_ID).apply {
setProperties(
PropertyFactory.lineColor("#E53935"),
- PropertyFactory.lineWidth(3f)
+ PropertyFactory.lineWidth(3f),
+ PropertyFactory.lineDasharray(arrayOf(1f, 2f)),
+ PropertyFactory.lineCap("round")
)
})
}
- if (points.size >= 2) {
- val coords = points.map { Point.fromLngLat(it.lon, it.lat) }
- trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+
+ if (activePoints.size >= 2) {
+ val coords = activePoints.map { Point.fromLngLat(it.lon, it.lat) }
+ trackActiveSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackActiveSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+
+ if (pastTracks.isNotEmpty()) {
+ val features = pastTracks.map { track ->
+ Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
+ }
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(features))
} else {
- trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
}
@@ -249,7 +334,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
val lonOffset = radiusMeters / (111320.0 * cos(Math.toRadians(lat))) * sin(Math.toRadians(angle))
points.add(Point.fromLngLat(lon + lonOffset, lat + latOffset))
}
- points.add(points[0]) // Close the polygon
+ points.add(points[0])
return Polygon.fromLngLats(listOf(points))
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
new file mode 100644
index 0000000..cf78ec2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
@@ -0,0 +1,132 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.RasterLayer
+import org.maplibre.android.style.sources.RasterSource
+import org.maplibre.android.style.sources.TileSet
+
+enum class MapBasePreset { SATELLITE, CHARTS, HYBRID }
+
+/**
+ * Manages the raster layer stack: base imagery (satellite / NOAA charts / hybrid)
+ * and the wind overlay. Persists selections across sessions.
+ *
+ * All sources are registered at style-build time; this class only toggles
+ * layer visibility — no runtime source swapping required.
+ */
+class MapLayerManager(context: Context) {
+
+ private val prefs = context.getSharedPreferences("map_layers", Context.MODE_PRIVATE)
+
+ var basePreset: MapBasePreset = loadBasePreset()
+ private set
+
+ var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true)
+ private set
+
+ // ── Source / Layer IDs ────────────────────────────────────────────────────
+
+ companion object {
+ const val SOURCE_SATELLITE = "satellite-source"
+ const val SOURCE_CHARTS = "charts-source"
+ const val SOURCE_WIND = "wind-source"
+ const val SOURCE_SEAMARKS = "openseamap-source"
+
+ const val LAYER_SATELLITE = "satellite-layer"
+ const val LAYER_CHARTS = "charts-layer"
+ const val LAYER_WIND = "wind-layer"
+ const val LAYER_SEAMARKS = "openseamap-layer"
+
+ private const val KEY_BASE = "base_preset"
+ private const val KEY_WIND = "wind_enabled"
+
+ // Tile URLs
+ private const val URL_SATELLITE =
+ "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
+ private const val URL_CHARTS =
+ "https://tileservice.charts.noaa.gov/tiles/50000_1/{z}/{x}/{y}.png"
+ private const val URL_WIND =
+ "https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=ae2a038149aa0900d1bc74160aa2a37e"
+ private const val URL_SEAMARKS =
+ "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
+ }
+
+ /**
+ * Registers all raster sources and layers into the [Style.Builder].
+ * Call this before [Style.Builder.build]. Layers are added in the correct
+ * order; MapHandler's vector layers go on top after style loads.
+ */
+ fun addToStyleBuilder(builder: Style.Builder) {
+ // ── Sources ───────────────────────────────────────────────────────────
+ builder.withSource(RasterSource(SOURCE_SATELLITE,
+ TileSet("2.2.0", URL_SATELLITE), 256))
+
+ builder.withSource(RasterSource(SOURCE_CHARTS,
+ TileSet("2.2.0", URL_CHARTS), 256))
+
+ builder.withSource(RasterSource(SOURCE_WIND,
+ TileSet("2.2.0", URL_WIND), 256))
+
+ builder.withSource(RasterSource(SOURCE_SEAMARKS,
+ TileSet("2.2.0", URL_SEAMARKS).also { it.setMaxZoom(18f) }, 256))
+
+ // ── Layers (bottom → top within raster stack) ─────────────────────────
+ builder.withLayer(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply {
+ setProperties(PropertyFactory.rasterOpacity(1f))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply {
+ setProperties(PropertyFactory.rasterOpacity(opacityFor(basePreset)))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_CHARTS)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_WIND, SOURCE_WIND).apply {
+ setProperties(PropertyFactory.rasterOpacity(0.6f))
+ setProperties(PropertyFactory.visibility(if (windEnabled) "visible" else "none"))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS).apply {
+ setProperties(PropertyFactory.visibility("visible"))
+ })
+ }
+
+ /** Apply a new base preset to a live style. */
+ fun setBasePreset(style: Style, preset: MapBasePreset) {
+ basePreset = preset
+ prefs.edit().putString(KEY_BASE, preset.name).apply()
+
+ style.getLayer(LAYER_SATELLITE)?.setProperties(
+ PropertyFactory.visibility(visibilityFor(preset, LAYER_SATELLITE)))
+ style.getLayer(LAYER_CHARTS)?.let {
+ it.setProperties(PropertyFactory.visibility(visibilityFor(preset, LAYER_CHARTS)))
+ (it as? RasterLayer)?.setProperties(PropertyFactory.rasterOpacity(opacityFor(preset)))
+ }
+ }
+
+ /** Toggle wind overlay on a live style. */
+ fun setWindEnabled(style: Style, enabled: Boolean) {
+ windEnabled = enabled
+ prefs.edit().putBoolean(KEY_WIND, enabled).apply()
+ style.getLayer(LAYER_WIND)?.setProperties(
+ PropertyFactory.visibility(if (enabled) "visible" else "none"))
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private fun visibilityFor(preset: MapBasePreset, layerId: String): String = when (layerId) {
+ LAYER_SATELLITE -> if (preset == MapBasePreset.SATELLITE || preset == MapBasePreset.HYBRID) "visible" else "none"
+ LAYER_CHARTS -> if (preset == MapBasePreset.CHARTS || preset == MapBasePreset.HYBRID) "visible" else "none"
+ else -> "visible"
+ }
+
+ private fun opacityFor(preset: MapBasePreset): Float =
+ if (preset == MapBasePreset.HYBRID) 0.75f else 1f
+
+ private fun loadBasePreset(): MapBasePreset =
+ prefs.getString(KEY_BASE, null)?.let {
+ runCatching { MapBasePreset.valueOf(it) }.getOrNull()
+ } ?: MapBasePreset.SATELLITE
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
new file mode 100644
index 0000000..d3f9a4d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
@@ -0,0 +1,158 @@
+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.
+ *
+ * Animation speed is driven by [swellPeriodSec] — a 20s period swell animates
+ * at half the speed of a 10s period swell. View height should be set by the
+ * caller to reflect actual swell height (see [InstrumentHandler.updateWaveState]).
+ *
+ * Whitecaps are only drawn when [windSpeedKt] >= 12 (Beaufort 4).
+ */
+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() }
+
+ /** Wind speed in knots. Whitecaps are suppressed below 12 kt (Beaufort 4). */
+ var windSpeedKt: Float = 0f
+ 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()
+
+ // Animation speed scales inversely with period: 10s → 0.8 rad/s, 20s → 0.4, 6s → 1.33
+ val swellSpeed = 8.0 / swellPeriodSec
+ val windSpeed = swellSpeed * 2.2
+
+ // Amplitude fills most of the view height — the view itself is sized for swell scale
+ val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.08f, h * 0.42f)
+ val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.16f)
+ 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 * swellSpeed) * swellAmp
+ + sin((x / windWlen) * TWO_PI - t * windSpeed + 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 — only at Beaufort 4+ (≥12 kt) ─────────────────
+ if (windSpeedKt >= 12f) {
+ val capPath = Path()
+ x = windWlen * 0.5f
+ while (x <= w) {
+ val wv = sin((x / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp
+ val wn = sin(((x + 3) / windWlen) * TWO_PI - t * windSpeed + 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
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..d435f00
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package org.terst.nav.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import org.terst.nav.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
index e950b5d..4bc0c7a 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
@@ -48,6 +48,13 @@ class SafetyFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener {
listener?.onConfigureAnchor()
}
+
+ view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
}
fun updateAnchorStatus(statusText: String) {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
index ef48d37..1c797d5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
@@ -16,6 +16,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch
import org.terst.nav.R
@@ -28,7 +29,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var speechRecognizer: SpeechRecognizer
private val viewModel by lazy {
- VoiceLogViewModel(repository = InMemoryLogbookRepository())
+ VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
}
private lateinit var tvStatus: TextView
@@ -38,6 +39,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnSave: Button
private lateinit var btnRetry: Button
private lateinit var tvSavedConfirmation: TextView
+ private lateinit var btnGenerateReport: MaterialButton
override fun onCreateView(
inflater: LayoutInflater,
@@ -55,12 +57,19 @@ class VoiceLogFragment : Fragment() {
btnSave = view.findViewById(R.id.btn_save)
btnRetry = view.findViewById(R.id.btn_retry)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
+ btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
btnSave.setOnClickListener { viewModel.confirmAndSave() }
btnRetry.setOnClickListener { viewModel.retry() }
+ btnGenerateReport.setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
new file mode 100644
index 0000000..fd504cb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..dc3117c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
new file mode 100644
index 0000000..8c3ac56
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)
diff --git a/android-app/app/src/main/res/drawable/ic_layers.xml b/android-app/app/src/main/res/drawable/ic_layers.xml
new file mode 100644
index 0000000..f86f83a
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_layers.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M11.99,18.54L4.62,12.81 3,14.07l9,7 9,-7-1.63,-1.27-8.38,5.74zM12,16l8.36,-6.54L22,8.07l-10,-7-10,7 1.63,1.39L12,16z"/>
+</vector>
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index feeb43d..1741c62 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -1,91 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout 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="match_parent"
+ android:orientation="vertical"
tools:context=".MainActivity">
- <!-- Main Content Area -->
- <androidx.constraintlayout.widget.ConstraintLayout
+ <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_marginBottom="56dp"> <!-- Space for BottomNav -->
+ android:layout_height="0dp"
+ android:layout_weight="1">
- <org.maplibre.android.maps.MapView
- android:id="@+id/mapView"
+ <!-- Main Content Area -->
+ <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
- android:layout_height="match_parent" />
+ android:layout_height="match_parent">
- <org.terst.nav.ui.map.ParticleWindView
- android:id="@+id/particle_wind_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:clickable="false"
- android:focusable="false" />
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/mapView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
- <!-- Overlay Fragment Container (for Log, Safety, Help) -->
- <FrameLayout
- android:id="@+id/fragment_container"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:visibility="gone"
- android:background="?attr/colorSurface" />
+ <org.terst.nav.ui.map.ParticleWindView
+ android:id="@+id/particle_wind_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clickable="false"
+ android:focusable="false" />
- </androidx.constraintlayout.widget.ConstraintLayout>
+ <!-- Overlay Fragment Container (for Log, Safety, Help) -->
+ <FrameLayout
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ android:background="?attr/colorSurface" />
- <!-- Collapsible Instrument Bottom Sheet -->
- <androidx.cardview.widget.CardView
- android:id="@+id/instrument_bottom_sheet"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- app:behavior_hideable="false"
- app:behavior_peekHeight="120dp"
- app:cardElevation="16dp"
- app:cardCornerRadius="24dp"
- app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
-
- <include
- layout="@layout/layout_instruments_sheet"
+ <!-- Quit button — stops all services and exits -->
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_quit"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:alpha="0.7"
+ app:icon="@drawable/ic_close"
+ app:iconSize="18dp"
+ app:iconGravity="textStart"
+ app:iconPadding="0dp"
+ app:cornerRadius="20dp"
+ app:elevation="4dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginTop="16dp"
+ android:layout_marginEnd="16dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginBottom="24dp" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <!-- Collapsible Instrument Bottom Sheet -->
+ <androidx.cardview.widget.CardView
+ android:id="@+id/instrument_bottom_sheet"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ app:behavior_hideable="false"
+ app:behavior_peekHeight="120dp"
+ app:cardElevation="16dp"
+ app:cardCornerRadius="24dp"
+ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
- </androidx.cardview.widget.CardView>
+ <include
+ layout="@layout/layout_instruments_sheet"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ </androidx.cardview.widget.CardView>
+
+ <!-- Persistent MOB Button (Crucial for safety, always on top) -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mob"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Man Overboard"
+ app:srcCompat="@android:drawable/ic_dialog_alert"
+ app:backgroundTint="@color/mob_button_background"
+ app:elevation="20dp"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|start" />
+
+ <!-- Record Track Button -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_record_track"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Record Track"
+ app:srcCompat="@drawable/ic_track_record"
+ app:elevation="20dp"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|end" />
+
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- Bottom Navigation -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_gravity="bottom"
android:background="?attr/colorSurface"
app:menu="@menu/bottom_nav_menu" />
- <!-- Persistent MOB Button (Crucial for safety, always on top) -->
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_mob"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="16dp"
- android:clickable="true"
- android:focusable="true"
- android:contentDescription="Man Overboard"
- app:srcCompat="@android:drawable/ic_dialog_alert"
- app:backgroundTint="@color/mob_button_background"
- app:layout_anchor="@id/instrument_bottom_sheet"
- app:layout_anchorGravity="top|start" />
-
- <!-- Record Track Button -->
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_record_track"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="16dp"
- android:clickable="true"
- android:focusable="true"
- android:contentDescription="Record Track"
- app:srcCompat="@drawable/ic_track_record"
- app:layout_anchor="@id/instrument_bottom_sheet"
- app:layout_anchorGravity="top|end" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_pretrip_report.xml b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
new file mode 100644
index 0000000..d7ede49
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Pre-Trip Planning"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="16dp" />
+
+ <!-- Boat Config (Simple for now) -->
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ app:cardCornerRadius="12dp"
+ app:strokeWidth="1dp"
+ app:strokeColor="?attr/colorOutlineVariant">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Vessel Profile"
+ android:textStyle="bold"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_vessel_info"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="35ft Sloop (Monohull)"
+ android:textSize="14sp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- Report Content -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_report"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="4dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="20dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Weather Summary"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_weather_summary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:layout_marginBottom="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Routing Suggestion"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_routing_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:layout_marginBottom="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Sail Plan"
+ android:textStyle="bold"
+ android:textSize="18sp"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_sail_plan_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="16sp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_pretrip"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="GENERATE PRE-TRIP REPORT" />
+
+ <ProgressBar
+ android:id="@+id/progress_pretrip"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml
index 5b2397e..f90420e 100644
--- a/android-app/app/src/main/res/layout/fragment_safety.xml
+++ b/android-app/app/src/main/res/layout/fragment_safety.xml
@@ -104,4 +104,13 @@
</com.google.android.material.card.MaterialCardView>
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_plan_trip"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="PLAN TRIP (PRE-TRIP REPORT)"
+ app:layout_constraintTop_toBottomOf="@id/card_anchor" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml
new file mode 100644
index 0000000..1ce0bde
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Trip Narrative"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Choose Narrative Style:"
+ android:textSize="14sp"
+ android:layout_marginBottom="8dp" />
+
+ <HorizontalScrollView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_styles"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_professional"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Professional"
+ android:checked="true" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_adventurous"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Adventurous" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_journal"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Journal" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_pirate"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Pirate" />
+
+ </com.google.android.material.chip.ChipGroup>
+ </HorizontalScrollView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="4dp"
+ app:strokeWidth="1dp"
+ app:strokeColor="?attr/colorOutlineVariant">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_narrative_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="Generate a report to see your story..."
+ android:textSize="16sp"
+ android:lineSpacingExtra="4dp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_refresh_report"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="REFRESH REPORT" />
+
+ <ProgressBar
+ android:id="@+id/progress_report"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml
index e5f864c..6d136be 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -63,4 +63,18 @@
android:text=""
android:textSize="14sp"
android:layout_marginTop="16dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="#DDDDDD"
+ android:layout_marginTop="32dp"
+ android:layout_marginBottom="32dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_report"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:text="GENERATE TRIP REPORT" />
</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index 0a84418..33a7bd9 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -1,32 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<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: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="40dp"
+ 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" />
- <!-- Grid of Instruments -->
+ <!--
+ 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="16dp"
+ android:layout_marginTop="12dp"
app:columnCount="3"
- app:rowCount="3"
- app:layout_constraintTop_toBottomOf="@id/drag_handle">
+ app:rowCount="2"
+ app:layout_constraintTop_toBottomOf="@id/drag_handle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
- <!-- Wind: AWS -->
+ <!-- AWS -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -35,10 +49,28 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="AWS" />
- <TextView android:id="@+id/value_aws" style="@style/InstrumentPrimaryValue" tools:text="12.5" />
+ <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>
- <!-- Compass: HDG -->
+ <!-- HDG: ° is part of the value string -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -47,10 +79,27 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="HDG" />
- <TextView android:id="@+id/value_hdg" style="@style/InstrumentPrimaryValue" tools:text="225" />
+ <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>
- <!-- Performance: BSP -->
+ <!-- BSP: no direction -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -59,10 +108,21 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="BSP" />
- <TextView android:id="@+id/value_bsp" style="@style/InstrumentPrimaryValue" tools:text="6.2" />
+ <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_bsp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="6.8" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
</LinearLayout>
- <!-- Wind: TWS -->
+ <!-- TWS -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -71,10 +131,28 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="TWS" />
- <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="15.0" />
+ <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>
- <!-- Compass: COG -->
+ <!-- COG: ° is part of the value string -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -83,10 +161,27 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="COG" />
- <TextView android:id="@+id/value_cog" style="@style/InstrumentPrimaryValue" tools:text="230" />
+ <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>
- <!-- Performance: SOG -->
+ <!-- SOG: no direction -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -95,72 +190,232 @@
android:gravity="center"
android:padding="8dp">
<TextView style="@style/InstrumentLabel" android:text="SOG" />
- <TextView android:id="@+id/value_sog" style="@style/InstrumentPrimaryValue" tools:text="6.5" />
+ <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_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="7.1" />
+ <TextView style="@style/InstrumentUnit" android:text="kts" />
+ </LinearLayout>
</LinearLayout>
- <!-- VMG -->
+ </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"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="VMG" />
- <TextView android:id="@+id/value_vmg" style="@style/InstrumentPrimaryValue" tools:text="4.2" />
+ <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="center_vertical">
+ <TextView
+ android:id="@+id/value_depth"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="42.0" />
+ <TextView style="@style/InstrumentUnit" android:text="ft" />
+ </LinearLayout>
</LinearLayout>
- <!-- Depth -->
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="#2B2930" />
+
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Depth" />
- <TextView android:id="@+id/value_depth" style="@style/InstrumentPrimaryValue" tools:text="12.0" />
+ <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="center_vertical">
+ <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="center_vertical">
+ <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>
- <!-- Polar % -->
+ <!-- Waves -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Polar %" />
- <TextView android:id="@+id/value_polar_pct" style="@style/InstrumentPrimaryValue" tools:text="95%" />
+ 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="center_vertical">
+ <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>
- </androidx.gridlayout.widget.GridLayout>
+ <!-- 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="center_vertical">
+ <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>
- <!-- Additional Detail (Visible when expanded) -->
- <TextView
- android:id="@+id/label_baro"
- style="@style/InstrumentLabel"
- android:text="Barometer"
- android:layout_marginTop="24dp"
- app:layout_constraintTop_toBottomOf="@id/instrument_grid"
- app:layout_constraintStart_toStartOf="parent" />
-
- <TextView
- android:id="@+id/value_baro"
- style="@style/InstrumentPrimaryValue"
- tools:text="1013.2 hPa"
- app:layout_constraintTop_toBottomOf="@id/label_baro"
- app:layout_constraintStart_toStartOf="parent" />
-
- <org.terst.nav.PolarDiagramView
- android:id="@+id/polar_diagram_view"
- android:layout_width="0dp"
- android:layout_height="0dp"
- android:layout_marginTop="24dp"
- app:layout_constraintDimensionRatio="1:1"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/value_baro"
- app:layout_constraintBottom_toBottomOf="parent" />
+ </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
new file mode 100644
index 0000000..c424606
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
@@ -0,0 +1,104 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="32dp"
+ android:background="?attr/colorSurface">
+
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="16dp"
+ android:text="MAP LAYERS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary" />
+
+ <!-- Base map selection -->
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_base"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ app:singleSelection="true"
+ app:selectionRequired="true"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_satellite"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Satellite" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_charts"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Charts" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_hybrid"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Hybrid" />
+
+ </com.google.android.material.chip.ChipGroup>
+
+ <!-- Wind divider -->
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginBottom="16dp"
+ android:background="@color/md_theme_surfaceVariant" />
+
+ <!-- Wind toggle -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind overlay"
+ android:textSize="15sp"
+ android:textColor="@color/instrument_text_normal" />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="OpenWeatherMap wind speed"
+ android:textSize="12sp"
+ android:textColor="@color/instrument_text_secondary" />
+ </LinearLayout>
+
+ <com.google.android.material.switchmaterial.SwitchMaterial
+ android:id="@+id/switch_wind"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
new file mode 100644
index 0000000..a26b76e
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="32dp"
+ android:background="?attr/colorSurface">
+
+ <!-- Drag handle -->
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <!-- Title -->
+ <TextView
+ android:id="@+id/summary_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary"
+ tools:text="Track · 06 Apr 14:32" />
+
+ <!-- Distance + Duration row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Distance" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_distance"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="12.3" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="nm" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Duration" />
+ <TextView
+ android:id="@+id/summary_duration"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2h 14m" />
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Speed row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Max Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_max_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="8.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="5.5" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Wind + Waves row (conditionally visible) -->
+ <LinearLayout
+ android:id="@+id/summary_conditions_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <LinearLayout
+ android:id="@+id/summary_wind_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Wind" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wind"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="14" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/summary_wave_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wave"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="ft" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
index b29fb08..e7fc15d 100644
--- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
@@ -5,9 +5,9 @@
android:icon="@drawable/ic_map"
android:title="Map" />
<item
- android:id="@+id/nav_instruments"
- android:icon="@drawable/ic_instruments"
- android:title="Instruments" />
+ android:id="@+id/nav_layers"
+ android:icon="@drawable/ic_layers"
+ android:title="Layers" />
<item
android:id="@+id/nav_log"
android:icon="@drawable/ic_log"
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index b380d2d..eb62cb2 100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -16,13 +16,13 @@
<color name="md_theme_errorContainer">#FFDAD6</color>
<color name="md_theme_onErrorContainer">#410002</color>
- <color name="md_theme_background">#FDFBFF</color>
- <color name="md_theme_onBackground">#1A1C1E</color>
- <color name="md_theme_surface">#FDFBFF</color>
- <color name="md_theme_onSurface">#1A1C1E</color>
- <color name="md_theme_surfaceVariant">#E0E2EC</color>
- <color name="md_theme_onSurfaceVariant">#44474E</color>
- <color name="md_theme_outline">#74777F</color>
+ <color name="md_theme_background">#1C1B1F</color>
+ <color name="md_theme_onBackground">#E6E1E5</color>
+ <color name="md_theme_surface">#1C1B1F</color>
+ <color name="md_theme_onSurface">#E6E1E5</color>
+ <color name="md_theme_surfaceVariant">#49454F</color>
+ <color name="md_theme_onSurfaceVariant">#CAC4D0</color>
+ <color name="md_theme_outline">#938F99</color>
<!-- Legacy / Functional Colors -->
<color name="black">#FF000000</color>
@@ -32,12 +32,12 @@
<color name="accent">#FF6D00</color>
<!-- Instrument Specific -->
- <color name="instrument_text_normal">#1A1C1E</color>
- <color name="instrument_text_secondary">#44474E</color>
- <color name="instrument_text_alarm">#BA1A1A</color>
- <color name="instrument_text_stale">#74777F</color>
- <color name="instrument_background">#FDFBFF</color>
- <color name="instrument_card_background">#F1F4F9</color>
+ <color name="instrument_text_normal">#E6E1E5</color>
+ <color name="instrument_text_secondary">#9A94A0</color>
+ <color name="instrument_text_alarm">#FF5449</color>
+ <color name="instrument_text_stale">#49454F</color>
+ <color name="instrument_background">#1C1B1F</color>
+ <color name="instrument_card_background">#2B2930</color>
<color name="mob_button_background">#BA1A1A</color>
<color name="anchor_button_background">#005FB0</color>
diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml
index 1b65ea9..21ff47a 100755
--- a/android-app/app/src/main/res/values/dimens.xml
+++ b/android-app/app/src/main/res/values/dimens.xml
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <!-- Font sizes for instrument display -->
- <dimen name="text_size_instrument_primary">24sp</dimen>
- <dimen name="text_size_instrument_secondary">18sp</dimen>
- <dimen name="text_size_instrument_label">14sp</dimen>
+ <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>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
index b18f4a8..8baa509 100755
--- a/android-app/app/src/main/res/values/themes.xml
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -24,7 +24,7 @@
<item name="colorOnErrorContainer">@color/md_theme_onErrorContainer</item>
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorSurface</item>
- <item name="android:windowLightStatusBar">true</item>
+ <item name="android:windowLightStatusBar">false</item>
</style>
<!-- Night Vision Theme (Stays Dark and Red) -->
@@ -45,8 +45,9 @@
<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.1</item>
- <item name="android:textStyle">bold</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">
@@ -54,15 +55,70 @@
<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">bold</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
<item name="android:includeFontPadding">false</item>
</style>
- <style name="InstrumentSecondaryLabel" parent="android:Widget.TextView">
- <item name="android:textColor">@color/instrument_text_secondary</item>
- <item name="android:textSize">@dimen/text_size_instrument_secondary</item>
+ <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">#6B6070</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>
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
index 749630f..c455085 100644
--- a/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
@@ -3,8 +3,7 @@ package org.terst.nav.data.repository
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.*
-import io.mockk.coEvery
-import io.mockk.mockk
+import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Before
@@ -36,6 +35,9 @@ class WeatherRepositoryTest {
time = listOf("2026-03-13T00:00", "2026-03-13T01:00"),
waveHeight = listOf(1.2, 1.1),
waveDirection = listOf(250.0, 255.0),
+ swellWaveHeight = emptyList(),
+ swellWaveDirection = emptyList(),
+ swellWavePeriod = emptyList(),
oceanCurrentVelocity = listOf(0.3, 0.4),
oceanCurrentDirection = listOf(180.0, 185.0)
)
@@ -48,7 +50,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems maps weather response to ForecastItem list`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -65,8 +67,25 @@ class WeatherRepositoryTest {
}
@Test
+ fun `fetchCurrentConditions maps responses to MarineConditions`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchCurrentConditions(37.5, -122.3)
+
+ if (result.isFailure) {
+ fail("fetchCurrentConditions failed with: ${result.exceptionOrNull()}")
+ }
+ val cond = result.getOrThrow()
+ assertEquals(15.0, cond.windSpeedKt!!, 0.001)
+ assertEquals(1.2, cond.waveHeightM!!, 0.001)
+ assertEquals(0.3 * 1.94384, cond.currentSpeedKt!!, 0.001)
+ assertEquals(180.0, cond.currentDirDeg!!, 0.001)
+ }
+
+ @Test
fun `fetchWindArrow returns WindArrow for first (current) hour`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)
@@ -81,7 +100,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems returns failure when weather API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Network error")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } throws RuntimeException("Network error")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -91,7 +110,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchWindArrow returns failure when API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Timeout")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } throws RuntimeException("Timeout")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt
new file mode 100644
index 0000000..7ed7ec7
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt
@@ -0,0 +1,83 @@
+package org.terst.nav.track
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GpxRoundTripTest {
+
+ private fun roundTrip(points: List<TrackPoint>): List<TrackPoint> {
+ val gpx = GpxSerializer.serialize(points, "Test Track")
+ return GpxParser.parse(gpx.byteInputStream())
+ }
+
+ @Test
+ fun `round-trip preserves lat and lon`() {
+ val pt = TrackPoint(lat = 37.8044, lon = -122.2712, sogKnots = 6.1, cogDeg = 247.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(37.8044, result.lat, 0.00001)
+ assertEquals(-122.2712, result.lon, 0.00001)
+ }
+
+ @Test
+ fun `round-trip preserves sog and cog`() {
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 5.3, cogDeg = 183.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(5.3, result.sogKnots, 0.001)
+ assertEquals(183.0, result.cogDeg, 0.001)
+ }
+
+ @Test
+ fun `round-trip preserves optional nav fields`() {
+ val pt = TrackPoint(
+ lat = 1.0, lon = 2.0, sogKnots = 4.0, cogDeg = 90.0,
+ depthMeters = 12.5, baroHpa = 1013.2, windSpeedKnots = 14.0,
+ windAngleDeg = 45.0, isTrueWind = true, waveHeightM = 1.2
+ )
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(12.5, result.depthMeters!!, 0.001)
+ assertEquals(1013.2, result.baroHpa!!, 0.001)
+ assertEquals(14.0, result.windSpeedKnots!!, 0.001)
+ assertEquals(45.0, result.windAngleDeg!!, 0.001)
+ assertTrue(result.isTrueWind)
+ assertEquals(1.2, result.waveHeightM!!, 0.001)
+ }
+
+ @Test
+ fun `round-trip with null optional fields leaves them null`() {
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertNull(result.depthMeters)
+ assertNull(result.baroHpa)
+ assertNull(result.windSpeedKnots)
+ }
+
+ @Test
+ fun `round-trip preserves multiple points in order`() {
+ val points = listOf(
+ TrackPoint(lat = 1.0, lon = 1.0, sogKnots = 1.0, cogDeg = 0.0, timestampMs = 1000L),
+ TrackPoint(lat = 2.0, lon = 2.0, sogKnots = 2.0, cogDeg = 90.0, timestampMs = 2000L),
+ TrackPoint(lat = 3.0, lon = 3.0, sogKnots = 3.0, cogDeg = 180.0, timestampMs = 3000L),
+ )
+ val result = roundTrip(points)
+ assertEquals(3, result.size)
+ assertEquals(1.0, result[0].lat, 0.00001)
+ assertEquals(2.0, result[1].lat, 0.00001)
+ assertEquals(3.0, result[2].lat, 0.00001)
+ }
+
+ @Test
+ fun `round-trip preserves timestamp`() {
+ val ts = 1712345678000L
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0, timestampMs = ts)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(ts, result.timestampMs)
+ }
+
+ @Test
+ fun `track name with special chars is escaped`() {
+ val gpx = GpxSerializer.serialize(emptyList(), "Track & \"Fun\" <test>")
+ assertTrue(gpx.contains("Track &amp; &quot;Fun&quot; &lt;test&gt;"))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt
new file mode 100644
index 0000000..2daaf45
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt
@@ -0,0 +1,56 @@
+package org.terst.nav.track
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TrackSummaryTest {
+
+ private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) =
+ TrackPoint(lat = lat, lon = lon, sogKnots = sog, cogDeg = 0.0, timestampMs = ts)
+
+ @Test
+ fun `distance between two points one nm apart`() {
+ // 1 nautical mile north along the prime meridian ≈ 0.01667° latitude
+ val a = pt(0.0, 0.0, ts = 0L)
+ val b = pt(0.016667, 0.0, ts = 60_000L)
+ val s = summarise(listOf(a, b))
+ assertEquals(1.0, s.distanceNm, 0.01)
+ }
+
+ @Test
+ fun `duration is last minus first timestamp`() {
+ val points = listOf(pt(0.0, 0.0, ts = 1_000L), pt(0.0, 0.0, ts = 61_000L))
+ assertEquals(60_000L, summarise(points).durationMs)
+ }
+
+ @Test
+ fun `max sog picks highest value`() {
+ val points = listOf(pt(0.0, 0.0, sog = 4.0), pt(0.0, 0.0, sog = 9.2), pt(0.0, 0.0, sog = 6.0))
+ assertEquals(9.2, summarise(points).maxSogKt, 0.001)
+ }
+
+ @Test
+ fun `avg wind is null when no wind data`() {
+ val s = summarise(listOf(pt(0.0, 0.0)))
+ assertNull(s.avgWindKt)
+ }
+
+ @Test
+ fun `avg wind averages available readings`() {
+ val points = listOf(
+ TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 10.0),
+ TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 20.0),
+ )
+ assertEquals(15.0, summarise(points).avgWindKt!!, 0.001)
+ }
+
+ @Test
+ fun `avg wave height averages available readings`() {
+ val points = listOf(
+ TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 1.0),
+ TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 3.0),
+ )
+ assertEquals(2.0, summarise(points).avgWaveHeightM!!, 0.001)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt
new file mode 100644
index 0000000..e26b67d
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt
@@ -0,0 +1,25 @@
+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
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt
new file mode 100644
index 0000000..a749ba2
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt
@@ -0,0 +1,39 @@
+package org.terst.nav.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Locale
+
+class InstrumentHandlerTest {
+
+ @Test
+ fun `metresToFeet converts correctly`() {
+ assertEquals(3.28f, metresToFeet(1.0).toFloat(), 0.01f)
+ }
+
+ @Test
+ fun `metresToFeet zero returns zero`() {
+ assertEquals(0f, metresToFeet(0.0).toFloat(), 0.001f)
+ }
+
+ @Test
+ fun `formatFt formats to one decimal`() {
+ val result = formatFt(3.28084, Locale.US)
+ assertEquals("3.3", result)
+ }
+
+ @Test
+ fun `formatBearing appends degree symbol`() {
+ assertEquals("275°", formatBearing(275.0, Locale.US))
+ }
+
+ @Test
+ fun `formatBearing rounds to zero decimals`() {
+ assertEquals("123°", formatBearing(123.4, Locale.US))
+ }
+
+ @Test
+ fun `formatPeriod appends s`() {
+ assertEquals("· 14s", formatPeriod(14.0, Locale.US))
+ }
+}
diff --git a/docs/superpowers/plans/2026-04-03-map-interaction.md b/docs/superpowers/plans/2026-04-03-map-interaction.md
new file mode 100644
index 0000000..9f8fa13
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-03-map-interaction.md
@@ -0,0 +1,256 @@
+# Map Interaction 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:** Add manual pan/zoom with auto-follow GPS centering, a Recenter button that appears when the user pans away, and full UI fade-out in manual mode.
+
+**Architecture:** `MapHandler` owns an `isFollowing: StateFlow<Boolean>` and registers a `MapLibreMap.OnCameraMoveStartedListener` to detect gesture-driven camera moves. `MainActivity` collects the flow and animates the bottom sheet, nav bar, FABs, and recenter button in/out.
+
+**Tech Stack:** MapLibre Android SDK (`MapLibreMap.OnCameraMoveStartedListener`, `REASON_API_GESTURE`), Kotlin `StateFlow`, Android `View.animate()`.
+
+---
+
+## File Map
+
+| File | What changes |
+|------|-------------|
+| `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon`, gesture listener, `recenter()`, guard in `centerOnLocation()` |
+| `android-app/app/src/main/res/layout/activity_main.xml` | Add `fab_recenter` pill button inside the map ConstraintLayout |
+| `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click |
+
+---
+
+### Task 1: Extend MapHandler with follow state and gesture detection
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`
+
+- [ ] **Step 1: Add imports and new fields**
+
+Open `MapHandler.kt`. Add these imports at the top (after existing imports):
+
+```kotlin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.maplibre.android.maps.MapLibreMap
+```
+
+Add these fields inside the `MapHandler` class, before `setupLayers`:
+
+```kotlin
+private val _isFollowing = MutableStateFlow(true)
+val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+private var lastLat: Double = 0.0
+private var lastLon: Double = 0.0
+```
+
+- [ ] **Step 2: Register the gesture listener in the constructor**
+
+Replace the class declaration line:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+```
+with:
+```kotlin
+class MapHandler(private val maplibreMap: MapLibreMap) {
+
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ }
+```
+
+- [ ] **Step 3: Guard centerOnLocation and store last position**
+
+Replace the existing `centerOnLocation` method:
+```kotlin
+fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+ val position = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(zoom)
+ .build()
+ maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+}
+```
+
+- [ ] **Step 4: Add recenter()**
+
+Add this method after `centerOnLocation`:
+```kotlin
+fun recenter() {
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon)
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+git commit -m "feat(map): add isFollowing state and gesture-driven manual mode to MapHandler"
+```
+
+---
+
+### Task 2: Add the Recenter button to the layout
+
+**Files:**
+- Modify: `android-app/app/src/main/res/layout/activity_main.xml`
+
+- [ ] **Step 1: Add fab_recenter inside the ConstraintLayout**
+
+In `activity_main.xml`, find the ConstraintLayout that wraps `mapView` (around line 10). It currently contains `mapView` and `fragment_container`. Add the recenter button as a third child, before the closing `</androidx.constraintlayout.widget.ConstraintLayout>` tag:
+
+```xml
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginBottom="24dp" />
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add android-app/app/src/main/res/layout/activity_main.xml
+git commit -m "feat(ui): add fab_recenter pill button to map layout"
+```
+
+---
+
+### Task 3: Wire MainActivity to animate UI on follow state changes
+
+**Files:**
+- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`
+
+- [ ] **Step 1: Add imports**
+
+Add these imports to `MainActivity.kt` (with existing imports):
+```kotlin
+import androidx.cardview.widget.CardView
+import com.google.android.material.button.MaterialButton
+```
+
+- [ ] **Step 2: Add view fields**
+
+In the `MainActivity` class body, alongside the existing `fabRecordTrack` declaration, add:
+```kotlin
+private lateinit var fabMob: FloatingActionButton
+private lateinit var fabRecenter: MaterialButton
+private lateinit var bottomSheet: CardView
+private lateinit var bottomNav: BottomNavigationView
+```
+
+- [ ] **Step 3: Add the fade helpers**
+
+Add these two private methods to `MainActivity` (before `onStart`):
+
+```kotlin
+private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+}
+
+private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
+ }
+}
+```
+
+- [ ] **Step 4: Wire up views and observe isFollowing in initializeUI**
+
+In `initializeUI()`, after the existing `fabRecordTrack` setup, add:
+
+```kotlin
+fabMob = findViewById(R.id.fab_mob)
+fabRecenter = findViewById(R.id.fab_recenter)
+bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+bottomNav = findViewById(R.id.bottom_navigation)
+
+fabRecenter.setOnClickListener {
+ mapHandler?.recenter()
+}
+```
+
+- [ ] **Step 5: Observe isFollowing after mapHandler is created**
+
+In `setupMap()`, inside the `getMapAsync` lambda, after `mapHandler = MapHandler(maplibreMap)`, add:
+
+```kotlin
+lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack)
+ } else {
+ fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Manual smoke test**
+
+Build and install. Verify:
+1. App opens — bottom sheet, nav, FABs visible; no Recenter button
+2. Pan the map — all UI fades out, Recenter button fades in
+3. Tap Recenter — UI fades back in, map animates to GPS position, Recenter gone
+4. GPS updates while in manual mode — map does NOT jump back to GPS position
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+git commit -m "feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing"
+```
+
+---
+
+### Task 4: Request Gemini review, fix issues, loop until clean, merge
+
+- [ ] **Step 1: Push branch and open PR**
+```bash
+git push local main
+git push github main
+```
+Then open a PR (or request review inline if working on main).
+
+- [ ] **Step 2: Request code review using code-review:code-review skill**
+
+Invoke `code-review:code-review` skill against the PR. Address all issues with confidence ≥ 80.
+
+- [ ] **Step 3: Loop until review returns clean**
+
+Re-request review after each fix. Stop when no issues ≥ 80 are found.
+
+- [ ] **Step 4: Merge and push to both remotes**
+```bash
+gh pr merge <number> --repo thepeterstone/nav --squash --delete-branch
+git checkout main && git pull github main
+git push local main
+```
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) |
diff --git a/docs/superpowers/specs/2026-04-03-map-interaction-design.md b/docs/superpowers/specs/2026-04-03-map-interaction-design.md
new file mode 100644
index 0000000..02e38e1
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-03-map-interaction-design.md
@@ -0,0 +1,59 @@
+# Map Interaction Design
+_2026-04-03_
+
+## Overview
+
+Enable free map pan/zoom while keeping GPS auto-follow as the default. When the user gestures on the map, the UI hides and a Recenter button appears. Tapping Recenter restores auto-follow and the full UI.
+
+## State
+
+A single `isFollowing: Boolean` flag owned by `MapHandler`. Starts `true`. This is the only piece of new state.
+
+## Entering Manual Mode
+
+`MapLibreMap.addOnCameraMoveStartedListener` fires with `REASON_API_GESTURE` when the user initiates a pan, pinch-zoom, or rotate. On that event:
+
+- `MapHandler.isFollowing` → `false`
+- `MapHandler` emits via a new `StateFlow<Boolean> isFollowing` exposed to MainActivity
+- `MapHandler.centerOnLocation()` becomes a no-op while `!isFollowing` — GPS updates still arrive, last position is stored
+
+## Returning to Auto-Follow
+
+`fab_recenter` click handler:
+
+- Calls `MapHandler.recenter()` — sets `isFollowing = true`, calls `centerOnLocation(lastLat, lastLon)`
+- `isFollowing` StateFlow emits `true` → MainActivity restores UI
+
+## UI Changes
+
+### New element: `fab_recenter`
+
+- Pill-shaped button (`wrap_content` width, 40dp height, 20dp corner radius)
+- Label: "⊙ Recenter"
+- Position: centered horizontally, `24dp` above the bottom of the map `ConstraintLayout`
+- `android:visibility="gone"` by default
+- Elevation: 20dp (above the instrument sheet)
+
+### Visibility toggling (MainActivity)
+
+When `isFollowing` → `false`:
+- Fade out (alpha 0, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track`
+- Show `fab_recenter` (visibility VISIBLE, fade in 150ms)
+
+When `isFollowing` → `true`:
+- Hide `fab_recenter` (fade out 150ms, then GONE)
+- Fade in (alpha 1, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track`
+
+## Files to Change
+
+| File | Change |
+|------|--------|
+| `MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon` storage, `recenter()`, guard in `centerOnLocation()`, register `OnCameraMoveStartedListener` |
+| `activity_main.xml` | Add `fab_recenter` pill button |
+| `MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click |
+
+## Out of Scope
+
+- Tapping the map (without gesture) to restore UI — not requested
+- Timeout to auto-restore UI — not requested
+- Zoom-level persistence across recenter — not requested
diff --git a/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md b/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md
new file mode 100644
index 0000000..4e4e0c4
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-04-instrument-sheet-visual-redesign.md
@@ -0,0 +1,156 @@
+# Instrument Sheet Visual Redesign
+
+**Date:** 2026-04-04
+**Status:** Approved for implementation
+
+## Overview
+
+Redesign `layout_instruments_sheet.xml` and supporting code to replace the plain divider between the instrument grid and forecast section with an animated water-state canvas, enhance typography, add direction indicators, use imperial units, and prevent touch-through to the map.
+
+---
+
+## 1. Animated Wave Divider Canvas
+
+Replace the static `View` divider (and `FORECAST` section header) with a `WaveView` — a custom `View` subclass that draws an animated ocean scene.
+
+**Visual behaviour:**
+- Height: ~72dp
+- Top half gradient: surface background colour (`#1C1B1F`) → dark transitional blue (`#162433`)
+- Bottom half gradient: ocean blue (`#0B3050`) → ocean section background (`#0D2137`)
+- Primary sine wave: amplitude driven by **swell height (ft)**, wavelength proportional to **swell period (s)**
+- Secondary high-frequency sine: amplitude driven by **wind wave height (ft)**, wavelength ~35% of swell wavelength
+- Whitecaps: short curved strokes at wind-wave crests when amplitude exceeds threshold
+- Surface shimmer: thin semi-transparent cyan stroke along the wave waterline
+- Animated via `ValueAnimator` / `postInvalidateOnAnimation()` loop; paused when view is not visible
+
+**Data inputs** (passed from `InstrumentHandler`):
+- `swellHeightFt: Float`
+- `swellPeriodSec: Float`
+- `windWaveHeightFt: Float`
+
+**File:** `android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt`
+
+---
+
+## 2. Typography Enhancements
+
+**Instrument values (grid + depth/baro):**
+- Value: `26sp`, weight `300` (light), colour `#E6E1E5`
+- Unit label: `10sp`, weight `400`, colour `#79747E` — baseline-aligned, small gap after value
+
+**Heading/COG special case:**
+- The `°` symbol is part of the value string, not a separate unit label (e.g. `"247°"` rendered in the value style)
+
+**Forecast values (ocean section):**
+- Value: `22sp`, weight `300`, colour `#CAE8FF`
+- Unit: `10sp`, colour `#3B6785`
+
+**Forecast labels:**
+- `10sp`, weight `500`, letter-spacing `1.2sp`, colour `#5B9EC2`, uppercase
+
+---
+
+## 3. Direction Indicators
+
+A single reusable SVG-style drawable approach: a `DirectionArrowView` (custom `View`, ~14dp × 14dp) that draws:
+- Thin circle outline (stroke only, no fill)
+- Chevron arrow (notched polygon) pointing in the given bearing
+- Rotated via `bearing` property setter
+
+**Instrument section style:** outline `#3A3640`, arrow fill `#9A94A0`
+**Forecast section style:** outline `#1E4A6E`, arrow fill `#6FC3E8`
+
+Applied to:
+- AWS, TWS (wind speed cells) — bearing = apparent/true wind direction
+- HDG, COG (heading/course cells) — bearing = heading value itself
+- Current, Waves, Swell (forecast cells) — bearing from forecast data
+
+Cells without a direction (BSP, SOG, Depth, Baro) have no arrow.
+
+**File:** `android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt`
+
+---
+
+## 4. Forecast Cell Layout
+
+Each forecast cell (Current, Waves, Swell) is two lines:
+
+```
+CURRENT ← forecast-label style
+0.8 kts ← value + unit baseline-aligned
+[arrow] 185° ← DirectionArrowView + bearing text
+```
+
+Swell adds period to the direction line:
+```
+[arrow] 260° · 14s
+```
+
+`"Wave"` → `"Waves"`, `"Curr"` → `"Current"`.
+No section header ("FORECAST" label removed).
+
+---
+
+## 5. Units: Feet Instead of Metres
+
+All heights from the data layer are in metres and must be converted before display (`* 3.28084`):
+
+- `DepthData.depthMeters` — converted in `InstrumentHandler.updateDisplay()`
+- `MarineConditions.waveHeightM` — converted in `InstrumentHandler.updateConditions()`
+- `MarineConditions.swellHeightM` — converted in `InstrumentHandler.updateConditions()`
+
+The `WaveView` data inputs (`swellHeightFt`, `windWaveHeightFt`) therefore receive already-converted values.
+
+---
+
+## 6. Remove Reports Section
+
+Remove from `layout_instruments_sheet.xml`:
+- `reports_divider` View
+- `label_reports` TextView
+- `reports_row` LinearLayout (containing `btn_nav_pretrip` and `btn_nav_tripreport`)
+
+Remove corresponding wiring from `MainActivity` / wherever the button click listeners are set up.
+
+---
+
+## 7. Prevent Map Touch-Through
+
+The bottom sheet currently sits inside a `CoordinatorLayout`. Touch events on the sheet's non-interactive areas fall through to the `MapFragment` below.
+
+**Fix:** Set `android:clickable="true"` and `android:focusable="true"` on the root `ConstraintLayout` of `layout_instruments_sheet.xml`. This consumes all touch events on the sheet, preventing drag gestures from reaching the map.
+
+---
+
+## 8. Colour Palette Summary
+
+| Token | Value | Use |
+|---|---|---|
+| Surface | `#1C1B1F` | Instrument section background |
+| Outline dim | `#2B2930` | Subtle dividers within instrument grid |
+| Value | `#E6E1E5` | Instrument values |
+| Label | `#CAC4D0` | Instrument labels |
+| Unit | `#79747E` | Instrument unit labels |
+| Arrow outline (sky) | `#3A3640` | Arrow circle border in instrument section |
+| Arrow fill (sky) | `#9A94A0` | Arrow chevron in instrument section |
+| Ocean bg | `#0D2137` | Forecast section background |
+| Ocean value | `#CAE8FF` | Forecast values |
+| Ocean label | `#5B9EC2` | Forecast labels |
+| Ocean unit | `#3B6785` | Forecast unit labels |
+| Ocean arrow outline | `#1E4A6E` | Arrow circle border in forecast section |
+| Ocean arrow fill | `#6FC3E8` | Arrow chevron in forecast section |
+| Bearing text | `#6FC3E8` | Bearing degrees in forecast section |
+| Period text | `#4A7FA0` | Swell period in forecast section |
+
+---
+
+## Files Changed
+
+| File | Change |
+|---|---|
+| `layout_instruments_sheet.xml` | Major restructure: wave canvas, cell layout changes, remove reports |
+| `WaveView.kt` | New custom View |
+| `DirectionArrowView.kt` | New custom View |
+| `InstrumentHandler.kt` | Add `updateWave()`, `updateDirections()`, feet conversion for depth |
+| `MainActivity.kt` | Remove report button wiring |
+| `styles.xml` / `themes.xml` | Add/update `InstrumentLabel`, `InstrumentPrimaryValue` styles if needed |
diff --git a/scripts/.claude/ct-fix-weather-fallback.yaml b/scripts/.claude/ct-fix-weather-fallback.yaml
new file mode 100644
index 0000000..97823c7
--- /dev/null
+++ b/scripts/.claude/ct-fix-weather-fallback.yaml
@@ -0,0 +1,64 @@
+name: "Fix WeatherActivity silent fallback to San Francisco"
+description: "WeatherActivity silently uses lat=37.8, lon=-122.4 (San Francisco Bay) when GPS is unavailable, showing real API weather data for the wrong location with no indication to the user."
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ WeatherActivity has:
+ (android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt, lines ~23-24)
+
+ private val defaultLat = 37.8
+ private val defaultLon = -122.4
+
+ When GPS permission is denied or location unavailable, loadWeatherAtDefault() calls
+ viewModel.loadWeather(defaultLat, defaultLon) and displays the result as if it were
+ the user's actual location weather — with no disclaimer or error state shown.
+
+ Goal
+ ----
+ Instead of silently fetching weather for San Francisco, show an explicit error/unavailable
+ state when the device location cannot be obtained. Do not fetch any weather data in that path.
+
+ Remove defaultLat/defaultLon and loadWeatherAtDefault() entirely.
+ When location is unavailable, set UiState.Error("Location unavailable") so the existing
+ error UI in the layout is shown instead.
+
+ Step 1 — Read the current WeatherActivity
+ -----------------------------------------
+ Read android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt in full
+ to understand the current permission and location flow before making changes.
+
+ Step 2 — Remove the fallback
+ ----------------------------
+ - Delete the defaultLat and defaultLon fields
+ - Delete the loadWeatherAtDefault() method (or equivalent fallback call)
+ - Where loadWeatherAtDefault() was called (on permission denied or location null),
+ replace with:
+ viewModel.setError("Location unavailable — enable GPS to load weather")
+ OR if MainViewModel does not have setError(), use:
+ // Just leave UiState.Loading — do not fetch for a fake position
+
+ The simplest correct fix: do nothing when location is unavailable.
+ The existing "Loading" spinner is less harmful than wrong-location data.
+
+ Step 3 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL.
+
+ Step 4 — Commit and push
+ ------------------------
+ git add android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt
+ git commit -m "fix(weather): remove silent fallback to San Francisco coordinates
+
+ When GPS was unavailable, WeatherActivity fetched and displayed real weather
+ data for lat=37.8 lon=-122.4 (San Francisco Bay) with no indication to the
+ user. Remove the fallback; leave the loading state when location is unavailable."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "weather"
+ - "bug"
+ - "ux"
diff --git a/scripts/.claude/ct-remove-mock-tidal.yaml b/scripts/.claude/ct-remove-mock-tidal.yaml
new file mode 100644
index 0000000..86d4e76
--- /dev/null
+++ b/scripts/.claude/ct-remove-mock-tidal.yaml
@@ -0,0 +1,83 @@
+name: "Remove MockTidalCurrentGenerator"
+description: "LocationService generates random fake tidal current arrows near a hardcoded Solent coordinate (50.8, -1.3) every 60 seconds regardless of boat position. Remove the mock generator; leave the layer empty until a real tidal API is integrated."
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ In LocationService.onCreate() there is a coroutine loop:
+ (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, lines ~113-119)
+
+ serviceScope.launch {
+ while (true) {
+ val currents = MockTidalCurrentGenerator.generateMockCurrents()
+ _tidalCurrentState.update { it.copy(currents = currents) }
+ kotlinx.coroutines.delay(60000)
+ }
+ }
+
+ MockTidalCurrentGenerator.generateMockCurrents() produces 10 TidalCurrent objects with:
+ - Random speedKnots (0–5 kn)
+ - Random directionDegrees (0–360°)
+ - Positions scattered within 0.05° of lat=50.8, lon=-1.3 (Solent, UK)
+ regardless of actual boat position.
+ (android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt)
+
+ TidalCurrentState has an isVisible flag (defaults false) and a currents list.
+ MapHandler.updateTidalCurrents() respects isVisible — when false it clears the layer.
+
+ Goal
+ ----
+ 1. Remove the mock generator loop from LocationService.onCreate()
+ 2. Delete MockTidalCurrentGenerator.kt
+ 3. Ensure _tidalCurrentState starts with isVisible=false and empty currents (the default)
+ 4. Remove the import of MockTidalCurrentGenerator from LocationService
+
+ Do NOT remove TidalCurrentState, _tidalCurrentState, or the tidalCurrentState public flow —
+ those are needed when a real tidal API is added later.
+ Do NOT remove ACTION_TOGGLE_TIDAL_VISIBILITY handling in onStartCommand — keep the
+ visibility toggle working for future use.
+
+ Step 1 — Remove the mock loop from LocationService
+ ---------------------------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt:
+
+ Delete the entire block (including the comment):
+ // Mock tidal current data generator
+ serviceScope.launch {
+ while (true) {
+ val currents = MockTidalCurrentGenerator.generateMockCurrents()
+ _tidalCurrentState.update { it.copy(currents = currents) }
+ kotlinx.coroutines.delay(60000) // Update every minute
+ }
+ }
+
+ Remove the import:
+ import org.terst.nav.MockTidalCurrentGenerator
+
+ Step 2 — Delete MockTidalCurrentGenerator.kt
+ ---------------------------------------------
+ Delete android-app/app/src/main/kotlin/org/terst/nav/MockTidalCurrentGenerator.kt
+
+ Step 3 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL with no references to MockTidalCurrentGenerator remaining.
+
+ Step 4 — Commit and push
+ ------------------------
+ git add -A
+ git commit -m "chore: remove MockTidalCurrentGenerator
+
+ The mock generator was producing 10 random tidal current arrows near a
+ hardcoded Solent coordinate (50.8, -1.3) every 60 seconds regardless of
+ boat position. Remove it entirely. The tidal overlay infrastructure
+ (TidalCurrentState, mapHandler.updateTidalCurrents) is retained for when
+ a real tidal current API is integrated."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "cleanup"
+ - "tidal"
+ - "mock-data"
diff --git a/scripts/.claude/ct-show-swell-direction.yaml b/scripts/.claude/ct-show-swell-direction.yaml
new file mode 100644
index 0000000..6021bf0
--- /dev/null
+++ b/scripts/.claude/ct-show-swell-direction.yaml
@@ -0,0 +1,75 @@
+name: "Display swell direction in instrument sheet"
+description: "MarineConditions.swellDirDeg is fetched from Open-Meteo and stored but never shown. The Swell cell shows height and period but drops the direction."
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ fetchCurrentConditions() populates MarineConditions.swellDirDeg from
+ swell_wave_direction (degrees true from Open-Meteo).
+ (android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt, line 13)
+
+ The instrument sheet expanded section has a Swell column:
+ (android-app/app/src/main/res/layout/layout_instruments_sheet.xml)
+ <LinearLayout vertical gravity=center>
+ <TextView style="InstrumentLabel" text="Swell" />
+ <TextView id="value_swell_ht" style="InstrumentPrimaryValue" /> <!-- e.g. "0.8 m" -->
+ <TextView id="value_swell_per" style="InstrumentLabel" /> <!-- e.g. "8 s" -->
+ </LinearLayout>
+
+ MainActivity's marineConditions collector sets swellHt and swellPer but ignores swellDirDeg:
+ (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, marineConditions collector)
+
+ InstrumentHandler.updateConditions() has swellPer but no swellDir parameter.
+ (android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt)
+
+ Goal
+ ----
+ Show swell direction by updating value_swell_per to contain both direction and period
+ (e.g. "045° / 8 s"), avoiding the need for a fourth TextView in an already-tight column.
+ Alternatively add a third sub-label TextView — use whichever fits better visually.
+
+ The simplest approach: combine direction and period into value_swell_per as "DIR° / Xs".
+
+ Step 1 — Update updateConditions() in InstrumentHandler
+ --------------------------------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt,
+ the swellPer parameter already exists. No layout change needed if we combine
+ direction + period into that field.
+
+ Step 2 — Update the marineConditions collector in MainActivity
+ --------------------------------------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
+ in observeDataSources(), update the swellPer line in updateConditions() to combine
+ direction and period:
+
+ swellPer = when {
+ c.swellDirDeg != null && c.swellPeriodS != null ->
+ "%.0f° / %.0f s".format(Locale.getDefault(), c.swellDirDeg, c.swellPeriodS)
+ c.swellPeriodS != null ->
+ "%.0f s".format(Locale.getDefault(), c.swellPeriodS)
+ c.swellDirDeg != null ->
+ "%.0f°".format(Locale.getDefault(), c.swellDirDeg)
+ else -> "—"
+ }
+
+ Step 3 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL.
+
+ Step 4 — Commit and push
+ ------------------------
+ git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+ git commit -m "feat(instruments): show swell direction alongside swell period
+
+ swellDirDeg was fetched from Open-Meteo and stored in MarineConditions
+ but never displayed. Combines direction and period in the swell sub-label
+ as e.g. '045° / 8 s'."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "instruments"
+ - "swell"
+ - "forecast"
diff --git a/scripts/.claude/ct-show-wind-direction.yaml b/scripts/.claude/ct-show-wind-direction.yaml
new file mode 100644
index 0000000..3ac9276
--- /dev/null
+++ b/scripts/.claude/ct-show-wind-direction.yaml
@@ -0,0 +1,92 @@
+name: "Display forecast wind direction (TWD) in instrument sheet"
+description: "MarineConditions.windDirDeg is fetched from Open-Meteo and stored in the model but never displayed anywhere. Show it as a sub-label under the TWS cell."
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ fetchCurrentConditions() in WeatherRepository populates MarineConditions.windDirDeg
+ from the Open-Meteo hourly winddirection_10m field (in degrees true).
+ (android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt, line ~63)
+ (android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt, line 9)
+
+ In MainActivity.observeDataSources(), the marineConditions collector reads windSpeedKt
+ (displays as TWS) but never reads windDirDeg:
+ (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt, marineConditions collector)
+
+ The instrument sheet layout has a TWS cell:
+ (android-app/app/src/main/res/layout/layout_instruments_sheet.xml)
+ <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" />
+
+ Goal
+ ----
+ Show wind direction as a secondary line below the TWS value (e.g. "045°") using the
+ same pattern used for curr_dir below curr_spd in the conditions section.
+
+ Step 1 — Add value_twd TextView to layout
+ ------------------------------------------
+ In android-app/app/src/main/res/layout/layout_instruments_sheet.xml,
+ find the TWS LinearLayout:
+
+ <LinearLayout ... android:gravity="center" android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="TWS" />
+ <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="—" />
+ </LinearLayout>
+
+ Add a third child TextView for the direction:
+
+ <LinearLayout ... android:gravity="center" android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="TWS" />
+ <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="—" />
+ <TextView android:id="@+id/value_twd" style="@style/InstrumentLabel" tools:text="—" />
+ </LinearLayout>
+
+ Step 2 — Add valueTwd field to InstrumentHandler
+ -------------------------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt:
+
+ Add constructor parameter:
+ private val valueTwd: TextView
+
+ Add twd parameter to updateDisplay():
+ twd: String? = null
+
+ Add in the updateDisplay body:
+ twd?.let { valueTwd.text = it }
+
+ Step 3 — Wire in MainActivity
+ ------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
+ in setupHandlers(), add to InstrumentHandler constructor:
+ valueTwd = findViewById(R.id.value_twd)
+
+ In setupHandlers(), add "—" initial value:
+ instrumentHandler?.updateDisplay(..., twd = "—")
+
+ In observeDataSources(), in the marineConditions collector, add:
+ instrumentHandler?.updateDisplay(
+ tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—",
+ twd = c.windDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—"
+ )
+
+ Step 4 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL.
+
+ Step 5 — Commit and push
+ ------------------------
+ git add android-app/app/src/main/res/layout/layout_instruments_sheet.xml \
+ android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt \
+ android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+ git commit -m "feat(instruments): display forecast wind direction (TWD) under TWS cell
+
+ windDirDeg was fetched from Open-Meteo and stored in MarineConditions
+ but never shown. Adds value_twd sub-label below the TWS primary value."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "instruments"
+ - "wind"
+ - "forecast"
diff --git a/scripts/.claude/ct-wind-wiring.txt b/scripts/.claude/ct-wind-wiring.txt
new file mode 100644
index 0000000..09768ab
--- /dev/null
+++ b/scripts/.claude/ct-wind-wiring.txt
@@ -0,0 +1,156 @@
+Context
+-------
+MainViewModel.addGpsPoint() (android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt,
+line 67) builds TrackPoint with hardcoded wind zeroes:
+
+ windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false
+
+LocationService already exposes live NMEA wind data:
+ LocationService.nmeaWindDataFlow: SharedFlow<WindData> (companion object, line 362)
+
+WindData (android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt):
+ windAngle: Double // degrees, relative or true
+ windSpeed: Double // knots
+ isTrueWind: Boolean
+ timestampMs: Long
+
+TrackPoint (android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt) already has
+windSpeedKnots, windAngleDeg, isTrueWind fields — they just aren't populated.
+
+Goal
+----
+Cache the latest WindData in MainViewModel and use it when building TrackPoints.
+Default to zero wind if no NMEA wind sentence has arrived yet.
+
+Step 1 — Red: write failing tests
+----------------------------------
+Create android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt
+
+Setup boilerplate needed (Dispatchers.setMain / resetMain with UnconfinedTestDispatcher):
+
+ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+ import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
+ import kotlinx.coroutines.test.UnconfinedTestDispatcher
+ import kotlinx.coroutines.test.resetMain
+ import kotlinx.coroutines.test.runTest
+ import kotlinx.coroutines.test.setMain
+ import org.junit.After
+ import org.junit.Assert.assertEquals
+ import org.junit.Before
+ import org.junit.Rule
+ import org.junit.Test
+ import org.terst.nav.sensors.WindData
+ import org.terst.nav.ui.MainViewModel
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ class MainViewModelWindTest {
+
+ @get:Rule val instantTask = InstantTaskExecutorRule()
+ private val dispatcher = UnconfinedTestDispatcher()
+
+ @Before fun setUp() { Dispatchers.setMain(dispatcher) }
+ @After fun tearDown() { Dispatchers.resetMain() }
+
+ @Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest {
+ val vm = MainViewModel()
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ val pt = vm.trackPoints.value.first()
+ assertEquals(0.0, pt.windSpeedKnots, 0.001)
+ assertEquals(0.0, pt.windAngleDeg, 0.001)
+ assertEquals(false, pt.isTrueWind)
+ }
+
+ @Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest {
+ val vm = MainViewModel()
+ vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L))
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ val pt = vm.trackPoints.value.first()
+ assertEquals(15.5, pt.windSpeedKnots, 0.001)
+ assertEquals(45.0, pt.windAngleDeg, 0.001)
+ assertEquals(true, pt.isTrueWind)
+ }
+
+ @Test fun `addGpsPoint reflects wind update between fixes`() = runTest {
+ val vm = MainViewModel()
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L))
+ vm.addGpsPoint(37.1, -122.0, 5.0, 90.0)
+ val pts = vm.trackPoints.value
+ assertEquals(0.0, pts[0].windSpeedKnots, 0.001)
+ assertEquals(8.0, pts[1].windSpeedKnots, 0.001)
+ }
+ }
+
+Run and confirm RED:
+ cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"
+
+Step 2 — Green: implement
+--------------------------
+In android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt:
+
+1. Add import:
+ import org.terst.nav.sensors.WindData
+
+2. Add field after trackRepository:
+ private var latestWind: WindData? = null
+
+3. Add method:
+ fun updateWind(wind: WindData) {
+ latestWind = wind
+ }
+
+4. Update addGpsPoint() to use latestWind:
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val wind = latestWind
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ windSpeedKnots = wind?.windSpeed ?: 0.0,
+ windAngleDeg = wind?.windAngle ?: 0.0,
+ isTrueWind = wind?.isTrueWind ?: false,
+ timestampMs = System.currentTimeMillis()
+ )
+ if (trackRepository.addPoint(point)) {
+ _trackPoints.value = trackRepository.getPoints()
+ }
+ }
+
+Run and confirm GREEN:
+ cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"
+
+Step 3 — Wire: feed wind updates from LocationService
+------------------------------------------------------
+In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
+inside observeDataSources(), add after the anchorWatchState collector:
+
+ lifecycleScope.launch {
+ LocationService.nmeaWindDataFlow.collect { wind ->
+ viewModel.updateWind(wind)
+ }
+ }
+
+No additional imports needed (LocationService is already imported).
+
+Step 4 — Verify all tests pass
+-------------------------------
+cd android-app && ./gradlew :app:testDebugUnitTest
+
+Confirm all existing tests still pass alongside the 3 new wind tests.
+
+Step 5 — Commit and push
+-------------------------
+git add -A && git commit -m "feat(track): wire NMEA wind data into GPS track points
+
+MainViewModel caches the latest WindData from LocationService.nmeaWindDataFlow
+via updateWind(). addGpsPoint() populates TrackPoint wind fields from the cache,
+defaulting to zero if no NMEA wind sentence has arrived yet.
+
+MainActivity.observeDataSources() feeds LocationService.nmeaWindDataFlow
+into viewModel.updateWind() alongside the existing GPS and anchor observers.
+
+3 new unit tests in MainViewModelWindTest verify zero-default, wind capture,
+and mid-track wind update behaviour." && git push origin main
diff --git a/scripts/.claude/ct-wire-anchor-to-map.yaml b/scripts/.claude/ct-wire-anchor-to-map.yaml
new file mode 100644
index 0000000..9ef8513
--- /dev/null
+++ b/scripts/.claude/ct-wire-anchor-to-map.yaml
@@ -0,0 +1,82 @@
+name: "Wire anchor watch state to map overlay"
+description: "LocationService.anchorWatchState is collected in MainActivity but only updates SafetyFragment text — MapHandler.updateAnchorWatch() is never called, so the anchor point and radius circle never appear on the map"
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ LocationService.anchorWatchState: StateFlow<AnchorWatchState>
+ (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, companion, line ~353)
+
+ MapHandler.updateAnchorWatch(state: AnchorWatchState) is fully implemented — it shows
+ an anchor icon and radius circle on the map, or clears them when inactive:
+ (android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt, line ~139)
+
+ In MainActivity.observeDataSources(), anchorWatchState IS collected, but only
+ to update a text field in SafetyFragment:
+ LocationService.anchorWatchState.collect { state ->
+ safetyFragment.updateAnchorStatus(...)
+ }
+ There is no call to mapHandler?.updateAnchorWatch(state) anywhere.
+
+ AnchorWatchState:
+ (android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt)
+ data class AnchorWatchState(
+ val isActive: Boolean,
+ val anchorLocation: Location?,
+ val watchCircleRadiusMeters: Double,
+ val setTimeMillis: Long
+ )
+
+ The map style must be loaded before calling updateAnchorWatch — gate on loadedStyleFlow.
+
+ Goal
+ ----
+ In the existing anchorWatchState collector in observeDataSources(), add a call to
+ mapHandler?.updateAnchorWatch(state) alongside the existing safetyFragment update.
+ Gate the map call on the style being loaded.
+
+ Step 1 — Update the existing collector
+ ---------------------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
+ find the anchorWatchState collector in observeDataSources():
+
+ lifecycleScope.launch {
+ LocationService.anchorWatchState.collect { state ->
+ safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
+ }
+ }
+
+ Replace with:
+
+ lifecycleScope.launch {
+ LocationService.anchorWatchState.collect { state ->
+ safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
+ if (loadedStyleFlow.value != null) {
+ mapHandler?.updateAnchorWatch(state)
+ }
+ }
+ }
+
+ No import changes needed.
+
+ Step 2 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL.
+
+ Step 3 — Commit and push
+ ------------------------
+ git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+ git commit -m "fix(map): wire anchor watch state to map overlay
+
+ The anchorWatchState collector in observeDataSources() was only updating
+ SafetyFragment text. Now also calls mapHandler.updateAnchorWatch(state)
+ when the map style is loaded, showing the anchor icon and radius circle."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "map"
+ - "anchor"
+ - "wiring"
diff --git a/scripts/.claude/ct-wire-tidal-to-map.yaml b/scripts/.claude/ct-wire-tidal-to-map.yaml
new file mode 100644
index 0000000..b5a1923
--- /dev/null
+++ b/scripts/.claude/ct-wire-tidal-to-map.yaml
@@ -0,0 +1,75 @@
+name: "Wire tidal current state to map overlay"
+description: "LocationService.tidalCurrentState is emitted but never collected in MainActivity — MapHandler.updateTidalCurrents() is never called, so the tidal arrow layer is always empty"
+agent:
+ model: "sonnet"
+ working_dir: "/workspace/nav"
+ instructions: |
+ Context
+ -------
+ LocationService exposes a public StateFlow:
+ LocationService.tidalCurrentState: StateFlow<TidalCurrentState>
+ (android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt, companion object, line ~354)
+
+ MapHandler has a fully-implemented method:
+ fun updateTidalCurrents(state: TidalCurrentState)
+ (android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt, line ~155)
+
+ MainActivity.observeDataSources()
+ (android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt)
+ never subscribes to tidalCurrentState, so updateTidalCurrents() is never called.
+ The tidal arrow overlay on the map is always empty regardless of what data arrives.
+
+ The map style must be loaded before calling updateTidalCurrents — use loadedStyleFlow
+ as a gate, the same way updateTrackLayer is gated (see the combine() pattern already
+ in observeDataSources for track points).
+
+ Goal
+ ----
+ Subscribe to LocationService.tidalCurrentState in MainActivity.observeDataSources()
+ and forward each emission to mapHandler?.updateTidalCurrents(state).
+
+ The subscription should be gated on loadedStyleFlow being non-null (style loaded),
+ matching the pattern used for track points.
+
+ Step 1 — Add the observer
+ -------------------------
+ In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
+ inside observeDataSources(), add after the existing collectors:
+
+ lifecycleScope.launch {
+ loadedStyleFlow.filterNotNull().collect {
+ LocationService.tidalCurrentState.collect { state ->
+ mapHandler?.updateTidalCurrents(state)
+ }
+ }
+ }
+
+ Or equivalently using combine(), consistent with the track pattern:
+
+ lifecycleScope.launch {
+ loadedStyleFlow.filterNotNull()
+ .combine(LocationService.tidalCurrentState) { _, state -> state }
+ .collect { state -> mapHandler?.updateTidalCurrents(state) }
+ }
+
+ No import changes needed — LocationService is already imported.
+
+ Step 2 — Build
+ --------------
+ cd android-app && ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -5
+ Confirm BUILD SUCCESSFUL.
+
+ Step 3 — Commit and push
+ ------------------------
+ git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+ git commit -m "fix(map): wire tidal current state to map overlay in MainActivity
+
+ LocationService.tidalCurrentState was emitted but never collected.
+ Subscribe in observeDataSources() gated on style load, forwarding
+ each TidalCurrentState to mapHandler.updateTidalCurrents()."
+ git push github main && git push local main
+timeout: "15m"
+tags:
+ - "map"
+ - "tidal"
+ - "wiring"
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index 7953822..85dd2dd 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -5,22 +5,28 @@ class TrackRepository {
var isRecording: Boolean = false
private set
- private val points = mutableListOf<TrackPoint>()
+ private val activePoints = mutableListOf<TrackPoint>()
+ private val pastTracks = mutableListOf<List<TrackPoint>>()
fun startTrack() {
- points.clear()
+ activePoints.clear()
isRecording = true
}
fun stopTrack() {
+ if (isRecording && activePoints.isNotEmpty()) {
+ pastTracks.add(activePoints.toList())
+ }
isRecording = false
}
fun addPoint(point: TrackPoint): Boolean {
if (!isRecording) return false
- points.add(point)
+ activePoints.add(point)
return true
}
- fun getPoints(): List<TrackPoint> = points.toList()
+ fun getPoints(): List<TrackPoint> = activePoints.toList()
+
+ fun getPastTracks(): List<List<TrackPoint>> = pastTracks.toList()
}