diff options
| author | Claude <noreply@anthropic.com> | 2026-04-23 23:10:11 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-04-23 23:10:11 +0000 |
| commit | e1db9200e9d44c10450361cc8984a45b2eda87b7 (patch) | |
| tree | 21bdfca02491e6ff4cde7a22673c821c0a03e108 | |
| parent | 55509f579b4d074f01237dd90791b6d25aaec3cd (diff) | |
| parent | eb78d317c722234a7ef2c501c68c9aa730ec2758 (diff) | |
Merge origin/main — unite all work onto one branch
Brings in dev log (NavLogger), UnitPrefs, MapLayerManager, HUD views,
conditions throttling, track save/load pipeline, improved ParticleWindView
(antimeridian-aware, dynamic particle count), Snackbar error surfacing,
and all other main-branch work from the prior session.
Combined with master's hardware source flags, vessel registry, crew
management, thermal alarm, CPA collision alerts, and track stats.
Also documents primary branch policy in CLAUDE.md and .agent/config.md:
all work merges to main, never master.
https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW
111 files changed, 10295 insertions, 1019 deletions
diff --git a/.agent/config.md b/.agent/config.md index 6094452..b3a28dc 100644 --- a/.agent/config.md +++ b/.agent/config.md @@ -14,7 +14,18 @@ This is the primary source of truth for all AI agents working on **Nav**. These | `narrative.md` | **Background** — Historical context and evolution of the project. | | `preferences.md` | **User Prefs** — Living record of user-specific likes/dislikes. | -## 2. Core Mandates +## 2. Branch Policy — READ THIS FIRST + +**Primary branch: `main`** + +- ALL work merges to `main`. Never to `master` or any other branch. +- `master` does not exist as a development target. If it exists locally, ignore it. +- Feature branches are cut from `main` and merge back to `main`. +- When a session harness specifies a generated branch name (e.g. `claude/foo-XYZ`), develop there and merge to `main` when done — not to wherever the local HEAD points. +- The CI/CD workflow targets `main`. Firebase distribution fires on `main` pushes only. +- If you are ever unsure which branch to merge to: it is `main`. + +## 3. Core Mandates ### ULTRA-STRICT ROOT SAFETY PROTOCOL 1. **Inquiry-Only Default:** Treat every message as research/analysis unless it is an explicit, imperative command (Directive). diff --git a/.agent/preferences.md b/.agent/preferences.md index 7ca21d6..6d1c4bb 100644 --- a/.agent/preferences.md +++ b/.agent/preferences.md @@ -5,3 +5,10 @@ Living record of user preferences for the Nav project. ## 1. Interaction & Workflow - **Safety First:** Cautious and deliberate action. - **Checkpoint Model:** Research -> Strategy -> GO. + +## 2. Repository Conventions — NON-NEGOTIABLE +- **Default branch is `main`.** Always has been, always will be. +- When the user says "main", "master", or "the default branch" they all mean `main`. +- **CI/CD (`.github/workflows/android.yml`) triggers on `main`.** Firebase App Distribution only fires on pushes to `main`. Never change the trigger branch without explicit instruction. +- Before any merge or push, verify: `git branch --show-current` is `main` and `git remote show origin | grep "HEAD branch"` shows `main`. +- Never create or develop on `master`. If `master` ever appears, rename it to `main` immediately. diff --git a/.agent/worklog.md b/.agent/worklog.md index 4818a94..55fd5a7 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` @@ -160,14 +124,13 @@ Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel - `MainActivity.kt` — `startThermalMonitoring()` called from `observeDataSources()`; HOT triggers Toast + alarm sound (reuses `R.raw.mob_alarm`); CRITICAL triggers urgent Toast + alarm; OK/WARM stops alarm; alarm released in `onDestroy()` ## 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 dc154f0..e99bf7d 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, claude/** ] pull_request: - branches: [ master ] + branches: [ main ] jobs: build: @@ -28,7 +28,7 @@ jobs: working-directory: android-app - name: upload artifact to Firebase App Distribution - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/main' && github.event_name == 'push' uses: wzieba/Firebase-Distribution-Github-Action@v1 with: appId: ${{secrets.FIREBASE_APP_ID}} @@ -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 @@ -2,6 +2,8 @@ This repository uses a centralized agent configuration. -**Primary Source of Truth:** ".agent/config.md" +**Primary Source of Truth:** `.agent/config.md` -Refer to ".agent/config.md" before performing any tasks. +**Primary branch: `main`** — all work merges here. Never merge to `master`. + +Refer to `.agent/config.md` before performing any tasks. diff --git a/android-app/.gitignore b/android-app/.gitignore index 7db1cd2..acd3de6 100644 --- a/android-app/.gitignore +++ b/android-app/.gitignore @@ -11,8 +11,8 @@ build/ *.iws local.properties -# Generated files -app/src/main/assets/ +# Generated assets (add specific patterns here if needed) +# app/src/main/assets/ — removed blanket ignore; docs are tracked # Keystore *.jks 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..7cbafc7 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-feature android:name="android.hardware.camera" android:required="false" /> + <!-- 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" @@ -22,7 +26,9 @@ android:foregroundServiceType="location" /> <activity android:name=".MainActivity" - android:exported="true"> + android:exported="true" + android:screenOrientation="portrait" + android:configChanges="orientation|screenSize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> diff --git a/android-app/app/src/main/assets/docs/colregs_reference.md b/android-app/app/src/main/assets/docs/colregs_reference.md new file mode 100644 index 0000000..6e5c39a --- /dev/null +++ b/android-app/app/src/main/assets/docs/colregs_reference.md @@ -0,0 +1,230 @@ +# ColRegs — Rules of the Road + +International Regulations for Preventing Collisions at Sea (COLREGS), 1972 with amendments. This is a summary reference; consult the official text for legal purposes. + +--- + +## Part A — General (Rules 1–3) + +**Rule 1 — Application** +Applies to all vessels on the high seas and connected navigable waters. + +**Rule 2 — Responsibility** +Nothing in these rules exonerates a vessel, owner, master, or crew from the consequences of neglect. Good seamanship always applies. + +**Rule 3 — Definitions** +- *Vessel* — any watercraft, including seaplanes and WIG craft +- *Power-driven vessel* — any vessel propelled by machinery +- *Sailing vessel* — under sail only; if engine is running, she is power-driven +- *Vessel engaged in fishing* — using nets, lines, trawls that restrict maneuverability +- *Underway* — not at anchor, aground, or made fast to shore +- *Restricted visibility* — fog, mist, falling snow, heavy rain, sandstorm, or similar conditions + +--- + +## Part B — Steering and Sailing Rules + +### Section I — Conduct in Any Visibility (Rules 4–10) + +**Rule 4** — Applies in any condition of visibility. + +**Rule 5 — Look-out** +Every vessel shall maintain a proper look-out at all times by sight, hearing, and all available means. + +**Rule 6 — Safe Speed** +Every vessel shall proceed at a safe speed. Factors: visibility, traffic density, vessel maneuverability, background lights at night, radar state, sea state. + +**Rule 7 — Risk of Collision** +Risk exists if the compass bearing of an approaching vessel does not appreciably change. When in doubt, assume risk exists. + +**Rule 8 — Action to Avoid Collision** +- Action must be positive, made in ample time, and large enough to be readily apparent +- Course or speed changes should be large enough to be noticed +- If necessary, stop or reverse + +**Rule 9 — Narrow Channels** +- Keep to the starboard side of a narrow channel +- Vessels under 20 m or sailing vessels shall not impede vessels that can safely navigate only in the channel +- Overtaking only when safe and the overtaken vessel signals agreement +- Do not cross a narrow channel if it impedes a through-traffic vessel + +**Rule 10 — Traffic Separation Schemes** +- Join/leave at end; if joining from side, at acute angle +- Keep out of separation zones +- Crossing traffic does so at right angles where practicable +- Inshore traffic zones: use only if < 20 m, or sailing, or fishing + +--- + +### Section II — Conduct in Sight of One Another (Rules 11–18) + +**Rule 11** — Applies to vessels in sight of one another. + +**Rule 12 — Sailing Vessels** +- Vessel on port tack gives way to vessel on starboard tack +- Both on same tack: windward vessel gives way to leeward vessel +- Port tack vessel cannot determine which tack the other is on: gives way + +**Rule 13 — Overtaking** +Any vessel overtaking gives way. Overtaking means coming up from more than 22.5° abaft the other's beam. Overtaking status persists until clear and past. + +**Rule 14 — Head-on Situation** +Both vessels altering course to starboard so each passes on the port side of the other. Applies when risk of collision exists and vessels are nearly end-on. + +**Rule 15 — Crossing Situation** +The vessel that has the other on its own starboard side gives way (the "burdened" or give-way vessel). The stand-on vessel is on the right. + +**Rule 16 — Action by Give-way Vessel** +Take early and substantial action to keep well clear. + +**Rule 17 — Action by Stand-on Vessel** +- May take action to avoid collision by own maneuver alone when it becomes apparent the give-way vessel is not taking sufficient action +- Must take action when collision cannot be avoided by give-way vessel alone +- Course change to port for a vessel on your port side is avoided if possible + +**Rule 18 — Responsibilities Between Vessels** + +Hierarchy (higher number gives way to all above): +1. Vessel not under command (NUC) +2. Vessel restricted in ability to maneuver (RAM) +3. Vessel constrained by draft +4. Vessel engaged in fishing +5. Sailing vessel +6. Power-driven vessel underway + +*Note:* Sailing and power vessels give way to NUC, RAM, constrained, and fishing vessels. A power vessel gives way to a sailing vessel. + +--- + +### Section III — Conduct in Restricted Visibility (Rule 19) + +**Rule 19 — Restricted Visibility** +- Proceed at safe speed adapted to conditions +- Have engines ready for immediate maneuver +- On hearing fog signal apparently forward of beam: reduce to bare steerage or stop +- Avoid alteration of course to port for a vessel forward of beam (except overtaking) +- Avoid alteration toward a vessel abeam or abaft beam + +--- + +## Part C — Lights and Shapes (Rules 20–31) + +### Lights (Rules 20–22) + +**Rule 20 — Application** +Lights required from sunset to sunrise and in restricted visibility. + +**Rule 21 — Definitions** +- *Masthead light* — white forward light, 225° arc +- *Side lights* — red (port) and green (starboard), 112.5° each +- *Stern light* — white aft, 135° arc +- *Towing light* — yellow, same arc as stern light +- *All-round light* — 360° arc +- *Flashing light* — 120+ flashes/minute + +**Rule 22 — Visibility of Lights** + +| Vessel size | Masthead | Side | Stern | All-round | +|---|---|---|---|---| +| ≥ 50 m | 6 nm | 3 nm | 3 nm | 3 nm | +| 12–50 m | 5 nm | 2 nm | 2 nm | 2 nm | +| 7–12 m | 3 nm | 1 nm | 2 nm | 2 nm | +| < 7 m | — | — | — | 2 nm | + +--- + +### Light Combinations to Know + +**Under power (≥ 50 m):** Two masthead lights (forward lower, aft higher) + sidelights + stern light + +**Under power (< 50 m):** One masthead light + sidelights + stern light + +**Under sail (underway):** Sidelights + stern light only. *No masthead light when under sail.* + +**Sail + engine:** Power-driven vessel rules apply — show cone (point down) by day. + +**At anchor (< 50 m):** One white all-round light forward. +**At anchor (≥ 50 m):** White all-round forward + aft. + +**Not under command:** Two red all-round lights (vertical). If making way: add sidelights + stern light. + +**Restricted in ability to maneuver:** Red-white-red all-round lights (vertical). If making way: add masthead + sidelights + stern. + +**Vessel aground:** Anchor lights + two red all-round lights (vertical). + +**Towing vessel:** Extra masthead light(s) + yellow towing light instead of (or in addition to) stern light. + +**Fishing (trawling):** Green over white all-round (vertical) + sidelights + stern if making way. +**Fishing (other):** Red over white all-round (vertical) + sidelights + stern if making way + white toward gear if gear > 150 m. + +**Pilot vessel on duty:** White over red all-round lights. + +--- + +### Day Shapes (Rule 28) + +| Shape | Vessel Type | +|---|---| +| Black ball | At anchor | +| Black cone (apex down) | Sailing vessel with engine | +| Two black balls (vertical) | Not under command | +| Ball-diamond-ball (vertical) | Restricted in ability to maneuver | +| Black cylinder | Constrained by draft | +| Basket | Engaged in fishing | +| Cone (apex up) | Vessel being towed (if requested) | + +--- + +## Part D — Sound and Light Signals (Rules 32–37) + +**Rule 32 — Definitions** +- *Short blast* — about 1 second +- *Prolonged blast* — 4–6 seconds + +**Rule 33 — Equipment** +- ≥ 12 m: whistle + bell +- ≥ 100 m: also gong + +**Rule 34 — Maneuvering and Warning Signals** + +| Signal | Meaning | +|---|---| +| 1 short | I am altering course to starboard | +| 2 shorts | I am altering course to port | +| 3 shorts | I am operating astern propulsion | +| 5+ shorts (rapid) | Danger / doubt signal | +| 1 prolonged | Vessel leaving berth | + +**Rule 35 — Sound Signals in Restricted Visibility** + +| Signal | Vessel | +|---|---| +| 1 prolonged (≤ 2 min) | Power-driven vessel making way | +| 2 prolonged (≤ 2 min) | Power-driven vessel underway but stopped | +| 1 long + 2 short (≤ 2 min) | NUC, RAM, sailing, fishing, towing | +| 1 long + 3 short | Vessel being towed (last vessel) | +| Rapid bell (5 sec, ≤ 1 min) | At anchor (< 100 m) | +| Bell + gong (≤ 1 min) | At anchor (≥ 100 m) | +| 3 strokes + rapid bell + 3 strokes | Vessel aground | + +**Rule 36 — Attention Signal** +Five or more short and rapid blasts. Also a light signal of the same pattern. + +**Rule 37 — Distress Signals** +Gun fired at ~1 min intervals; continuous foghorn; SOS (···−−−···); MAYDAY by voice; orange smoke; flames; parachute flare; dye; square flag + ball; high-intensity white light flashing; radio alarm signal. + +--- + +## Part E — Exemptions (Rule 38) + +Older vessels may be exempt from some lighting requirements for a period of years after the rules came into force. + +--- + +## Quick Memory Aids + +**Starboard right-of-way:** When another vessel is on your starboard side in a crossing situation, YOU give way. + +**Lights mnemonic — red over green, sailing machine:** A sailing vessel shows red (port side) and green (starboard) sidelights plus a white stern light. No masthead light while under sail alone. + +**The hierarchy:** NUC → RAM → Constrained → Fishing → Sail → Power diff --git a/android-app/app/src/main/assets/docs/migrate_navionics.md b/android-app/app/src/main/assets/docs/migrate_navionics.md new file mode 100644 index 0000000..83b28fa --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_navionics.md @@ -0,0 +1,56 @@ +# Migrating from Navionics + +Welcome to Nav. This guide covers the key differences and how to move your data over. + +--- + +## What's Different + +| Feature | Navionics | Nav | +|---|---|---| +| Charts | Navionics SonarChart + Raster | OpenFreeMap vector tiles | +| Track recording | In-app GPX | GPX saved to Documents/Nav/ | +| Trip log | Voyage log | Voice log (transcribed) | +| Departure planning | None | Pre-trip briefing with sail plan | +| Wind overlay | Basic | Wind particles + hourly forecast | + +--- + +## Exporting Your Tracks from Navionics + +1. Open Navionics on your phone or tablet +2. Go to **My Charts → Tracks** +3. Tap a track → **Share → Export GPX** +4. Save or AirDrop the `.gpx` file to your device + +--- + +## Importing Tracks into Nav + +Nav automatically reads GPX files from the **Documents/Nav/** folder on your device. + +1. Move your exported `.gpx` files to `Documents/Nav/` using the Files app +2. Re-open Nav — tracks are loaded on startup +3. Similar-conditions trip comparison in the Pre-Trip Briefing will use these tracks + +--- + +## Waypoints + +Nav does not currently manage waypoints. Use Navionics or a dedicated chart plotter for waypoint routing. Nav focuses on departure briefing, track recording, and the sail log. + +--- + +## Charts + +Nav uses **OpenFreeMap** vector tiles. These are free, fast, and work well for coastal cruising. They do not include depth contours or hazard overlays — continue using Navionics or a NOAA chart app for navigation in unfamiliar waters. + +--- + +## Daily Workflow + +**Before you sail:** Open the **Safety → Plan Trip** screen for your departure briefing — sail plan, heading recommendation, watch items, and a look at how conditions will change over your trip window. + +**On the water:** Tap **Log** to dictate voice notes. The GPS track records automatically when you tap the record button. + +**After you return:** The post-trip report summarises distance, duration, and max SOG. diff --git a/android-app/app/src/main/assets/docs/migrate_sea_people.md b/android-app/app/src/main/assets/docs/migrate_sea_people.md new file mode 100644 index 0000000..b1920b4 --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_sea_people.md @@ -0,0 +1,59 @@ +# Migrating from Sea People + +Welcome to Nav. This guide covers what transfers over and what works differently. + +--- + +## What's Different + +| Feature | Sea People | Nav | +|---|---|---| +| Social feed | Community posts | Not applicable — Nav is single-user | +| Trip log | Text entries + photos | Voice log (transcribed audio) | +| Boat profiles | Basic boat card | Detailed sail inventory with reef points | +| Weather | Integrated forecast | Open-Meteo hourly + NOAA marine | +| Departure planning | Manual notes | Pre-trip briefing with sail plan & route | + +--- + +## Exporting Your Log from Sea People + +1. Open Sea People → **Profile → My Logs** +2. Use the **Export** option (CSV or PDF depending on your version) +3. Save entries you want to keep for reference + +Nav's voice log works differently — entries are dictated on the water and transcribed. You won't be able to import Sea People entries directly, but you can review them alongside Nav going forward. + +--- + +## Boat Profiles + +Nav stores a detailed boat profile with your sail inventory: + +- **Headsails** with wind range for each (e.g. 155% Genoa ≤ 13 kt, 100% Jib 10–21 kt, 65% Blade 18+ kt) +- **Main reef points** +- **Hull length** (used for hull-speed estimates in the departure briefing) + +Your boats are pre-seeded on first launch. Profiles are stored locally and used in the **Pre-Trip Briefing** to give sail recommendations specific to your boat. + +--- + +## Trip Log Workflow + +**On the water:** Tap the **Log** tab and use the microphone button to dictate a note. Notes are timestamped with your GPS position. + +**Voice notes work well for:** +- Sea state observations ("1.5m swell, 10s period, comfortable") +- Traffic ("fishing vessel on starboard, gave way") +- Sail changes ("reefed main at 18 kt") +- Time stamps ("rounded the point, heading west") + +--- + +## Daily Workflow + +**Before you sail:** Safety → Plan Trip for your departure briefing. + +**On the water:** Log tab for voice notes; tap the record FAB to start the GPS track. + +**After you return:** The post-trip report generates a narrative summary you can save or share. diff --git a/android-app/app/src/main/assets/docs/sailing_reference.md b/android-app/app/src/main/assets/docs/sailing_reference.md new file mode 100644 index 0000000..7fc7bdb --- /dev/null +++ b/android-app/app/src/main/assets/docs/sailing_reference.md @@ -0,0 +1,206 @@ +# Sailing Quick Reference + +--- + +## Points of Sail + +The point of sail describes the angle between the boat's heading and the true wind direction. + +| Point of Sail | True Wind Angle | Description | +|---|---|---| +| In irons | 0–30° | Head-to-wind, sails luffing, no drive | +| Close hauled | ~30–45° | Sailing as close to the wind as possible | +| Close reach | ~45–60° | Between close hauled and beam reach | +| Beam reach | ~90° | Wind directly abeam — often fastest point | +| Broad reach | ~120–150° | Wind on the quarter — comfortable, fast | +| Run | ~150–180° | Wind from directly behind | + +**No-go zone:** ~0–30° on either side of the wind — the boat cannot make progress sailing directly into the wind. + +--- + +## Tacking vs. Gybing + +**Tacking** — turning the bow through the wind (bow crosses the wind). The boom swings across from one side to the other. Used to head upwind. + +**Gybing** — turning the stern through the wind (stern crosses the wind). The boom can swing violently — always control the mainsheet. Used to change direction downwind. + +--- + +## Sail Trim Basics + +**Telltales** — strips of yarn or fabric on the sail. +- Both telltales streaming aft → sail trimmed correctly +- Windward telltale lifting → sheet in (trim), or bear away +- Leeward telltale lifting → sheet out (ease), or head up + +**In irons fix:** Let sails luff, push boom to one side, fall off onto a tack. + +**Reef** — reducing sail area by partially lowering the mainsail and tying off the excess. Reef before you think you need to. Typical thresholds: first reef ~15–18 kt, second reef ~21–25 kt. + +--- + +## Hull Speed + +The theoretical maximum displacement hull speed: + +**Hull speed (kt) ≈ 1.34 × √(waterline length in feet)** + +| LOA | Hull Speed | +|---|---| +| 20 ft | ~6.0 kt | +| 23 ft | ~6.4 kt | +| 30 ft | ~7.3 kt | +| 40 ft | ~8.5 kt | + +A modern fin-keel boat can exceed hull speed in planing conditions (surfing downwind in big waves). + +--- + +## Navigation Lights — Quick Reference + +| Situation | What You See | What It Is | +|---|---|---| +| Red + green + white | Two side lights + stern | Head-on approach | +| Red only | Port sidelight | Vessel crossing left-to-right in front of you | +| Green only | Starboard sidelight | Vessel crossing right-to-left — you are give-way | +| White only (masthead) + green | Overtaking from starboard | Vessel overtaking you on starboard | +| Two white (stacked) + red/green | Two masthead lights | Large ship (≥50 m) underway under power | +| Red + white (all-round, vertical) | Not under command | Give way — vessel cannot maneuver | +| Green + white (all-round, vertical) | Trawler | Give way — engaged in fishing | +| White all-round only | At anchor | Avoid — vessel at anchor | +| White + red all-round (vertical) | Pilot vessel | Pilot boat on duty | + +--- + +## Day Shapes + +| Shape | Meaning | +|---|---| +| ⚫ Black ball | Vessel at anchor | +| 🔻 Black cone (apex down) | Sailing vessel motorsailing | +| ⚫ ⚫ Two balls (vertical) | Not under command | +| ⚫ ◆ ⚫ Ball-diamond-ball | Restricted in ability to maneuver | +| ▬ Black cylinder | Constrained by draft | + +--- + +## Beaufort Wind Scale + +| Force | kt | Description | Sea State | +|---|---|---|---| +| 0 | < 1 | Calm | Mirror smooth | +| 1 | 1–3 | Light air | Ripples | +| 2 | 4–6 | Light breeze | Small wavelets | +| 3 | 7–10 | Gentle breeze | Scattered whitecaps | +| 4 | 11–16 | Moderate breeze | Moderate waves, frequent whitecaps | +| 5 | 17–21 | Fresh breeze | Long waves, many whitecaps, spray | +| 6 | 22–27 | Strong breeze | Large waves, spray, whitecaps everywhere | +| 7 | 28–33 | Near gale | Sea heaping up, foam streaks | +| 8 | 34–40 | Gale | Moderately high waves, edges blowing | +| 9 | 41–47 | Strong gale | High waves, dense foam, visibility affected | +| 10 | 48–55 | Storm | Very high waves, sea white, heavy sea roll | +| 11 | 56–63 | Violent storm | Exceptionally high waves | +| 12 | 64+ | Hurricane force | Air filled with foam, visibility nil | + +--- + +## Common Knots + +**Bowline** — fixed loop that won't slip. The classic sailing knot. "The rabbit comes out of the hole, round the tree, and back down the hole." + +**Cleat hitch** — securing a line to a cleat. Take a round turn around the base, then two figure-8 turns, then one locking hitch over the horn. + +**Clove hitch** — temporary attachment to a post or rail. Two half hitches; easy to adjust and release. + +**Figure-eight** — stopper knot. Prevents a line from running through a block or fairlead. + +**Round turn and two half hitches** — secure, adjustable attachment to a ring or rail. + +**Reef knot** — joining two lines of similar diameter. Right over left, left over right. Not for critical loads — use a sheet bend for mismatched diameters. + +**Sheet bend** — joining two lines of different diameter. The thicker line forms the loop. + +**Rolling hitch** — attaching to another line or spar under load. Grips when pulled along the spar. + +**Anchor hitch (fisherman's bend)** — the correct knot for attaching a line to an anchor. + +--- + +## Buoyage — IALA System B (Americas, Japan, Philippines, Korea) + +**Red right returning** — red buoys on the starboard side when returning from sea. + +| Mark | Shape | Color | Top Mark | Meaning | +|---|---|---|---|---| +| Port lateral | Can / pillar | Red | None | Keep to starboard (IALA-B: keep red to starboard) | +| Starboard lateral | Nun / cone | Green | Cone | Keep to port | +| Safe water | Sphere | Red + white vertical stripes | Sphere | Safe water on all sides | +| Isolated danger | Pillar / spar | Black + red bands | Two black balls | Isolated danger, safe water around it | +| Special mark | Any | Yellow | Yellow X | Special purpose (mooring, racing, TSS) | +| Cardinal (N) | Pillar / spar | Black over yellow | Two cones pointing up | Pass to the north | +| Cardinal (S) | Pillar / spar | Yellow over black | Two cones pointing down | Pass to the south | +| Cardinal (E) | Pillar / spar | Black-yellow-black bands | Cones base-to-base | Pass to the east | +| Cardinal (W) | Pillar / spar | Yellow-black-yellow bands | Cones point-to-point | Pass to the west | + +*IALA-A (Europe, Africa, most of Asia):* Red/green assignments are reversed — "red left returning." + +--- + +## VHF Radio Channels + +| Channel | Use | +|---|---| +| 16 | **International distress, safety, and calling** — always monitor | +| 22A | US Coast Guard working channel | +| 9 | Boater calling channel (US) | +| 6 | Ship-to-ship safety communications | +| 13 | Bridge-to-bridge (1 watt) | +| 70 | DSC digital selective calling — do not use for voice | +| 24, 25, 26, 27, 28 | Public correspondence (marine operator) | + +**MAYDAY procedure:** +1. MAYDAY MAYDAY MAYDAY +2. This is [vessel name × 3] +3. MAYDAY [vessel name] +4. Position +5. Nature of distress +6. Number of persons aboard +7. Any other information +8. Over + +--- + +## Tide and Current Basics + +**Flood** — tide coming in (rising sea level). +**Ebb** — tide going out (falling sea level). +**Slack** — the period of minimal current around high and low water. + +Rule of twelfths — tide rises/falls unevenly: +- Hour 1: 1/12 of range +- Hour 2: 2/12 of range +- Hour 3: 3/12 of range ← fastest +- Hour 4: 3/12 of range ← fastest +- Hour 5: 2/12 of range +- Hour 6: 1/12 of range + +**Spring tides** — larger range; occur near new and full moon. +**Neap tides** — smaller range; occur near quarter moons. + +--- + +## Distress Signals (Rule 37 / SOLAS) + +Any of these signals indicate distress and request assistance: +- Red parachute flare or red hand flare +- Orange smoke signal +- MAYDAY spoken over radio (Ch 16) +- SOS (···−−−···) by any signaling method +- Continuous foghorn sound +- Gun fired at approximately 1-minute intervals +- Flames on the vessel +- Slowly and repeatedly raising and lowering both arms +- Square flag with ball above or below it +- Orange dye in water +- Satellite EPIRB signal 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 cc506a5..18e0907 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,19 @@ 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.BoatSpeedData 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 +41,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 +68,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 +100,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 +129,29 @@ class LocationService : Service() { } } - // Mock tidal current data generator + // Collect NMEA Boat Speed Data serviceScope.launch { - while (true) { - val currents = MockTidalCurrentGenerator.generateMockCurrents() - _tidalCurrentState.update { it.copy(currents = currents) } - kotlinx.coroutines.delay(60000) // Update every minute + nmeaStreamManager.nmeaBoatSpeedData.collectLatest { bspData -> + _nmeaBoatSpeedDataFlow.emit(bspData) } } 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 +161,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,10 +234,9 @@ 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() @@ -186,14 +246,12 @@ class LocationService : Service() { } } 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) @@ -201,12 +259,10 @@ 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) } @@ -226,28 +282,23 @@ class LocationService : Service() { 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, @@ -257,22 +308,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.") } } } @@ -289,25 +333,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() @@ -318,29 +352,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 { @@ -352,55 +384,49 @@ class LocationService : Service() { const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY" const val ACTION_START_NMEA = "ACTION_START_NMEA" const val ACTION_STOP_NMEA = "ACTION_STOP_NMEA" - const val EXTRA_WATCH_RADIUS = "extra_watch_radius" const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" + const val EXTRA_WATCH_RADIUS = "extra_watch_radius" + + 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() - // 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 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 _nmeaBoatSpeedDataFlow = MutableSharedFlow<BoatSpeedData>(replay = 1) + val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> get() = _nmeaBoatSpeedDataFlow private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) - val currentPowerMode: StateFlow<PowerMode> - get() = _currentPowerMode + val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode + + private val _tidalCurrentState = MutableStateFlow(TidalCurrentState()) + val tidalCurrentState: StateFlow<TidalCurrentState> get() = _tidalCurrentState } } - 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 b1f1919..eaab575 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,7 +8,7 @@ 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.TextView @@ -16,20 +16,19 @@ 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 @@ -46,6 +45,8 @@ import org.terst.nav.ui.safety.SafetyFragment import org.terst.nav.ui.vessel.VesselRegistryFragment import org.terst.nav.ui.voicelog.VoiceLogFragment import java.util.* +import org.terst.nav.data.model.MarineConditions +import org.terst.nav.safety.AnchorWatchState class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { @@ -55,15 +56,48 @@ 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 fabRecenter: MaterialButton + private lateinit var bottomSheet: CardView + private lateinit var bottomNav: BottomNavigationView + private lateinit var mapCrosshair: View + + // HUD value TextViews + private lateinit var hudSog: TextView + private lateinit var hudCog: TextView + private lateinit var hudBsp: TextView + private lateinit var hudDepth: TextView + + // HUD unit label TextViews + private lateinit var unitHudSog: TextView + private lateinit var unitHudBsp: TextView + private lateinit var unitHudDepth: TextView + + // Instrument sheet unit label TextViews + private lateinit var unitTws: TextView + private lateinit var unitTemp: TextView + private lateinit var unitBaro: TextView + private lateinit var unitCurrSpd: TextView + private lateinit var unitWaveHt: TextView + private lateinit var unitSwellHt: TextView + + // Unit preferences + private lateinit var unitPrefs: UnitPrefs + + // Cached raw values for unit-change refresh + private var lastConditions: MarineConditions? = null + private var lastBaroHpa: Float? = null + private var lastSogKnots: Double? = null + private var lastBspKnots: Double? = null + private var lastDepthMeters: Double? = null + private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -71,7 +105,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { override fun onResume() { super.onResume() - mapView?.onResume() + if (!NavApplication.isTesting) mapView?.onResume() if (pendingServiceStart) { pendingServiceStart = false startServices() @@ -84,27 +118,73 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setContentView(R.layout.activity_main) viewModel.featureFlags = featureFlags + unitPrefs = UnitPrefs(this) checkForegroundPermissions() initializeUI() } private fun initializeUI() { fragmentContainer = findViewById(R.id.fragment_container) - setupMap() + fabRecordTrack = findViewById(R.id.fab_record_track) + fabRecenter = findViewById(R.id.fab_recenter) + bottomSheet = findViewById(R.id.instrument_bottom_sheet) + bottomNav = findViewById(R.id.bottom_navigation) + mapCrosshair = findViewById(R.id.map_crosshair) + + // HUD value views + hudSog = findViewById(R.id.hud_sog) + hudCog = findViewById(R.id.hud_cog) + hudBsp = findViewById(R.id.hud_bsp) + hudDepth = findViewById(R.id.hud_depth) + + // HUD unit label views + unitHudSog = findViewById(R.id.unit_hud_sog) + unitHudBsp = findViewById(R.id.unit_hud_bsp) + unitHudDepth = findViewById(R.id.unit_hud_depth) + + // Instrument sheet unit label views + unitTws = findViewById(R.id.unit_tws) + unitTemp = findViewById(R.id.unit_temp) + unitBaro = findViewById(R.id.unit_baro) + unitCurrSpd = findViewById(R.id.unit_curr_spd) + unitWaveHt = findViewById(R.id.unit_wave_ht) + unitSwellHt = findViewById(R.id.unit_swell_ht) + + // Init HUD with dashes; apply correct unit labels from stored prefs + hudSog.text = "—" + hudCog.text = "—" + hudBsp.text = "—" + hudDepth.text = "—" + applyUnitLabels() + + NavLogger.i("app", "started") setupBottomSheet() setupBottomNavigation() setupHandlers() - - findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener { - onActivateMob() + setupMap() + // Hidden dev log — long-press the drag handle on the instrument sheet + findViewById<View>(R.id.drag_handle).setOnLongClickListener { + DevLogSheet().show(supportFragmentManager, "dev_log") + true } - fabRecordTrack = findViewById(R.id.fab_record_track) fabRecordTrack.setOnClickListener { - if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() + if (!viewModel.isRecording.value) viewModel.startTrack() } val tvTrackStats = findViewById<TextView>(R.id.tv_track_stats) + fabRecordTrack.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + if (viewModel.isRecording.value) { + viewModel.stopTrack() + } else { + viewModel.injectTestTrack() + } + true + } + + fabRecenter.setOnClickListener { mapHandler?.recenter() } + // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -126,17 +206,84 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } } + lifecycleScope.launch { + viewModel.trackSummary.collect { summary -> + org.terst.nav.track.TrackSummarySheet + .from(summary, viewModel.trackStartMs) + .show(supportFragmentManager, "track_summary") + } + } + } + + /** Updates all static unit label TextViews to match current [unitPrefs]. */ + private fun applyUnitLabels() { + unitHudSog.text = unitPrefs.speedUnitLabel() + unitHudBsp.text = unitPrefs.speedUnitLabel() + unitHudDepth.text = unitPrefs.depthUnitLabel() + unitTws.text = unitPrefs.speedUnitLabel() + unitTemp.text = unitPrefs.tempUnitLabel() + unitBaro.text = unitPrefs.pressureUnitLabel() + unitCurrSpd.text = unitPrefs.speedUnitLabel() + unitWaveHt.text = unitPrefs.depthUnitLabel() + unitSwellHt.text = unitPrefs.depthUnitLabel() + } + + /** + * Re-formats every cached raw value with the current unit prefs. + * Called after the user changes a unit in the settings sheet. + */ + private fun refreshUnits() { + applyUnitLabels() + lastSogKnots?.let { hudSog.text = unitPrefs.formatSpeed(it) } + lastBspKnots?.let { hudBsp.text = unitPrefs.formatSpeed(it) } + lastDepthMeters?.let { hudDepth.text = unitPrefs.formatDepth(it) } + lastConditions?.let { applyConditions(it) } + lastBaroHpa?.let { instrumentHandler?.updateConditions(baroHpa = it) } + } + + private fun applyConditions(c: MarineConditions) { + instrumentHandler?.updateConditions( + twsKt = c.windSpeedKt, + twsBearingDeg = c.windDirDeg?.toFloat(), + tempC = c.tempC, + currSpdKt = c.currentSpeedKt, + currDirDeg = c.currentDirDeg?.toFloat(), + waveHeightM = c.waveHeightM, + waveDirDeg = c.waveDirDeg?.toFloat(), + swellHeightM = c.swellHeightM, + swellDirDeg = c.swellDirDeg?.toFloat(), + swellPeriodS = c.swellPeriodS + ) + instrumentHandler?.updateWaveState( + swellHeightM = c.swellHeightM?.toFloat() ?: 0.9f, + swellPeriodSec = c.swellPeriodS?.toFloat() ?: 10f, + windWaveHeightM = c.waveHeightM?.toFloat() ?: 0.45f, + windSpeedKt = c.windSpeedKt?.toFloat() ?: 0f + ) } 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 buildLayerPickerSheet(): LayerPickerSheet { + val currentStyle = loadedStyleFlow.value + return LayerPickerSheet( + manager = layerManager, + onBaseChanged = { preset -> currentStyle?.let { layerManager.setBasePreset(it, preset) } }, + onWindChanged = { enabled -> currentStyle?.let { layerManager.setWindEnabled(it, enabled) } }, + onParticlesChanged = { enabled -> + layerManager.setParticlesEnabled(enabled) + particleWindView?.visibility = if (enabled) View.VISIBLE else View.GONE + }, + unitPrefs = unitPrefs, + onUnitsChanged = { refreshUnits() } + ) + } + 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() @@ -145,10 +292,9 @@ 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 -> { + buildLayerPickerSheet().show(supportFragmentManager, "layer_picker") + bottomNav.post { bottomNav.selectedItemId = R.id.nav_map } true } R.id.nav_log -> { @@ -169,6 +315,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN true } + R.id.nav_learn -> { + showOverlay(org.terst.nav.ui.learn.LearnFragment()) + bottomSheetBehavior.isHideable = true + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + true + } else -> false } } @@ -176,6 +328,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun showOverlay(fragment: androidx.fragment.app.Fragment) { fragmentContainer.visibility = View.VISIBLE + fabRecordTrack.visibility = View.GONE supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, fragment) .commit() @@ -183,6 +336,26 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun hideOverlays() { fragmentContainer.visibility = View.GONE + fabRecordTrack.visibility = View.VISIBLE + // Re-apply cached conditions in case they arrived while the overlay was covering the sheet + lastConditions?.let { applyConditions(it) } + } + + override 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() { @@ -195,7 +368,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } override fun onConfigureAnchor() { - anchorWatchHandler?.toggleVisibility() + showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler()) } override fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) { @@ -208,38 +381,33 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { 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), - 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) + valueTws = findViewById(R.id.value_tws), + arrowTws = findViewById(R.id.arrow_tws), + bearingTws = findViewById(R.id.bearing_tws), + valueTemp = findViewById(R.id.value_temp), + valueBaro = findViewById(R.id.value_baro), + 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), + unitPrefs = unitPrefs ) - - // anchorWatchHandler is initialized when the anchor config UI is available - - val mockPolarTable = createMockPolarTable() - findViewById<PolarDiagramView>(R.id.polar_diagram_view).setPolarTable(mockPolarTable) - startInstrumentSimulation(mockPolarTable) } - // 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) + val fineLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + val coarseLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) - if (fineLocationPermission == PackageManager.PERMISSION_GRANTED || coarseLocationPermission == PackageManager.PERMISSION_GRANTED) { + if (fineLocation == PackageManager.PERMISSION_GRANTED || coarseLocation == PackageManager.PERMISSION_GRANTED) { startServices() } else { requestPermissionLauncher.launch(arrayOf( @@ -269,37 +437,79 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun setupMap() { + layerManager = MapLayerManager(this) mapView = findViewById(R.id.mapView) particleWindView = findViewById(R.id.particle_wind_view) + + // Always start hidden; revealed by the windArrow observer once data arrives + particleWindView?.visibility = View.GONE + + 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 -> + mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE + if (following) { + fadeOut(fabRecenter, gone = true) + fadeIn(bottomNav, fabRecordTrack) + } else { + fadeOut(bottomNav, 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 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) + // Eagerly load conditions at style-ready time so the HUD is never blank + // on startup — the camera-idle may fire before GPS fixes, this is the backstop. + maplibreMap.cameraPosition.target?.let { center -> + if (center.latitude != 0.0 || center.longitude != 0.0) { + viewModel.loadConditions(center.latitude, center.longitude) + } + } } maplibreMap.addOnCameraIdleListener { - val bounds = maplibreMap.projection.visibleRegion.latLngBounds - viewModel.loadWindGrid( - bounds.southWest.latitude, bounds.northEast.latitude, - bounds.southWest.longitude, bounds.northEast.longitude + val corners = listOfNotNull( + maplibreMap.projection.visibleRegion.nearLeft, + maplibreMap.projection.visibleRegion.nearRight, + maplibreMap.projection.visibleRegion.farLeft, + maplibreMap.projection.visibleRegion.farRight ) + if (corners.size == 4) { + viewModel.loadWindGrid( + corners.minOf { it.latitude }, corners.maxOf { it.latitude }, + corners.minOf { it.longitude }, corners.maxOf { it.longitude } + ) + } + maplibreMap.cameraPosition.target?.let { center -> + // Only refresh conditions when the user has manually panned — + // in following mode the GPS handler already triggers the load. + if (mapHandler?.isFollowing?.value == false) { + viewModel.loadConditions(center.latitude, center.longitude) + } + } + } + + maplibreMap.addOnMapLongClickListener { _ -> + buildLayerPickerSheet().show(supportFragmentManager, "layer_picker") + true } } } @@ -350,11 +560,22 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun observeDataSources() { startThermalMonitoring() + 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) + // HUD — SOG and COG from GPS + lastSogKnots = gpsData.sog + hudSog.text = unitPrefs.formatSpeed(gpsData.sog) + hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) + if (!conditionsLoaded) { + conditionsLoaded = true + NavLogger.i("gps", "first fix lat=%.4f lon=%.4f sog=%.1fkt cog=%.0f°" + .format(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog)) + viewModel.loadConditions(gpsData.latitude, gpsData.longitude) + } if (!weatherLoaded) { weatherLoaded = true viewModel.loadWeather(gpsData.latitude, gpsData.longitude) @@ -366,6 +587,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { if (arrow != null) { mapHandler?.updateWindLayer(arrow) particleWindView?.setWind(arrow.directionDeg, arrow.speedKt) + if (layerManager.particlesEnabled) { + particleWindView?.visibility = View.VISIBLE + } } } } @@ -375,6 +599,50 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } lifecycleScope.launch { + LocationService.barometerStatus.collect { status -> + if (status.history.isNotEmpty()) { + lastBaroHpa = status.currentPressureHpa + instrumentHandler?.updateConditions(baroHpa = status.currentPressureHpa) + } + } + } + lifecycleScope.launch { + viewModel.marineConditions.collect { c -> + if (c == null) return@collect + lastConditions = c + applyConditions(c) + } + } + val conditionSpinners = listOf( + R.id.spinner_tws, R.id.spinner_temp, R.id.spinner_baro, + R.id.spinner_curr, R.id.spinner_waves, R.id.spinner_swell + ).mapNotNull { findViewById<android.view.View>(it) } + lifecycleScope.launch { + viewModel.conditionsLoading.collect { loading -> + val vis = if (loading) android.view.View.VISIBLE else android.view.View.INVISIBLE + conditionSpinners.forEach { it.visibility = vis } + } + } + lifecycleScope.launch { + viewModel.conditionsError.collect { msg -> + com.google.android.material.snackbar.Snackbar + .make(findViewById(android.R.id.content), msg, com.google.android.material.snackbar.Snackbar.LENGTH_LONG) + .show() + } + } + lifecycleScope.launch { + LocationService.nmeaDepthDataFlow.collect { depthData -> + lastDepthMeters = depthData.depthMeters + hudDepth.text = unitPrefs.formatDepth(depthData.depthMeters) + } + } + lifecycleScope.launch { + LocationService.nmeaBoatSpeedData.collect { bspData -> + lastBspKnots = bspData.bspKnots + hudBsp.text = unitPrefs.formatSpeed(bspData.bspKnots) + } + } + lifecycleScope.launch { LocationService.anchorWatchState.collect { state -> safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") } @@ -387,29 +655,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) } } } @@ -422,18 +669,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(); stopThermalAlarm() } - 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(); stopThermalAlarm() } + 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 934f486..cf3103d 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 @@ -16,12 +16,26 @@ class NavApplication : Application() { private set companion object { + val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository() + lateinit var trackRepository: org.terst.nav.track.TrackRepository + lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository 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() featureFlags = FeatureFlags(this) + trackRepository = org.terst.nav.track.TrackRepository(this) + boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this) FirebaseCrashlytics.getInstance().sendUnsentReports() installCrashLogger() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt new file mode 100644 index 0000000..8d60ae0 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt @@ -0,0 +1,83 @@ +package org.terst.nav + +import android.content.Context +import java.util.Locale + +enum class TempUnit { CELSIUS, FAHRENHEIT } +enum class DepthUnit { FEET, METERS } +enum class PressureUnit { HPA, INHG } +enum class SpeedUnit { KNOTS, MPH, KPH } + +/** + * Persists and applies the user's preferred display units. + * Defaults: Fahrenheit, feet, hPa, knots. + */ +class UnitPrefs(context: Context) { + + private val prefs = context.getSharedPreferences("unit_prefs", Context.MODE_PRIVATE) + + var tempUnit: TempUnit + get() = TempUnit.valueOf(prefs.getString(KEY_TEMP, TempUnit.FAHRENHEIT.name)!!) + set(v) { prefs.edit().putString(KEY_TEMP, v.name).apply() } + + var depthUnit: DepthUnit + get() = DepthUnit.valueOf(prefs.getString(KEY_DEPTH, DepthUnit.FEET.name)!!) + set(v) { prefs.edit().putString(KEY_DEPTH, v.name).apply() } + + var pressureUnit: PressureUnit + get() = PressureUnit.valueOf(prefs.getString(KEY_PRESSURE, PressureUnit.HPA.name)!!) + set(v) { prefs.edit().putString(KEY_PRESSURE, v.name).apply() } + + var speedUnit: SpeedUnit + get() = SpeedUnit.valueOf(prefs.getString(KEY_SPEED, SpeedUnit.KNOTS.name)!!) + set(v) { prefs.edit().putString(KEY_SPEED, v.name).apply() } + + fun formatTemp(tempC: Double): String = when (tempUnit) { + TempUnit.CELSIUS -> "%.0f".format(Locale.getDefault(), tempC) + TempUnit.FAHRENHEIT -> "%.0f".format(Locale.getDefault(), tempC * 9.0 / 5.0 + 32.0) + } + + fun tempUnitLabel(): String = when (tempUnit) { + TempUnit.CELSIUS -> "°C" + TempUnit.FAHRENHEIT -> "°F" + } + + fun formatDepth(metres: Double): String = when (depthUnit) { + DepthUnit.FEET -> "%.1f".format(Locale.getDefault(), metres * 3.28084) + DepthUnit.METERS -> "%.1f".format(Locale.getDefault(), metres) + } + + fun depthUnitLabel(): String = when (depthUnit) { + DepthUnit.FEET -> "ft" + DepthUnit.METERS -> "m" + } + + fun formatPressure(hpa: Float): String = when (pressureUnit) { + PressureUnit.HPA -> "%.1f".format(Locale.getDefault(), hpa) + PressureUnit.INHG -> "%.2f".format(Locale.getDefault(), hpa * 0.02953f) + } + + fun pressureUnitLabel(): String = when (pressureUnit) { + PressureUnit.HPA -> "hPa" + PressureUnit.INHG -> "inHg" + } + + fun formatSpeed(knots: Double): String = when (speedUnit) { + SpeedUnit.KNOTS -> "%.1f".format(Locale.getDefault(), knots) + SpeedUnit.MPH -> "%.1f".format(Locale.getDefault(), knots * 1.15078) + SpeedUnit.KPH -> "%.1f".format(Locale.getDefault(), knots * 1.852) + } + + fun speedUnitLabel(): String = when (speedUnit) { + SpeedUnit.KNOTS -> "kt" + SpeedUnit.MPH -> "mph" + SpeedUnit.KPH -> "kph" + } + + companion object { + private const val KEY_TEMP = "temp_unit" + private const val KEY_DEPTH = "depth_unit" + private const val KEY_PRESSURE = "pressure_unit" + private const val KEY_SPEED = "speed_unit" + } +} 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..557d6a2 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt @@ -0,0 +1,18 @@ +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 tempC: 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..a716051 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 @@ -1,9 +1,13 @@ package org.terst.nav.data.repository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope 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 +import org.terst.nav.ui.NavLogger class WeatherRepository( private val weatherApi: WeatherApiService, @@ -12,11 +16,10 @@ 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 { + suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> { + NavLogger.i("forecast", "lat=%.4f lon=%.4f".format(lat, lon)) + return runCatching { val weather = weatherApi.getWeatherForecast(lat, lon) val h = weather.hourly h.time.indices.map { i -> @@ -29,7 +32,15 @@ class WeatherRepository( weatherCode = h.weathercode[i] ) } + }.also { result -> + result.onSuccess { items -> + val first = items.firstOrNull() + NavLogger.i("forecast", "OK ${items.size} items wind[0]=${first?.windKt?.let { "%.1f".format(it) } ?: "—"}kt@${first?.windDirDeg?.toInt() ?: "—"}°") + }.onFailure { e -> + NavLogger.e("forecast", "${e.javaClass.simpleName}: ${e.message}") + } } + } /** * Fetch a single WindArrow representing the current conditions at [lat]/[lon]. @@ -48,6 +59,48 @@ 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> { + NavLogger.i("conditions", "lat=%.4f lon=%.4f".format(lat, lon)) + return runCatching { + coroutineScope { + val weatherDeferred = async { weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) } + val marineDeferred = async { marineApi.getMarineForecast(lat, lon) } + val weather = weatherDeferred.await() + val marine = marineDeferred.await() + val w = weather.hourly + val m = marine.hourly + MarineConditions( + windSpeedKt = w.windspeed10m.firstOrNull(), + windDirDeg = w.winddirection10m.firstOrNull(), + tempC = w.temperature2m.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() + ) + } + }.also { result -> + result.onSuccess { c -> + NavLogger.i("conditions", "OK" + + " wind=${c.windSpeedKt?.let { "%.1f".format(it) } ?: "—"}kt@${c.windDirDeg?.toInt() ?: "—"}°" + + " temp=${c.tempC?.let { "%.1f".format(it) } ?: "—"}°C" + + " waves=${c.waveHeightM?.let { "%.2f".format(it) } ?: "—"}m" + + " swell=${c.swellHeightM?.let { "%.2f".format(it) } ?: "—"}m@${c.swellDirDeg?.toInt() ?: "—"}°/${c.swellPeriodS?.toInt() ?: "—"}s" + + " curr=${c.currentSpeedKt?.let { "%.2f".format(it) } ?: "—"}kt@${c.currentDirDeg?.toInt() ?: "—"}°" + ) + }.onFailure { e -> + NavLogger.e("conditions", "${e.javaClass.simpleName}: ${e.message}") + } + } + } + + /** * 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/LogEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt index 17cebfb..c038547 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt @@ -8,5 +8,6 @@ data class LogEntry( val text: String, val entryType: EntryType, val lat: Double? = null, - val lon: Double? = null + val lon: Double? = null, + val photoPath: String? = null // absolute file path or content URI string ) 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/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt index 067cbaf..0a68ba8 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt @@ -20,12 +20,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { _state.value = VoiceLogState.Error(message) } - fun confirmAndSave() { - val current = _state.value as? VoiceLogState.Result ?: return + /** + * Save an entry with the given text and optional photo path. + * Called directly by the fragment from the Save button so the user + * can have edited the recognized text in the EditText beforehand. + */ + fun save(text: String, photoPath: String? = null) { + if (text.isBlank() && photoPath == null) return val entry = LogEntry( timestampMs = System.currentTimeMillis(), - text = current.recognized, - entryType = EntryType.GENERAL + text = text.ifBlank { "(photo only)" }, + entryType = EntryType.GENERAL, + photoPath = photoPath ) val saved = repository.save(entry) _state.value = VoiceLogState.Saved(saved) 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) +} 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 a68e315..4a28bb4 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,47 +1,101 @@ package org.terst.nav.track +import android.content.Context +import android.util.Log import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.terst.nav.ui.NavLogger -class TrackRepository { +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 + + NavLogger.i("track", "stopTrack: ${points.size} pts dur=${summary.durationMs / 60_000}min dist=%.2fnm".format(summary.distanceNm)) + _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") + NavLogger.e("track", "saveTrack returned false — ${points.size} pts 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() fun computeStats(): TrackStats? { - if (points.size < 2) return null + if (activePoints.size < 2) return null var distNm = 0.0 - for (i in 1 until points.size) { - distNm += haversineNm(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon) + for (i in 1 until activePoints.size) { + distNm += haversineNm(activePoints[i - 1].lat, activePoints[i - 1].lon, activePoints[i].lat, activePoints[i].lon) } - val durationMs = points.last().timestampMs - points.first().timestampMs + val durationMs = activePoints.last().timestampMs - activePoints.first().timestampMs val avgSog = if (durationMs > 0) { - points.map { it.sogKnots }.average() + activePoints.map { it.sogKnots }.average() } else 0.0 return TrackStats(distNm, durationMs, avgSog) } + /** + * 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() + } + companion object { fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val r = 3440.065 // Earth radius in nautical miles 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..3a04688 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt @@ -0,0 +1,163 @@ +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 org.terst.nav.ui.NavLogger +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) { + val msg = "storage not mounted (state=$storageState) — cannot save $fileName" + Log.e(TAG, msg); NavLogger.e("track", msg) + 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) + } + NavLogger.i("track", "MediaStore insert → Documents/Nav/$fileName") + val uri = context.contentResolver.insert( + MediaStore.Files.getContentUri("external"), values + ) ?: run { + val msg = "MediaStore insert returned null for $fileName" + Log.e(TAG, msg); NavLogger.e("track", msg) + return false + } + + return runCatching { + val stream = context.contentResolver.openOutputStream(uri) + if (stream == null) { + context.contentResolver.delete(uri, null, null) + val msg = "openOutputStream null for $fileName — deleted orphan entry" + Log.e(TAG, msg); NavLogger.e("track", msg) + 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) + + val msg = "saved $fileName (${gpx.length} bytes) → $uri" + Log.d(TAG, msg); NavLogger.i("track", msg) + true + }.getOrElse { e -> + val msg = "write failed for $fileName: ${e.javaClass.simpleName}: ${e.message}" + Log.e(TAG, msg); NavLogger.e("track", msg) + 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) + } + }.onFailure { e -> + NavLogger.e("track", "load parse error: ${e.javaClass.simpleName}: ${e.message}") + } + } + } + NavLogger.i("track", "loadViaMediaStore: ${tracks.size} tracks found") + 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/BoatProfileRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt new file mode 100644 index 0000000..090e928 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt @@ -0,0 +1,137 @@ +package org.terst.nav.tripreport + +import android.content.Context +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +/** + * Persists boat profiles to SharedPreferences as a JSON array. + * Pre-seeds Erickson 23 and Cal 20 on first launch. + */ +class BoatProfileRepository(context: Context) { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + private val listType = Types.newParameterizedType(List::class.java, ProfileJson::class.java) + private val adapter = moshi.adapter<List<ProfileJson>>(listType) + + // ── Public API ──────────────────────────────────────────────────────────── + + fun loadProfiles(): List<BoatProfile> { + val json = prefs.getString(KEY_PROFILES, null) + val list = if (json != null) runCatching { adapter.fromJson(json) }.getOrNull() else null + return if (list.isNullOrEmpty()) { + val defaults = defaultProfiles() + saveProfiles(defaults) + defaults + } else { + list.map { it.toDomain() } + } + } + + fun saveProfiles(profiles: List<BoatProfile>) { + prefs.edit().putString(KEY_PROFILES, adapter.toJson(profiles.map { ProfileJson.from(it) })).apply() + } + + fun selectedId(): String? = prefs.getString(KEY_SELECTED, null) + + fun setSelectedId(id: String) = prefs.edit().putString(KEY_SELECTED, id).apply() + + fun selectedProfile(): BoatProfile? { + val id = selectedId() ?: return loadProfiles().firstOrNull() + return loadProfiles().firstOrNull { it.id == id } + } + + // ── JSON transfer object (Moshi needs simple primitives) ───────────────── + + @JsonClass(generateAdapter = true) + data class SailJson( + val name: String, + val minWindKt: Int, + val maxWindKt: Int, + val isHeadsail: Boolean + ) { + fun toDomain() = SailConfig(name, minWindKt, maxWindKt, isHeadsail) + companion object { + fun from(s: SailConfig) = SailJson(s.name, s.minWindKt, s.maxWindKt, s.isHeadsail) + } + } + + @JsonClass(generateAdapter = true) + data class ProfileJson( + val id: String, + val name: String, + val lengthFt: Double, + val type: String, // BoatType.name + val rig: String, // RigType.name + val headsails: List<SailJson>, + val mainReefs: Int, + val hasSpinnaker: Boolean, + val hasGennaker: Boolean, + val notes: String + ) { + fun toDomain() = BoatProfile( + id = id, + name = name, + lengthFt = lengthFt, + type = runCatching { BoatType.valueOf(type) }.getOrDefault(BoatType.MONOHULL), + rig = runCatching { RigType.valueOf(rig) }.getOrDefault(RigType.SLOOP), + headsails = headsails.map { it.toDomain() }, + mainReefs = mainReefs, + hasSpinnaker = hasSpinnaker, + hasGennaker = hasGennaker, + notes = notes + ) + companion object { + fun from(p: BoatProfile) = ProfileJson( + id = p.id, + name = p.name, + lengthFt = p.lengthFt, + type = p.type.name, + rig = p.rig.name, + headsails = p.headsails.map { SailJson.from(it) }, + mainReefs = p.mainReefs, + hasSpinnaker = p.hasSpinnaker, + hasGennaker = p.hasGennaker, + notes = p.notes + ) + } + } + + companion object { + private const val PREF_NAME = "boat_profiles" + private const val KEY_PROFILES = "profiles" + private const val KEY_SELECTED = "selected_id" + + fun defaultProfiles() = listOf( + BoatProfile( + id = "e23", + name = "Erickson 23", + lengthFt = 23.0, + type = BoatType.MONOHULL, + rig = RigType.SLOOP, + headsails = listOf( + SailConfig("155% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true), + SailConfig("100% Jib", minWindKt = 10, maxWindKt = 21, isHeadsail = true), + SailConfig("65% Blade", minWindKt = 18, maxWindKt = 40, isHeadsail = true) + ), + mainReefs = 2 + ), + BoatProfile( + id = "cal20", + name = "Cal 20", + lengthFt = 20.0, + type = BoatType.MONOHULL, + rig = RigType.SLOOP, + headsails = listOf( + SailConfig("150% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true), + SailConfig("100% Jib", minWindKt = 10, maxWindKt = 22, isHeadsail = true), + SailConfig("65% Blade", minWindKt = 19, maxWindKt = 40, isHeadsail = true) + ), + mainReefs = 1 + ) + ) + } +} 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..c1bc6e7 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt @@ -0,0 +1,91 @@ +package org.terst.nav.tripreport + +enum class BoatType { MONOHULL, MULTIHULL } +enum class RigType { SLOOP, CUTTER, KETCH } + +/** + * A single headsail (or main-sail configuration) in the boat's inventory. + * Wind ranges are approximate apparent-wind speeds at which the sail is appropriate. + */ +data class SailConfig( + val name: String, // e.g. "155% Genoa", "100% Jib", "65% Blade" + val minWindKt: Int = 0, + val maxWindKt: Int, + val isHeadsail: Boolean +) + +data class BoatProfile( + val id: String, + val name: String, + val lengthFt: Double, + val type: BoatType, + val rig: RigType, + val headsails: List<SailConfig> = emptyList(), + val mainReefs: Int = 0, + val hasSpinnaker: Boolean = false, + val hasGennaker: Boolean = false, + val notes: String = "" +) + +// ── Snapshot used to build the report ──────────────────────────────────────── + +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 +) + +// ── Output models ───────────────────────────────────────────────────────────── + +/** One hourly step in the trip-window forecast table. */ +data class ConditionSlice( + val label: String, // "Now", "+1 h", "+2 h", "+3 h" + val windKt: Double, + val windDirDeg: Double, + val waveHeightM: Double?, + val weatherDescription: String +) + +/** Suggested heading and distance for a round-trip sail. */ +data class RouteProjection( + val departureHeadingDeg: Double, + val outboundLegNm: Double, // nm before recommended turn-around + val returnHeadingDeg: Double, + val outboundPointOfSail: String, // "Beam Reach", "Close Hauled", etc. + val returnPointOfSail: String, + val rationale: String // human-readable description +) + +/** A single hazard or advisory notice. */ +data class WatchItem( + val severity: String, // "⚠️" warning or "ℹ️" info + val message: String +) + +/** Aggregated stats from past trips that had similar wind conditions. */ +data class SimilarTripSummary( + val count: Int, + val avgDistanceNm: Double, + val avgDurationHrs: Double, + val avgMaxSogKt: Double, + val conditionDescription: String // e.g. "10–15 kt NE" +) + +data class SailSuggestion( + val sailName: String, + val action: String +) + +data class PreTripReport( + val summary: PreTripSummary, + val conditionWindow: List<ConditionSlice>, + val routeProjection: RouteProjection, + val sailPlan: List<SailSuggestion>, + val watchItems: List<WatchItem>, + val similarTrips: SimilarTripSummary? +) 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..ac1350d --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt @@ -0,0 +1,297 @@ +package org.terst.nav.tripreport + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import kotlinx.coroutines.launch +import org.terst.nav.NavApplication +import org.terst.nav.R +import org.terst.nav.ui.MainViewModel +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class PreTripReportFragment : Fragment() { + + private val mainViewModel: MainViewModel by activityViewModels() + private lateinit var viewModel: PreTripReportViewModel + + private lateinit var chipGroupBoats: ChipGroup + private lateinit var tvDepartureTime: TextView + private lateinit var btnPickDeparture: MaterialButton + private lateinit var btnGenerate: MaterialButton + private lateinit var progress: ProgressBar + private lateinit var cardConditions: MaterialCardView + private lateinit var conditionsTable: LinearLayout + private lateinit var cardRoute: MaterialCardView + private lateinit var tvRoute: TextView + private lateinit var cardSailPlan: MaterialCardView + private lateinit var tvSailPlan: TextView + private lateinit var cardWatchlist: MaterialCardView + private lateinit var tvWatchlist: TextView + private lateinit var cardSimilar: MaterialCardView + private lateinit var tvSimilar: TextView + private lateinit var tvError: TextView + + private val departureSdf = SimpleDateFormat("EEE MMM d, h:mm a", Locale.getDefault()) + + 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) + + viewModel = ViewModelProvider( + requireActivity(), + PreTripReportViewModel.Factory(NavApplication.boatProfileRepository) + )[PreTripReportViewModel::class.java] + + bindViews(view) + observeViewModel() + + btnGenerate.setOnClickListener { triggerGenerate() } + btnPickDeparture.setOnClickListener { showDeparturePicker() } + + if (mainViewModel.forecast.value.isNotEmpty()) { + triggerGenerate() + } + } + + private fun bindViews(view: View) { + chipGroupBoats = view.findViewById(R.id.chip_group_boats) + tvDepartureTime = view.findViewById(R.id.tv_departure_time) + btnPickDeparture = view.findViewById(R.id.btn_pick_departure) + btnGenerate = view.findViewById(R.id.btn_generate_pretrip) + progress = view.findViewById(R.id.progress_pretrip) + cardConditions = view.findViewById(R.id.card_conditions) + conditionsTable = view.findViewById(R.id.conditions_table) + cardRoute = view.findViewById(R.id.card_route) + tvRoute = view.findViewById(R.id.tv_route) + cardSailPlan = view.findViewById(R.id.card_sail_plan) + tvSailPlan = view.findViewById(R.id.tv_sail_plan) + cardWatchlist = view.findViewById(R.id.card_watchlist) + tvWatchlist = view.findViewById(R.id.tv_watchlist) + cardSimilar = view.findViewById(R.id.card_similar) + tvSimilar = view.findViewById(R.id.tv_similar) + tvError = view.findViewById(R.id.tv_error) + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.profiles.collect { profiles -> rebuildBoatChips(profiles) } + } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.selectedProfile.collect { profile -> + if (profile != null) syncChipSelection(profile.id) + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.departureMs.collect { ms -> renderDepartureLabel(ms) } + } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.state.collect { renderState(it) } + } + } + + // ── Departure picker ────────────────────────────────────────────────────── + + private fun showDeparturePicker() { + val currentMs = viewModel.departureMs.value + + // Constrain to today or future + val constraints = CalendarConstraints.Builder() + .setValidator(DateValidatorPointForward.now()) + .build() + + val datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText("Departure date") + .setSelection(currentMs) + .setCalendarConstraints(constraints) + .build() + + datePicker.addOnPositiveButtonClickListener { dateMs -> + // dateMs is midnight UTC of the selected date; add current time-of-day offset + val cal = Calendar.getInstance().apply { timeInMillis = dateMs } + // Default to current hour of day, rounded to nearest half-hour + val now = Calendar.getInstance() + cal.set(Calendar.HOUR_OF_DAY, now.get(Calendar.HOUR_OF_DAY)) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + + val timePicker = MaterialTimePicker.Builder() + .setTitleText("Departure time") + .setTimeFormat(TimeFormat.CLOCK_12H) + .setHour(cal.get(Calendar.HOUR_OF_DAY)) + .setMinute(0) + .build() + + timePicker.addOnPositiveButtonClickListener { + cal.set(Calendar.HOUR_OF_DAY, timePicker.hour) + cal.set(Calendar.MINUTE, timePicker.minute) + viewModel.setDeparture(cal.timeInMillis) + // Re-generate with new departure time + if (mainViewModel.forecast.value.isNotEmpty()) triggerGenerate() + } + + timePicker.show(childFragmentManager, "time_picker") + } + + datePicker.show(childFragmentManager, "date_picker") + } + + private fun renderDepartureLabel(ms: Long) { + val now = System.currentTimeMillis() + tvDepartureTime.text = if (abs(ms - now) < 3_600_000L) { + "Now" + } else { + departureSdf.format(ms) + } + } + + // ── Boat chips ──────────────────────────────────────────────────────────── + + private fun rebuildBoatChips(profiles: List<BoatProfile>) { + chipGroupBoats.removeAllViews() + val selectedId = viewModel.selectedProfile.value?.id + profiles.forEach { profile -> + val chip = Chip(requireContext()).apply { + text = profile.name + isCheckable = true + isChecked = (profile.id == selectedId) + tag = profile.id + } + chip.setOnCheckedChangeListener { _, checked -> + if (checked) { + viewModel.selectProfile(profile.id) + triggerGenerate() + } + } + chipGroupBoats.addView(chip) + } + } + + private fun syncChipSelection(selectedId: String) { + for (i in 0 until chipGroupBoats.childCount) { + val chip = chipGroupBoats.getChildAt(i) as? Chip ?: continue + chip.isChecked = (chip.tag == selectedId) + } + } + + private fun triggerGenerate() { + val forecast = mainViewModel.forecast.value + val conditions = mainViewModel.marineConditions.value + viewModel.generate(forecast, conditions) + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + private fun renderState(state: PreTripState) { + tvError.visibility = View.GONE + when (state) { + is PreTripState.Loading -> { + progress.visibility = View.VISIBLE + btnGenerate.isEnabled = false + } + is PreTripState.Success -> { + progress.visibility = View.GONE + btnGenerate.isEnabled = true + renderReport(state.report) + } + is PreTripState.Error -> { + progress.visibility = View.GONE + btnGenerate.isEnabled = true + tvError.text = state.message + tvError.visibility = View.VISIBLE + } + else -> { + progress.visibility = View.GONE + btnGenerate.isEnabled = true + } + } + } + + private fun renderReport(r: PreTripReport) { + renderConditionsWindow(r.conditionWindow) + renderRoute(r.routeProjection) + renderSailPlan(r.sailPlan) + renderWatchlist(r.watchItems) + renderSimilarTrips(r.similarTrips) + } + + private fun renderConditionsWindow(slices: List<ConditionSlice>) { + conditionsTable.removeAllViews() + slices.forEach { slice -> + val col = LayoutInflater.from(requireContext()) + .inflate(R.layout.item_condition_column, conditionsTable, false) + col.findViewById<TextView>(R.id.col_label).text = slice.label + col.findViewById<TextView>(R.id.col_wind).text = "%.0f kt".format(slice.windKt) + col.findViewById<TextView>(R.id.col_wind_dir).text = cardinalDir(slice.windDirDeg) + col.findViewById<TextView>(R.id.col_sky).text = slice.weatherDescription.take(12) + conditionsTable.addView(col) + } + cardConditions.visibility = if (slices.isNotEmpty()) View.VISIBLE else View.GONE + } + + private fun renderRoute(proj: RouteProjection) { + tvRoute.text = proj.rationale + cardRoute.visibility = View.VISIBLE + } + + private fun renderSailPlan(plan: List<SailSuggestion>) { + tvSailPlan.text = plan.joinToString("\n") { "• ${it.sailName}: ${it.action}" } + cardSailPlan.visibility = View.VISIBLE + } + + private fun renderWatchlist(items: List<WatchItem>) { + if (items.isEmpty()) { + cardWatchlist.visibility = View.GONE + return + } + tvWatchlist.text = items.joinToString("\n") { "${it.severity} ${it.message}" } + cardWatchlist.visibility = View.VISIBLE + } + + private fun renderSimilarTrips(similar: SimilarTripSummary?) { + if (similar == null) { + cardSimilar.visibility = View.GONE + return + } + tvSimilar.text = "%d trip%s in %s\n• Avg distance %.1f nm\n• Avg duration %.1f h\n• Avg max SOG %.1f kt".format( + Locale.getDefault(), + similar.count, + if (similar.count == 1) "" else "s", + similar.conditionDescription, + similar.avgDistanceNm, + similar.avgDurationHrs, + similar.avgMaxSogKt + ) + cardSimilar.visibility = View.VISIBLE + } + + private fun cardinalDir(deg: Double): String { + val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE", + "S","SSW","SW","WSW","W","WNW","NW","NNW") + return dirs[((deg + 11.25) / 22.5).toInt() % 16] + } + + private fun abs(x: Long) = if (x < 0) -x else x +} 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..2840d76 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt @@ -0,0 +1,408 @@ +package org.terst.nav.tripreport + +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.MarineConditions +import org.terst.nav.track.TrackPoint +import org.terst.nav.track.summarise +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.* + +class PreTripReportGenerator { + + /** + * Builds a full pre-trip briefing. + * + * @param lat Current position latitude + * @param lon Current position longitude + * @param forecastItems Hourly forecast list (168 slots) + * @param conditions Current marine snapshot (waves, current, swell) + * @param boatProfile Selected vessel with sail inventory + * @param pastTracks All saved tracks for similar-conditions comparison + * @param durationHrs Planned trip duration in hours (default 3) + * @param departureDateTimeMs Unix millis for planned departure (default = now) + */ + fun generateReport( + lat: Double, + lon: Double, + forecastItems: List<ForecastItem>, + conditions: MarineConditions?, + boatProfile: BoatProfile, + pastTracks: List<List<TrackPoint>> = emptyList(), + durationHrs: Double = 3.0, + departureDateTimeMs: Long = System.currentTimeMillis() + ): PreTripReport { + // Find the forecast slot nearest to the planned departure time + val startIndex = findDepartureSlot(forecastItems, departureDateTimeMs) + val window = forecastItems.drop(startIndex).take(4) + + val current = window.firstOrNull() + val windKt = current?.windKt ?: 0.0 + val windDir = current?.windDirDeg ?: 0.0 + + val summary = PreTripSummary( + timestampMs = departureDateTimeMs, + lat = lat, + lon = lon, + windSpeedKt = windKt, + windDirDeg = windDir, + waveHeightM = conditions?.waveHeightM, + weatherDescription = current?.weatherDescription() ?: "Unknown", + boatProfile = boatProfile + ) + + return PreTripReport( + summary = summary, + conditionWindow = buildConditionWindow(window, departureDateTimeMs), + routeProjection = projectRoute(windDir, windKt, + conditions?.currentSpeedKt ?: 0.0, + conditions?.currentDirDeg ?: 0.0, + durationHrs, boatProfile), + sailPlan = suggestSailPlan(windKt, window, boatProfile), + watchItems = buildWatchList(conditions, window, boatProfile, departureDateTimeMs), + similarTrips = findSimilarTrips(pastTracks, windKt, windDir) + ) + } + + // ── Departure slot lookup ───────────────────────────────────────────────── + + /** + * Finds the index of the forecast item closest to [departureMs]. + * Open-Meteo returns times as UTC ISO strings ("yyyy-MM-dd'T'HH:mm"). + */ + private fun findDepartureSlot(items: List<ForecastItem>, departureMs: Long): Int { + if (items.isEmpty()) return 0 + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + + var bestIdx = 0 + var bestDiff = Long.MAX_VALUE + items.forEachIndexed { idx, item -> + try { + val itemMs = sdf.parse(item.timeIso)?.time ?: return@forEachIndexed + val diff = abs(itemMs - departureMs) + if (diff < bestDiff) { + bestDiff = diff + bestIdx = idx + } + } catch (_: Exception) {} + } + // Leave at least 4 items for the condition window + return bestIdx.coerceAtMost((items.size - 4).coerceAtLeast(0)) + } + + // ── Condition window ────────────────────────────────────────────────────── + + private fun buildConditionWindow( + items: List<ForecastItem>, + departureDateTimeMs: Long + ): List<ConditionSlice> { + val isNearNow = abs(departureDateTimeMs - System.currentTimeMillis()) < 3_600_000L + val utcSdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US).also { + it.timeZone = TimeZone.getTimeZone("UTC") + } + // Display times in device local time (e.g. "2 PM") + val displaySdf = SimpleDateFormat("h a", Locale.getDefault()) + + return items.take(4).mapIndexed { i, item -> + val label = when { + isNearNow && i == 0 -> "Now" + isNearNow -> "+${i}h" + else -> try { + val t = utcSdf.parse(item.timeIso) + if (t != null) displaySdf.format(t) else "+${i}h" + } catch (_: Exception) { "+${i}h" } + } + ConditionSlice( + label = label, + windKt = item.windKt, + windDirDeg = item.windDirDeg, + waveHeightM = null, + weatherDescription = item.weatherDescription() + ) + } + } + + // ── Route projection ────────────────────────────────────────────────────── + + /** + * Candidate headings from Honokohau — each carries local context. + * Format: (headingDeg, label, localNote) + */ + private val candidates = listOf( + Triple(350.0, "N", "Kohala coast — upwind grind out on NE trades, fast run home"), + Triple(305.0, "NW", "Offshore — watch for trade acceleration past Upolu Point"), + Triple(270.0, "W", "Open water — comfortable in most trade conditions"), + Triple(225.0, "SW", "Toward Keauhou — broad reach out, may be a beat home if trades build"), + Triple(180.0, "S", "Keauhou Bay — note Alenuihāhā Channel shipping traffic") + ) + + private fun projectRoute( + windDirDeg: Double, + twsKt: Double, + currentSpeedKt: Double, + currentDirDeg: Double, + durationHrs: Double, + boatProfile: BoatProfile + ): RouteProjection { + data class Candidate( + val heading: Double, val label: String, val note: String, + val twa: Double, val sog: Double, val score: Double + ) + + val scored = candidates.map { (hdg, lbl, note) -> + val twa = twa(windDirDeg, hdg) + val sog = estimatedSogKt(boatProfile.lengthFt, twsKt, twa) + val score = headingScore(twa) + Candidate(hdg, lbl, note, twa, sog, score) + } + + val best = scored.maxByOrNull { it.score }!! + + val returnHdg = (best.heading + 180.0) % 360.0 + val returnTwa = twa(windDirDeg, returnHdg) + val returnSog = estimatedSogKt(boatProfile.lengthFt, twsKt, returnTwa) + + val outboundHrs = durationHrs / 2.0 + val outboundNm = best.sog * outboundHrs + + val currentComponent = currentSpeedKt * + cos(Math.toRadians(currentDirDeg - best.heading)) + val currentNote = when { + currentComponent > 0.15 -> "Current adds %.1f kt outbound.".format(currentComponent) + currentComponent < -0.15 -> "Current opposes %.1f kt outbound.".format(-currentComponent) + else -> "" + } + + val outboundPos = pointOfSailName(best.twa) + val returnPos = pointOfSailName(returnTwa) + + val rationale = buildString { + append("Head ${best.label} (${best.heading.toInt()}°) — $outboundPos ") + append("at ~%.1f kt. ".format(best.sog)) + append("Est. turn-around %.1f nm out".format(outboundNm)) + append(", ~%.0f min. ".format(outboundHrs * 60)) + append("Return (${returnHdg.toInt()}°): $returnPos at ~%.1f kt.".format(returnSog)) + if (currentNote.isNotEmpty()) append(" $currentNote") + append("\n${best.note}") + } + + return RouteProjection( + departureHeadingDeg = best.heading, + outboundLegNm = outboundNm, + returnHeadingDeg = returnHdg, + outboundPointOfSail = outboundPos, + returnPointOfSail = returnPos, + rationale = rationale + ) + } + + /** True wind angle in degrees (0–180) for a given course relative to wind direction. */ + private fun twa(windDirFrom: Double, boatHdg: Double): Double { + var angle = abs(windDirFrom - boatHdg) % 360.0 + if (angle > 180.0) angle = 360.0 - angle + return angle + } + + /** Simplified polar SOG estimate based on hull speed limit and point of sail. */ + fun estimatedSogKt(lengthFt: Double, twsKt: Double, twaDegs: Double): Double { + val hullSpeed = 1.34 * sqrt(lengthFt) + val efficiency = when { + twaDegs < 40 -> 0.45 + twaDegs < 70 -> 0.70 + twaDegs < 120 -> 0.85 // beam reach — optimum + twaDegs < 150 -> 0.78 + else -> 0.60 // dead run + } + val windFactor = when { + twsKt < 5 -> 0.30 + twsKt < 10 -> 0.65 + twsKt < 15 -> 0.85 + twsKt < 22 -> 1.00 + else -> 0.88 // reefed, slightly slower + } + return (hullSpeed * efficiency * windFactor).coerceAtMost(hullSpeed) + } + + /** Higher = better heading; prefer beam reach, penalise hard beat or dead run. */ + private fun headingScore(twa: Double): Double = when { + twa < 35 -> 0.2 + twa < 55 -> 0.5 + twa < 125 -> 1.0 // broad beam reach window — ideal + twa < 150 -> 0.75 + else -> 0.4 + } + + private fun pointOfSailName(twa: Double): String = when { + twa < 45 -> "Close Hauled" + twa < 70 -> "Close Reach" + twa < 115 -> "Beam Reach" + twa < 150 -> "Broad Reach" + else -> "Run" + } + + // ── Sail plan ───────────────────────────────────────────────────────────── + + private fun suggestSailPlan( + windKt: Double, + forecastItems: List<ForecastItem>, + boatProfile: BoatProfile + ): List<SailSuggestion> { + val suggestions = mutableListOf<SailSuggestion>() + + val headsail = boatProfile.headsails + .sortedBy { it.maxWindKt } + .firstOrNull { windKt <= it.maxWindKt } + ?: boatProfile.headsails.minByOrNull { it.maxWindKt } + + if (headsail != null) { + val note = headsailNote(headsail, windKt, forecastItems, boatProfile) + suggestions.add(SailSuggestion(headsail.name, note)) + } + + val mainAction = when { + windKt > 21 && boatProfile.mainReefs >= 2 -> "2nd Reef" + windKt > 15 && boatProfile.mainReefs >= 1 -> "1st Reef" + windKt > 22 && boatProfile.mainReefs == 1 -> "Full Reef" + else -> "Full Main" + } + suggestions.add(SailSuggestion("Main", mainAction)) + + val maxForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + if (maxForecastWind > windKt + 4) { + val nextSail = boatProfile.headsails + .sortedBy { it.maxWindKt } + .firstOrNull { maxForecastWind <= it.maxWindKt && it != headsail } + if (nextSail != null) { + suggestions.add(SailSuggestion( + "On deck", + "${nextSail.name} — winds forecast to reach %.0f kt".format(maxForecastWind) + )) + } + } + + return suggestions + } + + private fun headsailNote( + sail: SailConfig, + windKt: Double, + forecastItems: List<ForecastItem>, + profile: BoatProfile + ): String { + val maxForecast = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + + if (sail.name.contains("155%") && windKt > 10) { + return if (maxForecast > sail.maxWindKt) + "Flying now, but winds forecast to ${maxForecast.toInt()} kt — swap to 100% before departure" + else + "Good in current conditions; 155% sheets outside shrouds, plan tacks early" + } + + return when { + windKt <= sail.maxWindKt * 0.6 -> "Light for this sail — ${sail.name} is correct choice" + windKt <= sail.maxWindKt -> "In the sweet spot for ${sail.name}" + else -> "Approaching upper limit — consider smaller headsail" + } + } + + // ── Watch list ──────────────────────────────────────────────────────────── + + private fun buildWatchList( + conditions: MarineConditions?, + forecastItems: List<ForecastItem>, + boatProfile: BoatProfile, + departureDateTimeMs: Long = System.currentTimeMillis() + ): List<WatchItem> { + val items = mutableListOf<WatchItem>() + val windKt = forecastItems.firstOrNull()?.windKt ?: 0.0 + + conditions?.swellHeightM?.let { swell -> + if (swell > 2.0) + items += WatchItem("⚠️", "Significant swell %.1fm — expect motion".format(swell)) + } + conditions?.swellPeriodS?.let { period -> + if (period < 8.0) + items += WatchItem("⚠️", "Short swell period %.0fs — choppy, uncomfortable".format(period)) + } + + val peakForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + val peakHour = forecastItems.take(4).indexOfFirst { it.windKt == peakForecastWind } + if (peakForecastWind > windKt + 5) { + items += WatchItem("⚠️", + "Wind building to ${peakForecastWind.toInt()} kt in ~${peakHour} h — plan to be back before then") + } + + val maxPrecip = forecastItems.take(4).maxOfOrNull { it.precipProbabilityPct } ?: 0 + if (maxPrecip > 30) + items += WatchItem("⚠️", "Rain likely ($maxPrecip% chance) — visibility may reduce") + + // Use departure hour for time-of-day trade warnings + val departureHour = Calendar.getInstance().also { it.timeInMillis = departureDateTimeMs } + .get(Calendar.HOUR_OF_DAY) + if (departureHour in 11..14) + items += WatchItem("ℹ️", "Departing midday — Kona trades typically build 5–8 kt through afternoon") + else if (departureHour >= 15) + items += WatchItem("ℹ️", "Afternoon departure — trades may be at their strongest; build extra time margin") + + val largeGenoa = boatProfile.headsails.firstOrNull { it.name.contains("155%") } + if (largeGenoa != null && windKt > largeGenoa.maxWindKt) { + items += WatchItem("⚠️", "155% Genoa will overpower ${boatProfile.name} at ${windKt.toInt()} kt — rig 100% Jib instead") + } + + if (windKt > 30) + items += WatchItem("⚠️", "Winds ${windKt.toInt()} kt — consider staying in port") + else if (windKt > 22) + items += WatchItem("⚠️", "Strong winds — reef before casting off") + + return items + } + + // ── Similar trips ───────────────────────────────────────────────────────── + + private fun findSimilarTrips( + pastTracks: List<List<TrackPoint>>, + windKt: Double, + windDirDeg: Double + ): SimilarTripSummary? { + if (pastTracks.isEmpty()) return null + + val matched = pastTracks.filter { points -> + if (points.size < 5) return@filter false + val winds = points.mapNotNull { it.windSpeedKnots } + if (winds.isEmpty()) return@filter false + val avgWind = winds.average() + val avgDir = points.mapNotNull { it.windAngleDeg }.average().takeIf { !it.isNaN() } ?: return@filter false + abs(avgWind - windKt) <= 5.0 && angleDiff(avgDir, windDirDeg) <= 30.0 + } + + if (matched.isEmpty()) return null + + val summaries = matched.map { summarise(it) } + val condLo = (windKt - 5).coerceAtLeast(0.0).toInt() + val condHi = (windKt + 5).toInt() + val dirLabel = cardinalDir(windDirDeg) + + return SimilarTripSummary( + count = matched.size, + avgDistanceNm = summaries.map { it.distanceNm }.average(), + avgDurationHrs = summaries.map { it.durationMs / 3_600_000.0 }.average(), + avgMaxSogKt = summaries.map { it.maxSogKt }.average(), + conditionDescription = "$condLo–$condHi kt $dirLabel" + ) + } + + private fun angleDiff(a: Double, b: Double): Double { + var d = abs(a - b) % 360.0 + if (d > 180.0) d = 360.0 - d + return d + } + + private fun cardinalDir(deg: Double): String { + val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE", + "S","SSW","SW","WSW","W","WNW","NW","NNW") + return dirs[((deg + 11.25) / 22.5).toInt() % 16] + } +} 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..64a739a --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt @@ -0,0 +1,85 @@ +package org.terst.nav.tripreport + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +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.LocationService +import org.terst.nav.NavApplication +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 boatRepo: BoatProfileRepository, + private val generator: PreTripReportGenerator = PreTripReportGenerator() +) : ViewModel() { + + private val _state = MutableStateFlow<PreTripState>(PreTripState.Idle) + val state: StateFlow<PreTripState> = _state.asStateFlow() + + private val _profiles = MutableStateFlow<List<BoatProfile>>(emptyList()) + val profiles: StateFlow<List<BoatProfile>> = _profiles.asStateFlow() + + private val _selectedProfile = MutableStateFlow<BoatProfile?>(null) + val selectedProfile: StateFlow<BoatProfile?> = _selectedProfile.asStateFlow() + + private val _departureMs = MutableStateFlow(System.currentTimeMillis()) + val departureMs: StateFlow<Long> = _departureMs.asStateFlow() + + init { + val profiles = boatRepo.loadProfiles() + _profiles.value = profiles + _selectedProfile.value = boatRepo.selectedProfile() ?: profiles.firstOrNull() + } + + fun selectProfile(id: String) { + boatRepo.setSelectedId(id) + _selectedProfile.value = _profiles.value.firstOrNull { it.id == id } + } + + fun setDeparture(ms: Long) { + _departureMs.value = ms + } + + fun generate(forecastItems: List<ForecastItem>, conditions: MarineConditions?) { + val profile = _selectedProfile.value ?: return + val pos = LocationService.bestPosition.value + + viewModelScope.launch { + _state.value = PreTripState.Loading + try { + val pastTracks = NavApplication.trackRepository.getPastTracks() + val report = generator.generateReport( + lat = pos?.latitude ?: 19.664, // Honokohau fallback + lon = pos?.longitude ?: -156.024, + forecastItems = forecastItems, + conditions = conditions, + boatProfile = profile, + pastTracks = pastTracks, + departureDateTimeMs = _departureMs.value + ) + _state.value = PreTripState.Success(report) + } catch (e: Exception) { + _state.value = PreTripState.Error(e.message ?: "Failed to generate report") + } + } + } + + // ── Factory ─────────────────────────────────────────────────────────────── + + class Factory(private val boatRepo: BoatProfileRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>): T = + PreTripReportViewModel(boatRepo) as T + } +} 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/DevLogSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt new file mode 100644 index 0000000..99e6d5f --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt @@ -0,0 +1,58 @@ +package org.terst.nav.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import org.terst.nav.R + +class DevLogSheet : BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.sheet_dev_log, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Expand to full height + (dialog as? BottomSheetDialog)?.behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + } + + val logText = view.findViewById<TextView>(R.id.log_text) + val scrollView = view.findViewById<ScrollView>(R.id.scroll_view) + + fun refresh() { + val text = NavLogger.getLog() + logText.text = text.ifEmpty { "(no log entries yet)" } + scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) } + } + + refresh() + + view.findViewById<MaterialButton>(R.id.btn_copy).setOnClickListener { + val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("nav_dev_log", NavLogger.getLog())) + Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + + view.findViewById<MaterialButton>(R.id.btn_clear).setOnClickListener { + NavLogger.clear() + refresh() + } + } +} 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..ccb3a91 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,138 @@ 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 org.terst.nav.UnitPrefs import java.util.Locale +// ── Pure formatting helpers (top-level for testability) ────────────────────── + +/** 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 the area-conditions bottom sheet: wind header row, wave view, + * and the forecast section (current / waves / swell). + * + * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly + * in MainActivity via the HUD strip. + * + * All numeric values are accepted in SI / nautical base units: + * speeds in knots, heights in metres, pressure in hPa, temperature in °C. + * [unitPrefs] is consulted on each update to produce the correct formatted string. */ 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, - private val valueDepth: TextView, - private val valuePolarPct: TextView, - private val valueBaro: TextView, - private val labelTrend: TextView?, - private val barometerTrendView: BarometerTrendView?, - private val polarDiagramView: PolarDiagramView + // ── Conditions header ──────────────────────────────────────────────── + private val valueTws: TextView, + private val arrowTws: DirectionArrowView, + private val bearingTws: TextView, + private val valueTemp: TextView, + private val valueBaro: TextView, + // ── 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, + // ── Unit preferences ───────────────────────────────────────────────── + private val unitPrefs: UnitPrefs ) { - /** - * Updates the text displays for various instruments. - */ - 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?.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 } + init { + arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN + arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN + arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN + valueTws.text = "—" + valueTemp.text = "—" + valueBaro.text = "—" + valueCurrSpd.text = "—" + valueWaveHt.text = "—" + valueSwellHt.text = "—" } /** - * Updates the polar diagram view. + * Updates all area-conditions fields. Null arguments leave the current value unchanged. + * + * @param twsKt True wind speed in knots + * @param tempC Air temperature in degrees Celsius + * @param baroHpa Barometric pressure in hPa + * @param currSpdKt Ocean current speed in knots + * @param waveHeightM Wind-wave height in metres + * @param swellHeightM Swell height in metres */ - fun updatePolarDiagram(tws: Double, twa: Double, bsp: Double) { - polarDiagramView.setCurrentPerformance(tws, twa, bsp) + fun updateConditions( + twsKt: Double? = null, twsBearingDeg: Float? = null, + tempC: Double? = null, + baroHpa: Float? = null, + currSpdKt: Double? = null, currDirDeg: Float? = null, + waveHeightM: Double? = null, waveDirDeg: Float? = null, + swellHeightM: Double? = null, swellDirDeg: Float? = null, + swellPeriodS: Double? = null + ) { + twsKt?.let { valueTws.text = unitPrefs.formatSpeed(it) } + tempC?.let { valueTemp.text = unitPrefs.formatTemp(it) } + baroHpa?.let { valueBaro.text = unitPrefs.formatPressure(it) } + twsBearingDeg?.let { + arrowTws.bearing = it + bearingTws.text = formatBearing(it.toDouble()) + } + + currSpdKt?.let { valueCurrSpd.text = unitPrefs.formatSpeed(it) } + currDirDeg?.let { + arrowCurr.bearing = it + bearingCurr.text = formatBearing(it.toDouble()) + } + waveHeightM?.let { valueWaveHt.text = unitPrefs.formatDepth(it) } + waveDirDeg?.let { + arrowWaves.bearing = it + bearingWaves.text = formatBearing(it.toDouble()) + } + swellHeightM?.let { valueSwellHt.text = unitPrefs.formatDepth(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. + * Heights are passed in metres; the WaveView scale is computed internally. + * [windSpeedKt] gates whitecap rendering (Beaufort 4 threshold = 12 kt). */ - fun updateBarometerTrend(history: List<BarometerReading>) { - barometerTrendView?.setHistory(history) + fun updateWaveState( + swellHeightM: Float, + swellPeriodSec: Float, + windWaveHeightM: Float, + windSpeedKt: Float = 0f + ) { + val swellHtFt = swellHeightM * 3.28084f + val windWaveHtFt = windWaveHeightM * 3.28084f + + waveView.swellHeightFt = swellHtFt + waveView.swellPeriodSec = swellPeriodSec + waveView.windWaveHeightFt = windWaveHtFt + waveView.windSpeedKt = windSpeedKt + + // Size the view to the swell — bigger swell = taller window + val density = waveView.resources.displayMetrics.density + val heightDp = (32f + swellHtFt * 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..78fd70f --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt @@ -0,0 +1,119 @@ +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.DepthUnit +import org.terst.nav.PressureUnit +import org.terst.nav.R +import org.terst.nav.SpeedUnit +import org.terst.nav.TempUnit +import org.terst.nav.UnitPrefs + +class LayerPickerSheet( + private val manager: MapLayerManager, + private val onBaseChanged: (MapBasePreset) -> Unit, + private val onWindChanged: (Boolean) -> Unit, + private val onParticlesChanged: (Boolean) -> Unit, + private val unitPrefs: UnitPrefs, + private val onUnitsChanged: () -> 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?) { + // ── Base layer chips ────────────────────────────────────────────────── + val chipGroup = view.findViewById<ChipGroup>(R.id.chip_group_base) + chipGroup.check(when (manager.basePreset) { + MapBasePreset.SATELLITE -> R.id.chip_satellite + MapBasePreset.CHARTS -> R.id.chip_charts + MapBasePreset.HYBRID -> R.id.chip_hybrid + }) + 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) + } + + // ── Wind overlay switch ─────────────────────────────────────────────── + val windSwitch = view.findViewById<SwitchMaterial>(R.id.switch_wind) + windSwitch.isChecked = manager.windEnabled + windSwitch.setOnCheckedChangeListener { _, checked -> onWindChanged(checked) } + + // ── Particles switch ────────────────────────────────────────────────── + val particlesSwitch = view.findViewById<SwitchMaterial>(R.id.switch_particles) + particlesSwitch.isChecked = manager.particlesEnabled + particlesSwitch.setOnCheckedChangeListener { _, checked -> onParticlesChanged(checked) } + + // ── Temperature chips ───────────────────────────────────────────────── + val chipTemp = view.findViewById<ChipGroup>(R.id.chip_group_temp) + chipTemp.check(when (unitPrefs.tempUnit) { + TempUnit.FAHRENHEIT -> R.id.chip_fahrenheit + TempUnit.CELSIUS -> R.id.chip_celsius + }) + chipTemp.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.tempUnit = when (ids.firstOrNull()) { + R.id.chip_fahrenheit -> TempUnit.FAHRENHEIT + R.id.chip_celsius -> TempUnit.CELSIUS + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Depth chips ─────────────────────────────────────────────────────── + val chipDepth = view.findViewById<ChipGroup>(R.id.chip_group_depth) + chipDepth.check(when (unitPrefs.depthUnit) { + DepthUnit.FEET -> R.id.chip_feet + DepthUnit.METERS -> R.id.chip_meters + }) + chipDepth.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.depthUnit = when (ids.firstOrNull()) { + R.id.chip_feet -> DepthUnit.FEET + R.id.chip_meters -> DepthUnit.METERS + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Pressure chips ──────────────────────────────────────────────────── + val chipPressure = view.findViewById<ChipGroup>(R.id.chip_group_pressure) + chipPressure.check(when (unitPrefs.pressureUnit) { + PressureUnit.HPA -> R.id.chip_hpa + PressureUnit.INHG -> R.id.chip_inhg + }) + chipPressure.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.pressureUnit = when (ids.firstOrNull()) { + R.id.chip_hpa -> PressureUnit.HPA + R.id.chip_inhg -> PressureUnit.INHG + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Speed chips ─────────────────────────────────────────────────────── + val chipSpeed = view.findViewById<ChipGroup>(R.id.chip_group_speed) + chipSpeed.check(when (unitPrefs.speedUnit) { + SpeedUnit.KNOTS -> R.id.chip_knots + SpeedUnit.MPH -> R.id.chip_mph + SpeedUnit.KPH -> R.id.chip_kph + }) + chipSpeed.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.speedUnit = when (ids.firstOrNull()) { + R.id.chip_knots -> SpeedUnit.KNOTS + R.id.chip_mph -> SpeedUnit.MPH + R.id.chip_kph -> SpeedUnit.KPH + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + } +} 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 b457316..f05da21 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 @@ -21,17 +21,25 @@ import org.terst.nav.vessel.CrewMember import org.terst.nav.vessel.Vessel import org.terst.nav.vessel.VesselRepository 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.Job 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() @@ -56,6 +64,17 @@ 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 _conditionsLoading = MutableStateFlow(false) + val conditionsLoading: StateFlow<Boolean> = _conditionsLoading.asStateFlow() + + private val _conditionsError = MutableSharedFlow<String>(replay = 0) + val conditionsError: SharedFlow<String> = _conditionsError.asSharedFlow() + + private var conditionsJob: Job? = null + private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList()) val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow() @@ -69,7 +88,7 @@ class MainViewModel( private val aisRepository = AisRepository() - private val trackRepository = TrackRepository() + private val trackRepository = org.terst.nav.NavApplication.trackRepository val vesselRepository = VesselRepository() @@ -93,24 +112,86 @@ class MainViewModel( private val _trackStats = MutableStateFlow<TrackStats?>(null) val trackStats: StateFlow<TrackStats?> = _trackStats.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 - _trackStats.value = null + viewModelScope.launch { + val summary = trackRepository.stopTrack() + _pastTracks.value = trackRepository.getPastTracks() + _trackPoints.value = emptyList() + _isRecording.value = false + _trackStats.value = null + summary?.let { _trackSummary.emit(it) } + } + } + + /** Dev shortcut: saves a synthetic 30-min Honokohau circuit without requiring GPS movement. */ + fun injectTestTrack() { + viewModelScope.launch { + val startMs = System.currentTimeMillis() - 30 * 60_000L + val points = buildTestTrack(startMs) + trackRepository.startTrack() + points.forEach { trackRepository.addPoint(it) } + val summary = trackRepository.stopTrack() + _pastTracks.value = trackRepository.getPastTracks() + _trackPoints.value = emptyList() + _isRecording.value = false + _trackStats.value = null + summary?.let { _trackSummary.emit(it) } + } + } + + private fun buildTestTrack(startMs: Long): List<TrackPoint> { + // Honokohau Harbor, Kona, HI: 19.669°N, 156.027°W + // Heads west (~270°) for first half, returns east (~90°) for second half + val baseLat = 19.669 + val baseLon = -156.027 + val nmPerDegreeLon = 60.0 * kotlin.math.cos(kotlin.math.PI / 180.0 * baseLat) + val sogKt = 4.5 + val count = 16 + return (0 until count).map { i -> + val tMs = startMs + i * 2 * 60_000L + val outbound = i < count / 2 + val legIndex = if (outbound) i else (count - 1 - i) + val distNm = sogKt * (legIndex * 2.0 / 60.0) + val lonOffset = distNm / nmPerDegreeLon + val lat = baseLat + if (!outbound) 0.003 else 0.0 // slight northing on return + val lon = baseLon - lonOffset + val cogDeg = if (outbound) 270.0 else 90.0 + val sog = sogKt + (i % 3 - 1) * 0.4 // mild variation + TrackPoint( + lat = lat, lon = lon, + sogKnots = sog, cogDeg = cogDeg, + waveHeightM = 0.6, + timestampMs = tMs + ) + } } fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg + 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)) { @@ -152,9 +233,55 @@ class MainViewModel( .create(AisHubApiService::class.java) } + private var lastConditionsLat = Double.NaN + private var lastConditionsLon = Double.NaN + private var lastConditionsMs = 0L + + /** + * Fetches current conditions snapshot for [lat]/[lon] and exposes it via [marineConditions]. + * Throttled: skips if conditions loaded < 5 min ago and position < 2 nm from last load. + */ + fun loadConditions(lat: Double, lon: Double) { + val nowMs = System.currentTimeMillis() + val ageMs = nowMs - lastConditionsMs + val distNm = if (lastConditionsLat.isNaN()) Double.MAX_VALUE + else distanceNm(lastConditionsLat, lastConditionsLon, lat, lon) + if (ageMs < 5 * 60_000L && distNm < 2.0) { + NavLogger.i("conditions", "skip age=${ageMs / 1000}s dist=%.1fnm".format(distNm)) + return + } + lastConditionsLat = lat + lastConditionsLon = lon + lastConditionsMs = nowMs + + conditionsJob?.cancel() + conditionsJob = viewModelScope.launch { + _conditionsLoading.value = true + try { + repository.fetchCurrentConditions(lat, lon) + .onSuccess { _marineConditions.value = it } + .onFailure { + val msg = "${it.javaClass.simpleName}: ${it.message}" + android.util.Log.e("Nav", "fetchCurrentConditions failed: $msg", it) + _conditionsError.emit(msg) + } + } finally { + _conditionsLoading.value = false + } + } + } + + private fun distanceNm(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) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + return 3440.065 * 2 * kotlin.math.asin(kotlin.math.sqrt(a)) + } + /** - * Fetch weather and marine data for [lat]/[lon] in parallel. - * Called once the device location is known. + * Fetch weather and wind arrow for [lat]/[lon]. Called once on first GPS fix. */ fun loadWeather(lat: Double, lon: Double) { viewModelScope.launch { @@ -211,7 +338,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) { if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return @@ -238,7 +364,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..a7d746f 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,42 @@ 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 kotlin.math.sqrt +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 +66,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_SOURCE_ID = "wind-arrows-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 +131,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 +172,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 +193,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 +214,131 @@ 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) { + val prevLat = lastLat + val prevLon = lastLon + lastLat = lat + lastLon = lon + if (!_isFollowing.value) return + + val position = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom) + .build() + + // Skip animation for tiny GPS jitter (< 3 m) to avoid repeated symbol + // collision recalculations that cause labels to flash on/off. + val deltaMeters = approximateMeters(prevLat, prevLon, lat, lon) + if (deltaMeters < 3.0) { + maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + } else { + maplibreMap.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400) + } + } + + /** Fast flat-earth distance approximation (accurate to ~0.1% within a few km). */ + private fun approximateMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = (lat2 - lat1) * 111_320.0 + val dLon = (lon2 - lon1) * 111_320.0 * cos(Math.toRadians((lat1 + lat2) / 2.0)) + return sqrt(dLat * dLat + dLon * dLon) + } + + /** + * 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())) + } + } + + /** + * 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())) } - windSource?.setGeoJson(FeatureCollection.fromFeature(feature)) } /** - * Updates the GPS track polyline on the map. Lazily initialises the layer on first call. + * Updates the GPS track polyline on the map. Lazily initialises the layers on first call. */ - 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 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(3f) + PropertyFactory.lineWidth(4f), + 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 (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.lineDasharray(arrayOf(1f, 2f)), + PropertyFactory.lineCap("round") + ) + }) + } + + 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 +353,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..855233f --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt @@ -0,0 +1,142 @@ +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 + + var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, 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" + private const val KEY_PARTICLES = "particles_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.85f)) + 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 particles (Canvas overlay — no style interaction needed). */ + fun setParticlesEnabled(enabled: Boolean) { + particlesEnabled = enabled + prefs.edit().putBoolean(KEY_PARTICLES, enabled).apply() + } + + /** 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.CHARTS +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt new file mode 100644 index 0000000..c638113 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt @@ -0,0 +1,42 @@ +package org.terst.nav.ui + +import java.util.Calendar + +/** + * Lightweight in-memory log ring-buffer. + * + * Format: `HH:mm:ss.SSS LEVEL tag: message` + * + * Designed so both humans and Claude can read the output at a glance. + * Entries are captured from API calls, GPS fixes, and key state changes. + * Retrieve with [getLog] and copy to clipboard via DevLogSheet (long-press + * the drag handle on the instrument sheet). + */ +object NavLogger { + + private const val MAX_ENTRIES = 200 + private val entries = ArrayDeque<String>() + + fun i(tag: String, msg: String) = append("INFO ", tag, msg) + fun w(tag: String, msg: String) = append("WARN ", tag, msg) + fun e(tag: String, msg: String) = append("ERROR", tag, msg) + + private fun append(level: String, tag: String, msg: String) { + val cal = Calendar.getInstance() + val time = "%02d:%02d:%02d.%03d".format( + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + cal.get(Calendar.MILLISECOND) + ) + val entry = "$time $level $tag: $msg" + synchronized(entries) { + entries.addLast(entry) + while (entries.size > MAX_ENTRIES) entries.removeFirst() + } + } + + fun getLog(): String = synchronized(entries) { entries.joinToString("\n") } + + fun clear() = synchronized(entries) { entries.clear() } +} 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..ba7dee8 --- /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.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 org.terst.nav.R +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 = context.getColor(R.color.wave_shimmer) + } + + private val whitecapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1.5f + strokeCap = Paint.Cap.ROUND + color = context.getColor(R.color.wave_whitecap) + } + + 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, + context.getColor(R.color.wave_sky_top), + context.getColor(R.color.wave_sky_bottom), + Shader.TileMode.CLAMP + ) + seaPaint.shader = LinearGradient( + 0f, h * 0.4f, 0f, h.toFloat(), + context.getColor(R.color.wave_sea_top), + context.getColor(R.color.wave_sea_bottom), + 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/learn/LearnFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt new file mode 100644 index 0000000..418246c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt @@ -0,0 +1,45 @@ +package org.terst.nav.ui.learn + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.card.MaterialCardView +import org.terst.nav.R +import org.terst.nav.ui.doc.DocFragment + +class LearnFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_learn, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Migration guides — open local markdown docs + view.findViewById<MaterialCardView>(R.id.card_migrate_navionics).setOnClickListener { + openDoc("docs/migrate_navionics.md") + } + view.findViewById<MaterialCardView>(R.id.card_migrate_seapeople).setOnClickListener { + openDoc("docs/migrate_sea_people.md") + } + + // Reference — offline docs + view.findViewById<MaterialCardView>(R.id.card_colregs).setOnClickListener { + openDoc("docs/colregs_reference.md") + } + view.findViewById<MaterialCardView>(R.id.card_sailing_reference).setOnClickListener { + openDoc("docs/sailing_reference.md") + } + + } + + private fun openDoc(path: String) { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, DocFragment.newInstance(path)) + .addToBackStack(null) + .commit() + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt index a5a0b8b..26d9ea2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt @@ -8,6 +8,7 @@ import android.util.AttributeSet import android.view.View import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap +import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin import kotlin.random.Random @@ -22,6 +23,9 @@ import kotlin.random.Random * Speed is scaled to the visible viewport so the animation looks consistent at any * zoom level: a particle at reference wind (10 kt) crosses ~30% of the screen in * MAX_AGE seconds. + * + * Longitude arithmetic is done in a west-relative [0, lonSpan] coordinate space + * so that viewports crossing the antimeridian (±180°) work correctly. */ class ParticleWindView @JvmOverloads constructor( context: Context, @@ -35,13 +39,15 @@ class ParticleWindView @JvmOverloads constructor( private var windSpeedKt = 0.0 private val N = 300 + private var activeN = 150 // wind-speed-dependent; updated in setWind() private val particleLat = FloatArray(N) private val particleLon = FloatArray(N) private val particleAge = FloatArray(N) - private val MAX_AGE = 8f // seconds before forced respawn + private val MAX_AGE = 25f // seconds before forced respawn + private var lastLatRange = 0f // for zoom-out scatter detection private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - strokeWidth = 2.5f + strokeWidth = 3.5f strokeCap = Paint.Cap.ROUND style = Paint.Style.STROKE } @@ -59,6 +65,8 @@ class ParticleWindView @JvmOverloads constructor( fun setWind(dirFromDeg: Double, speedKt: Double) { windDirFromDeg = dirFromDeg windSpeedKt = speedKt + // Scale particle count: 60 at calm → 300 at 25+ kt + activeN = (60 + (speedKt.coerceIn(0.0, 25.0) / 25.0 * 240)).toInt() } // ── Rendering ──────────────────────────────────────────────────────────── @@ -73,13 +81,27 @@ class ParticleWindView @JvmOverloads constructor( else ((now - lastFrameNs) / 1_000_000_000f).coerceAtMost(0.05f) lastFrameNs = now - val bounds = m.projection.visibleRegion.latLngBounds - val latSouth = bounds.southWest.latitude.toFloat() - val latNorth = bounds.northEast.latitude.toFloat() - val lonWest = bounds.southWest.longitude.toFloat() - val lonEast = bounds.northEast.longitude.toFloat() + val r = m.projection.visibleRegion + val nL = r.nearLeft ?: run { postInvalidateOnAnimation(); return } + val nR = r.nearRight ?: run { postInvalidateOnAnimation(); return } + val fL = r.farLeft ?: run { postInvalidateOnAnimation(); return } + val fR = r.farRight ?: run { postInvalidateOnAnimation(); return } + + val latSouth = minOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat() + val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat() val latRange = latNorth - latSouth - val lonRange = lonEast - lonWest + + // If the user zoomed out significantly, immediately redistribute particles + // across the new viewport rather than waiting for them to drift to the edges. + if (lastLatRange > 0f && latRange > lastLatRange * 1.8f) scatter(m) + lastLatRange = latRange + + // West edge = left screen side; east edge = right screen side. + // Using screen-ordered corners handles antimeridian crossing correctly. + val lonWest = minOf(nL.longitude, fL.longitude).toFloat() + val lonEast = maxOf(nR.longitude, fR.longitude).toFloat() + // Span wraps around antimeridian when lonEast < lonWest (e.g. Pacific viewport) + val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f // Speed scale: at 10kt a particle crosses 30% of viewport in MAX_AGE seconds. val speedScale = latRange * 0.03f / MAX_AGE @@ -97,19 +119,27 @@ class ParticleWindView @JvmOverloads constructor( val tailDx = sin(screenRad).toFloat() * TAIL_PX val tailDy = (-cos(screenRad)).toFloat() * TAIL_PX - for (i in 0 until N) { + for (i in 0 until activeN) { particleLat[i] += dlat particleLon[i] += dlon + // Wrap longitude into [-180, 180] after movement + if (particleLon[i] > 180f) particleLon[i] -= 360f + else if (particleLon[i] < -180f) particleLon[i] += 360f particleAge[i] += dt + // Normalize particle longitude relative to lonWest into [0, 360) + // so the antimeridian-spanning bounds check works correctly. + val normLon = ((particleLon[i] - lonWest + 360f) % 360f) val needsRespawn = particleAge[i] > MAX_AGE || particleLat[i] < latSouth || particleLat[i] > latNorth - || particleLon[i] < lonWest || particleLon[i] > lonEast + || normLon > lonSpan if (needsRespawn) { particleLat[i] = latSouth + Random.nextFloat() * latRange - particleLon[i] = lonWest + Random.nextFloat() * lonRange - particleAge[i] = 0f + var newLon = lonWest + Random.nextFloat() * lonSpan + if (newLon > 180f) newLon -= 360f + particleLon[i] = newLon + particleAge[i] = Random.nextFloat() * MAX_AGE continue } @@ -117,8 +147,10 @@ class ParticleWindView @JvmOverloads constructor( LatLng(particleLat[i].toDouble(), particleLon[i].toDouble()) ) - val alpha = ((1f - particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(30, 200) - paint.color = Color.argb(alpha, 120, 200, 255) + // Sine curve: smooth fade-in from birth, peak at mid-life, smooth fade-out. + // No bright birth flash — particles ease in and ease out. + val alpha = (sin(PI * particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(15, 200) + paint.color = Color.argb(alpha, 30, 100, 255) canvas.drawLine(pt.x - tailDx, pt.y - tailDy, pt.x, pt.y, paint) } @@ -137,14 +169,24 @@ class ParticleWindView @JvmOverloads constructor( // ── Helpers ────────────────────────────────────────────────────────────── private fun scatter(m: MapLibreMap) { - val bounds = m.projection.visibleRegion.latLngBounds - val latSouth = bounds.southWest.latitude.toFloat() - val lonWest = bounds.southWest.longitude.toFloat() - val latRange = (bounds.northEast.latitude - bounds.southWest.latitude).toFloat() - val lonRange = (bounds.northEast.longitude - bounds.southWest.longitude).toFloat() - for (i in 0 until N) { + val r = m.projection.visibleRegion + val nL = r.nearLeft ?: return + val nR = r.nearRight ?: return + val fL = r.farLeft ?: return + val fR = r.farRight ?: return + + val latSouth = minOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat() + val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat() + val latRange = latNorth - latSouth + val lonWest = minOf(nL.longitude, fL.longitude).toFloat() + val lonEast = maxOf(nR.longitude, fR.longitude).toFloat() + val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f + + for (i in 0 until activeN) { particleLat[i] = latSouth + Random.nextFloat() * latRange - particleLon[i] = lonWest + Random.nextFloat() * lonRange + var newLon = lonWest + Random.nextFloat() * lonSpan + if (newLon > 180f) newLon -= 360f + particleLon[i] = newLon particleAge[i] = Random.nextFloat() * MAX_AGE // stagger so no mass respawn } scattered = true 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 a6c038c..d906094 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 @@ -23,6 +23,7 @@ class SafetyFragment : Fragment() { fun onActivateMob() fun onConfigureAnchor() fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) + fun onQuitRequested() } private var listener: SafetyListener? = null @@ -70,6 +71,17 @@ class SafetyFragment : Fragment() { flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, checked) listener?.onHardwareSourceChanged(HardwareSource.NMEA_INSTRUMENTS, checked) } + + view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment()) + .addToBackStack(null) + .commit() + } + + view.findViewById<MaterialButton>(R.id.button_quit).setOnClickListener { + listener?.onQuitRequested() + } } 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..ab30d96 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 @@ -3,6 +3,8 @@ package org.terst.nav.ui.voicelog import android.Manifest import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri import android.os.Bundle import android.speech.RecognitionListener import android.speech.RecognizerIntent @@ -10,34 +12,74 @@ import android.speech.SpeechRecognizer import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts 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 com.google.android.material.textfield.TextInputEditText import kotlinx.coroutines.launch import org.terst.nav.R -import org.terst.nav.logbook.InMemoryLogbookRepository import org.terst.nav.logbook.VoiceLogState import org.terst.nav.logbook.VoiceLogViewModel +import java.io.File +import java.io.FileOutputStream import java.util.Locale 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 - private lateinit var tvRecognized: TextView + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var etNote: TextInputEditText private lateinit var fabMic: FloatingActionButton - private lateinit var llConfirm: LinearLayout - private lateinit var btnSave: Button - private lateinit var btnRetry: Button + private lateinit var btnCamera: MaterialButton + private lateinit var btnGallery: MaterialButton + private lateinit var tvStatus: TextView + private lateinit var framePhoto: FrameLayout + private lateinit var ivPhoto: ImageView + private lateinit var btnRemovePhoto: MaterialButton + private lateinit var btnSave: MaterialButton + private lateinit var btnClear: MaterialButton private lateinit var tvSavedConfirmation: TextView + private lateinit var btnGenerateReport: MaterialButton + + /** Path or URI string for the currently attached photo, null if none. */ + private var pendingPhotoPath: String? = null + + // ── Camera (TakePicturePreview returns a thumbnail bitmap — no FileProvider needed) ── + + private val cameraLauncher = registerForActivityResult( + ActivityResultContracts.TakePicturePreview() + ) { bitmap: Bitmap? -> + if (bitmap != null) { + val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg") + try { + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) } + setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) } + } catch (_: Exception) { + tvStatus.text = "Couldn't save photo" + } + } + } + + // ── Gallery (GetContent — no storage permission needed; URI valid for this session) ── + + private val galleryLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) } + } + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── override fun onCreateView( inflater: LayoutInflater, @@ -48,37 +90,59 @@ class VoiceLogFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - tvStatus = view.findViewById(R.id.tv_status) - tvRecognized = view.findViewById(R.id.tv_recognized) - fabMic = view.findViewById(R.id.fab_mic) - llConfirm = view.findViewById(R.id.ll_confirm_buttons) - btnSave = view.findViewById(R.id.btn_save) - btnRetry = view.findViewById(R.id.btn_retry) + etNote = view.findViewById(R.id.et_note) + fabMic = view.findViewById(R.id.fab_mic) + btnCamera = view.findViewById(R.id.btn_camera) + btnGallery = view.findViewById(R.id.btn_gallery) + tvStatus = view.findViewById(R.id.tv_status) + framePhoto = view.findViewById(R.id.frame_photo) + ivPhoto = view.findViewById(R.id.iv_photo) + btnRemovePhoto = view.findViewById(R.id.btn_remove_photo) + btnSave = view.findViewById(R.id.btn_save) + btnClear = view.findViewById(R.id.btn_clear) 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() } + btnCamera.setOnClickListener { cameraLauncher.launch(null) } + btnGallery.setOnClickListener { galleryLauncher.launch("image/*") } + btnRemovePhoto.setOnClickListener { clearPhoto() } + + btnSave.setOnClickListener { + val text = etNote.text?.toString()?.trim() ?: "" + viewModel.save(text, pendingPhotoPath) + } + + btnClear.setOnClickListener { clearEntry() } + + 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) } } } + // ── Speech ──────────────────────────────────────────────────────────────── + private fun setupSpeechRecognizer() { if (!SpeechRecognizer.isRecognitionAvailable(requireContext())) { - tvStatus.text = "Speech recognition not available" fabMic.isEnabled = false + tvStatus.text = "Speech recognition unavailable" return } speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext()) speechRecognizer.setRecognitionListener(object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() } override fun onResults(results: Bundle?) { - val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) - val text = matches?.firstOrNull() ?: "" + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() ?: "" if (text.isNotBlank()) viewModel.onSpeechRecognized(text) else viewModel.onRecognitionError("Could not understand speech") } @@ -86,11 +150,11 @@ class VoiceLogFragment : Fragment() { viewModel.onRecognitionError("Recognition error: $error") } override fun onBeginningOfSpeech() {} - override fun onBufferReceived(buffer: ByteArray?) {} + override fun onBufferReceived(b: ByteArray?) {} override fun onEndOfSpeech() {} - override fun onEvent(eventType: Int, params: Bundle?) {} - override fun onPartialResults(partialResults: Bundle?) {} - override fun onRmsChanged(rmsdB: Float) {} + override fun onEvent(t: Int, p: Bundle?) {} + override fun onPartialResults(p: Bundle?) {} + override fun onRmsChanged(r: Float) {} }) } @@ -110,38 +174,57 @@ class VoiceLogFragment : Fragment() { speechRecognizer.startListening(intent) } + // ── Photo helpers ───────────────────────────────────────────────────────── + + private fun setPhoto(path: String, applyImage: () -> Unit) { + pendingPhotoPath = path + applyImage() + framePhoto.visibility = View.VISIBLE + } + + private fun clearPhoto() { + pendingPhotoPath = null + ivPhoto.setImageDrawable(null) + framePhoto.visibility = View.GONE + } + + private fun clearEntry() { + etNote.setText("") + clearPhoto() + viewModel.retry() + tvSavedConfirmation.text = "" + tvStatus.text = "" + } + + // ── State rendering ─────────────────────────────────────────────────────── + private fun renderState(state: VoiceLogState) { when (state) { is VoiceLogState.Idle -> { - tvStatus.text = "Tap microphone to log" - tvRecognized.text = "" - llConfirm.visibility = View.GONE - tvSavedConfirmation.text = "" + tvStatus.text = "" fabMic.isEnabled = true } is VoiceLogState.Listening -> { tvStatus.text = "Listening…" - tvRecognized.text = "" - llConfirm.visibility = View.GONE fabMic.isEnabled = false } is VoiceLogState.Result -> { - tvStatus.text = "Recognized:" - tvRecognized.text = state.recognized - llConfirm.visibility = View.VISIBLE - fabMic.isEnabled = false + // Fill the text field; user can edit before saving + etNote.setText(state.recognized) + etNote.setSelection(state.recognized.length) + tvStatus.text = "" + fabMic.isEnabled = true } is VoiceLogState.Saved -> { - tvStatus.text = "Saved!" - tvRecognized.text = state.entry.text - tvSavedConfirmation.text = "[${state.entry.entryType}] entry saved" - llConfirm.visibility = View.GONE + val photoNote = if (state.entry.photoPath != null) " + photo" else "" + tvSavedConfirmation.text = "Saved: ${state.entry.text.take(60)}$photoNote" + etNote.setText("") + clearPhoto() fabMic.isEnabled = true + tvStatus.text = "" } is VoiceLogState.Error -> { - tvStatus.text = "Error: ${state.message}" - tvRecognized.text = "" - llConfirm.visibility = View.GONE + tvStatus.text = state.message fabMic.isEnabled = true } } @@ -153,7 +236,9 @@ class VoiceLogFragment : Fragment() { permissions: Array<out String>, grantResults: IntArray ) { - if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + if (requestCode == RC_AUDIO + && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED + ) { startListening() } } 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_calendar.xml b/android-app/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 0000000..dd8030c --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <!-- Calendar outline --> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M19,4H5C3.895,4 3,4.895 3,6v14c0,1.105 0.895,2 2,2h14c1.105,0 2,-0.895 2,-2V6C21,4.895 20.105,4 19,4z" /> + <!-- Header bar --> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:pathData="M3,10h18" /> + <!-- Hanger lines --> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:strokeLineCap="round" + android:pathData="M8,2v4M16,2v4" /> + <!-- Day dots (3 representative) --> + <path + android:fillColor="#FF000000" + android:pathData="M8,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" /> + <path + android:fillColor="#FF000000" + android:pathData="M12,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" /> + <path + android:fillColor="#FF000000" + android:pathData="M16,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" /> + <path + android:fillColor="#FF000000" + android:pathData="M8,18m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" /> + <path + android:fillColor="#FF000000" + android:pathData="M12,18m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" /> +</vector> diff --git a/android-app/app/src/main/res/drawable/ic_crosshair.xml b/android-app/app/src/main/res/drawable/ic_crosshair.xml new file mode 100644 index 0000000..609538e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_crosshair.xml @@ -0,0 +1,28 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="32" + android:viewportHeight="32"> + + <!-- Horizontal line --> + <path + android:pathData="M2,16 L13,16 M19,16 L30,16" + android:strokeColor="#CCFFFFFF" + android:strokeWidth="1.5" + android:strokeLineCap="round" /> + + <!-- Vertical line --> + <path + android:pathData="M16,2 L16,13 M16,19 L16,30" + android:strokeColor="#CCFFFFFF" + android:strokeWidth="1.5" + android:strokeLineCap="round" /> + + <!-- Center circle --> + <path + android:pathData="M16,16 m-2.5,0 a2.5,2.5 0 1,0 5,0 a2.5,2.5 0 1,0 -5,0" + android:strokeColor="#CCFFFFFF" + android:strokeWidth="1.5" + android:fillColor="#00000000" /> + +</vector> 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/drawable/ic_learn.xml b/android-app/app/src/main/res/drawable/ic_learn.xml new file mode 100644 index 0000000..1574693 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_learn.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="@android:color/white" + android:pathData="M12,3L1,9l11,6 9-4.91V17h2V9L12,3zM5,13.18v4L12,21l7-3.82v-4L12,17l-7-3.82z"/> +</vector> diff --git a/android-app/app/src/main/res/drawable/ic_open_in_new.xml b/android-app/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..4645522 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="18dp" + android:height="18dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <!-- Box outline with open top-right corner --> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M18,13v6a2,2 0,0 1,-2,2H5a2,2 0,0 1,-2,-2V8a2,2 0,0 1,2,-2h6" /> + <!-- Arrow pointing top-right --> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M15,3h6v6" /> + <path + android:fillColor="@android:color/transparent" + android:strokeColor="#FF000000" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M10,14L21,3" /> +</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 a99a8cb..4150b2e 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -1,36 +1,103 @@ <?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" /> + + <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" /> + + <!-- Boat-instrument HUD strip — always on top of map --> + <include + android:id="@+id/nav_hud" + layout="@layout/layout_nav_hud" + android:layout_width="match_parent" + android:layout_height="48dp" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + + <!-- Crosshair — visible when not following (panning) --> + <ImageView + android:id="@+id/map_crosshair" + android:layout_width="32dp" + android:layout_height="32dp" + android:src="@drawable/ic_crosshair" + android:visibility="gone" + android:clickable="false" + android:focusable="false" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="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:clickable="true" + android:focusable="true" + android:background="?attr/colorSurface" /> - <!-- Overlay Fragment Container (for Log, Safety, Help) --> - <FrameLayout - android:id="@+id/fragment_container" + <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_constraintTop_toBottomOf="@id/nav_hud" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + android:layout_marginTop="8dp" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + <!-- Collapsible Instrument Bottom Sheet --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/instrument_bottom_sheet" android:layout_width="match_parent" - android:layout_height="match_parent" - android:visibility="gone" - android:background="?attr/colorSurface" /> + android:layout_height="wrap_content" + app:behavior_hideable="false" + app:behavior_peekHeight="120dp" + app:cardElevation="16dp" + app:shapeAppearance="@style/ShapeAppearance.Nav.BottomSheet" + app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> + + <include + layout="@layout/layout_instruments_sheet" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + + </com.google.android.material.card.MaterialCardView> <!-- Track stats bar — shown only while recording --> <TextView @@ -51,60 +118,28 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> - </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" - 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" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> + <!-- 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.cardview.widget.CardView> + </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_learn.xml b/android-app/app/src/main/res/layout/fragment_learn.xml new file mode 100644 index 0000000..fa18c5d --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_learn.xml @@ -0,0 +1,205 @@ +<?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="Learn" + android:textSize="24sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" + android:layout_marginBottom="24dp" /> + + <!-- ── Migration Guides ─────────────────────────────────────── --> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="MIGRATION GUIDES" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_migrate_navionics" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + app:cardCornerRadius="12dp" + app:cardElevation="2dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Migrating from Navionics" + android:textAppearance="?attr/textAppearanceTitleSmall" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_migrate_seapeople" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="24dp" + app:cardCornerRadius="12dp" + app:cardElevation="2dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Migrating from Sea People" + android:textAppearance="?attr/textAppearanceTitleSmall" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Reference (offline) ──────────────────────────────────── --> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="REFERENCE" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_colregs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + app:cardCornerRadius="12dp" + app:cardElevation="2dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + 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="ColRegs — Rules of the Road" + android:textAppearance="?attr/textAppearanceTitleSmall" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="International collision regulations, rules 1–38" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginTop="2dp" /> + + </LinearLayout> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_sailing_reference" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + app:cardCornerRadius="12dp" + app:cardElevation="2dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + 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="Sailing Quick Reference" + android:textAppearance="?attr/textAppearanceTitleSmall" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Points of sail, lights & shapes, knots" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginTop="2dp" /> + + </LinearLayout> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + </LinearLayout> +</ScrollView> 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..8cb094a --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml @@ -0,0 +1,324 @@ +<?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="16dp"> + + <!-- ── Title ──────────────────────────────────────────────── --> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Departure Briefing" + android:textAppearance="?attr/textAppearanceHeadlineSmall" + android:layout_marginBottom="16dp" /> + + <!-- ── Boat selector ──────────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <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" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_boats" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:singleSelection="true" + app:selectionRequired="true" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Departure time picker ──────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_departure" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp" + 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="DEPART" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="4dp" /> + + <TextView + android:id="@+id/tv_departure_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Now" + android:textAppearance="?attr/textAppearanceTitleMedium" /> + + </LinearLayout> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_pick_departure" + style="@style/Widget.Material3.Button.IconButton.Outlined" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="Pick departure date and time" + app:icon="@drawable/ic_calendar" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Generate button + progress ─────────────────────────── --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="16dp"> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_generate_pretrip" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Refresh Briefing" /> + + <ProgressBar + android:id="@+id/progress_pretrip" + android:layout_width="32dp" + android:layout_height="32dp" + android:layout_marginStart="12dp" + android:visibility="gone" /> + </LinearLayout> + + <!-- ── Conditions window ───────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_conditions" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <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="CONDITIONS WINDOW" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="12dp" /> + + <!-- 4-column table built programmatically --> + <LinearLayout + android:id="@+id/conditions_table" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Route projection ────────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_route" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <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="ROUTE PROJECTION" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <TextView + android:id="@+id/tv_route" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.3" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Sail plan ───────────────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_sail_plan" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <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="SAIL PLAN" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <TextView + android:id="@+id/tv_sail_plan" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.4" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Watch list ──────────────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_watchlist" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp" + app:strokeColor="?attr/colorError" + app:strokeWidth="1dp"> + + <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="WATCH FOR" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <TextView + android:id="@+id/tv_watchlist" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.5" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Similar trips ───────────────────────────────────────── --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_similar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + <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="SIMILAR TRIPS" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="8dp" /> + + <TextView + android:id="@+id/tv_similar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.3" /> + + </LinearLayout> + </com.google.android.material.card.MaterialCardView> + + <!-- ── Error state ─────────────────────────────────────────── --> + <TextView + android:id="@+id/tv_error" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:textColor="?attr/colorError" + android:textAppearance="?attr/textAppearanceBodyMedium" /> + + </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 806598e..1072abe 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -246,4 +246,24 @@ </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_data_sources" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/button_quit" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Quit" + android:textColor="?attr/colorOnSurfaceVariant" + app:icon="@drawable/ic_close" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + </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..c1275a6 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 @@ -1,66 +1,156 @@ <?xml version="1.0" encoding="utf-8"?> <LinearLayout 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:orientation="vertical" - android:gravity="center" - android:padding="24dp"> + android:padding="20dp"> <TextView - android:id="@+id/tv_status" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Tap microphone to log" - android:textSize="18sp" - android:textAlignment="center" + android:text="Log Entry" + android:textAppearance="?attr/textAppearanceHeadlineSmall" android:layout_marginBottom="16dp" /> - <TextView - android:id="@+id/tv_recognized" + <!-- Note text — voice fills this; user can also type directly --> + <com.google.android.material.textfield.TextInputLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="" - android:textSize="16sp" - android:textAlignment="center" - android:padding="12dp" - android:minHeight="80dp" - android:background="#F5F5F5" - android:layout_marginBottom="24dp" /> + android:layout_marginBottom="12dp" + style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/et_note" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minLines="3" + android:maxLines="6" + android:gravity="top" + android:hint="Tap mic or type a note…" + android:inputType="textMultiLine|textCapSentences" + android:scrollbars="vertical" /> + + </com.google.android.material.textfield.TextInputLayout> + + <!-- Action row: mic, camera, gallery, status --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> + + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/fab_mic" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:fabSize="mini" + android:src="@android:drawable/ic_btn_speak_now" + android:contentDescription="Start voice recognition" + android:layout_marginEnd="12dp" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_camera" + style="@style/Widget.Material3.Button.IconButton.Outlined" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="Take photo" + app:icon="@android:drawable/ic_menu_camera" + android:layout_marginEnd="8dp" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_gallery" + style="@style/Widget.Material3.Button.IconButton.Outlined" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="Attach photo from gallery" + app:icon="@android:drawable/ic_menu_gallery" + android:layout_marginEnd="12dp" /> + + <TextView + android:id="@+id/tv_status" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="" + android:textSize="14sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> - <com.google.android.material.floatingactionbutton.FloatingActionButton - android:id="@+id/fab_mic" + <!-- Photo thumbnail — hidden until a photo is attached --> + <FrameLayout + android:id="@+id/frame_photo" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="Start voice recognition" - android:src="@android:drawable/ic_btn_speak_now" - android:layout_marginBottom="16dp" /> + android:layout_marginBottom="12dp" + android:visibility="gone"> + + <ImageView + android:id="@+id/iv_photo" + android:layout_width="120dp" + android:layout_height="90dp" + android:scaleType="centerCrop" + android:contentDescription="Attached photo" /> + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_remove_photo" + style="@style/Widget.Material3.Button.IconButton" + android:layout_width="32dp" + android:layout_height="32dp" + android:layout_gravity="top|end" + android:contentDescription="Remove photo" + app:icon="@drawable/ic_close" /> + + </FrameLayout> + + <!-- Save / Clear row --> <LinearLayout - android:id="@+id/ll_confirm_buttons" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" - android:visibility="gone"> + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> - <Button + <com.google.android.material.button.MaterialButton android:id="@+id/btn_save" - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:text="Save" android:layout_marginEnd="8dp" /> - <Button - android:id="@+id/btn_retry" + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_clear" + style="@style/Widget.Material3.Button.TextButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Retry" /> + android:text="Clear" /> + </LinearLayout> + <!-- Saved confirmation --> <TextView android:id="@+id/tv_saved_confirmation" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textSize="14sp" - android:layout_marginTop="16dp" /> + android:textColor="?attr/colorPrimary" + android:layout_marginBottom="24dp" /> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/colorOutline" + android:layout_marginBottom="24dp" /> + + <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/item_condition_column.xml b/android-app/app/src/main/res/layout/item_condition_column.xml new file mode 100644 index 0000000..1ffcd55 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_condition_column.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_horizontal" + android:padding="4dp"> + + <TextView + android:id="@+id/col_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="10sp" + android:textAllCaps="true" + android:letterSpacing="0.08" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="4dp" /> + + <TextView + android:id="@+id/col_wind" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:layout_marginBottom="2dp" /> + + <TextView + android:id="@+id/col_wind_dir" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="?attr/textAppearanceBodySmall" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="4dp" /> + + <TextView + android:id="@+id/col_sky" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="10sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:gravity="center" /> + +</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..aa785e6 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,166 +1,343 @@ <?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 --> - <androidx.gridlayout.widget.GridLayout - android:id="@+id/instrument_grid" + <!-- + Area-conditions header row: Wind (kts + arrow) | Temp (°C) | Baro (hPa) + Always shows conditions for the current map center. + --> + <LinearLayout + android:id="@+id/conditions_header" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="16dp" - app:columnCount="3" - app:rowCount="3" - app:layout_constraintTop_toBottomOf="@id/drag_handle"> + android:orientation="horizontal" + android:layout_marginTop="12dp" + app:layout_constraintTop_toBottomOf="@id/drag_handle" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> - <!-- Wind: AWS --> + <!-- Wind --> <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="AWS" /> - <TextView android:id="@+id/value_aws" style="@style/InstrumentPrimaryValue" tools:text="12.5" /> + <TextView style="@style/InstrumentLabel" android:text="Wind" /> + <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 android:id="@+id/unit_tws" style="@style/InstrumentUnit" android:text="kt" /> + <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" /> + <ProgressBar + android:id="@+id/spinner_tws" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="?attr/colorOnSurfaceVariant" + android:layout_marginStart="4dp" /> + </LinearLayout> + <TextView + android:id="@+id/bearing_tws" + style="@style/InstrumentLabel" + tools:text="270°" /> </LinearLayout> - <!-- Compass: HDG --> - <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" /> - <TextView android:id="@+id/value_hdg" style="@style/InstrumentPrimaryValue" tools:text="225" /> - </LinearLayout> + <View + android:layout_width="1dp" + android:layout_height="match_parent" + android:background="@color/md_theme_outline" /> - <!-- Performance: BSP --> + <!-- Temp --> <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="BSP" /> - <TextView android:id="@+id/value_bsp" style="@style/InstrumentPrimaryValue" tools:text="6.2" /> + <TextView style="@style/InstrumentLabel" android:text="Temp" /> + <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_temp" + style="@style/InstrumentPrimaryValue" + tools:text="18" /> + <TextView android:id="@+id/unit_temp" style="@style/InstrumentUnit" android:text="°F" /> + <ProgressBar + android:id="@+id/spinner_temp" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="?attr/colorOnSurfaceVariant" + android:layout_marginStart="4dp" /> + </LinearLayout> </LinearLayout> - <!-- Wind: 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" /> - <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="15.0" /> - </LinearLayout> + <View + android:layout_width="1dp" + android:layout_height="match_parent" + android:background="@color/md_theme_outline" /> - <!-- Compass: COG --> + <!-- Baro --> <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="COG" /> - <TextView android:id="@+id/value_cog" style="@style/InstrumentPrimaryValue" tools:text="230" /> + <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 android:id="@+id/unit_baro" style="@style/InstrumentUnit" android:text="hPa" /> + <ProgressBar + android:id="@+id/spinner_baro" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="?attr/colorOnSurfaceVariant" + android:layout_marginStart="4dp" /> + </LinearLayout> </LinearLayout> - <!-- Performance: SOG --> - <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" /> - <TextView android:id="@+id/value_sog" style="@style/InstrumentPrimaryValue" tools:text="6.5" /> - </LinearLayout> + </LinearLayout> + + <!-- Animated wave divider — colors adapt via wave_sky/sea color resources --> + <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/conditions_header" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> - <!-- VMG --> + <!-- Forecast section (ocean — intentionally always dark, do not invert) --> + <LinearLayout + android:id="@+id/forecast_row" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:forceDarkAllowed="false" + 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" - 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" /> + 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 android:id="@+id/unit_curr_spd" style="@style/ForecastUnit" android:text="kt" /> + <ProgressBar + android:id="@+id/spinner_curr" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="#5B9EC2" + android:layout_marginStart="4dp" /> + </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> - <!-- Depth --> + <!-- 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="Depth" /> - <TextView android:id="@+id/value_depth" style="@style/InstrumentPrimaryValue" tools:text="12.0" /> + 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 android:id="@+id/unit_wave_ht" style="@style/ForecastUnit" android:text="ft" /> + <ProgressBar + android:id="@+id/spinner_waves" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="#5B9EC2" + android:layout_marginStart="4dp" /> + </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> - <!-- Polar % --> + <!-- Swell --> <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="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 android:id="@+id/unit_swell_ht" style="@style/ForecastUnit" android:text="ft" /> + <ProgressBar + android:id="@+id/spinner_swell" + style="?android:attr/progressBarStyleSmall" + android:layout_width="10dp" + android:layout_height="10dp" + android:indeterminate="true" + android:visibility="invisible" + android:alpha="0.5" + android:indeterminateTint="#5B9EC2" + android:layout_marginStart="4dp" /> + </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> - </androidx.gridlayout.widget.GridLayout> - - <!-- 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..fdfbb7a --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml @@ -0,0 +1,310 @@ +<?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="wrap_content" + android:background="?attr/colorSurface"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingEnd="24dp" + android:paddingBottom="40dp"> + + <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" /> + + <!-- ── MAP LAYERS ──────────────────────────────────────────── --> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + 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="20dp" + app:singleSelection="true" + app:selectionRequired="true"> + + <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> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_marginBottom="16dp" + android:background="@color/md_theme_surfaceVariant" /> + + <!-- Wind overlay toggle --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> + + <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="Wind speed colour map" + 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> + + <!-- Wind particles toggle --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="8dp"> + + <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 particles" + android:textSize="15sp" + android:textColor="@color/instrument_text_normal" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Animated particle overlay" + android:textSize="12sp" + android:textColor="@color/instrument_text_secondary" /> + </LinearLayout> + + <com.google.android.material.switchmaterial.SwitchMaterial + android:id="@+id/switch_particles" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + + <!-- ── UNITS ───────────────────────────────────────────────── --> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_marginTop="8dp" + android:layout_marginBottom="16dp" + android:background="@color/md_theme_surfaceVariant" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:text="UNITS" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:fontFamily="sans-serif-light" + android:textColor="@color/instrument_text_secondary" /> + + <!-- Temperature --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="10dp"> + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Temperature" + android:textSize="14sp" + android:textColor="@color/instrument_text_normal" /> + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_temp" + 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_fahrenheit" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="°F" /> + <com.google.android.material.chip.Chip + android:id="@+id/chip_celsius" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="°C" /> + </com.google.android.material.chip.ChipGroup> + </LinearLayout> + + <!-- Depth / height --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="10dp"> + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Depth / height" + android:textSize="14sp" + android:textColor="@color/instrument_text_normal" /> + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_depth" + 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_feet" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="ft" /> + <com.google.android.material.chip.Chip + android:id="@+id/chip_meters" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="m" /> + </com.google.android.material.chip.ChipGroup> + </LinearLayout> + + <!-- Pressure --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="10dp"> + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Pressure" + android:textSize="14sp" + android:textColor="@color/instrument_text_normal" /> + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_pressure" + 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_hpa" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="hPa" /> + <com.google.android.material.chip.Chip + android:id="@+id/chip_inhg" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="inHg" /> + </com.google.android.material.chip.ChipGroup> + </LinearLayout> + + <!-- Speed --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Speed" + android:textSize="14sp" + android:textColor="@color/instrument_text_normal" /> + <com.google.android.material.chip.ChipGroup + android:id="@+id/chip_group_speed" + 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_knots" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="kt" /> + <com.google.android.material.chip.Chip + android:id="@+id/chip_mph" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="mph" /> + <com.google.android.material.chip.Chip + android:id="@+id/chip_kph" + style="@style/Widget.Material3.Chip.Filter" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="kph" /> + </com.google.android.material.chip.ChipGroup> + </LinearLayout> + + </LinearLayout> +</ScrollView> diff --git a/android-app/app/src/main/res/layout/layout_nav_hud.xml b/android-app/app/src/main/res/layout/layout_nav_hud.xml new file mode 100644 index 0000000..90119fa --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml @@ -0,0 +1,164 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Persistent boat-instrument HUD strip. + Lives at the top of the map; always visible; shows dashes when NMEA unavailable. + Fields: SOG | COG | BSP | Depth +--> +<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="48dp" + android:orientation="horizontal" + android:background="#CC000000"> + + <!-- SOG --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center"> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="SOG" + android:textSize="9sp" + android:textColor="#99FFFFFF" + android:letterSpacing="0.08" /> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + <TextView + android:id="@+id/hud_sog" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="16sp" + android:textColor="#FFFFFF" + android:fontFamily="sans-serif-medium" + tools:text="7.1" /> + <TextView + android:id="@+id/unit_hud_sog" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="kt" + android:textSize="10sp" + android:textColor="#99FFFFFF" + android:layout_marginStart="2dp" /> + </LinearLayout> + </LinearLayout> + + <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" /> + + <!-- COG --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center"> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="COG" + android:textSize="9sp" + android:textColor="#99FFFFFF" + android:letterSpacing="0.08" /> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + <TextView + android:id="@+id/hud_cog" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="16sp" + android:textColor="#FFFFFF" + android:fontFamily="sans-serif-medium" + tools:text="253°" /> + </LinearLayout> + </LinearLayout> + + <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" /> + + <!-- BSP --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center"> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="BSP" + android:textSize="9sp" + android:textColor="#99FFFFFF" + android:letterSpacing="0.08" /> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + <TextView + android:id="@+id/hud_bsp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="16sp" + android:textColor="#FFFFFF" + android:fontFamily="sans-serif-medium" + tools:text="6.8" /> + <TextView + android:id="@+id/unit_hud_bsp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="kt" + android:textSize="10sp" + android:textColor="#99FFFFFF" + android:layout_marginStart="2dp" /> + </LinearLayout> + </LinearLayout> + + <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" /> + + <!-- Depth --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center"> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Depth" + android:textSize="9sp" + android:textColor="#99FFFFFF" + android:letterSpacing="0.08" /> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + <TextView + android:id="@+id/hud_depth" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="16sp" + android:textColor="#FFFFFF" + android:fontFamily="sans-serif-medium" + tools:text="42.0" /> + <TextView + android:id="@+id/unit_hud_depth" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="ft" + android:textSize="10sp" + android:textColor="#99FFFFFF" + android:layout_marginStart="2dp" /> + </LinearLayout> + </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/layout/sheet_dev_log.xml b/android-app/app/src/main/res/layout/sheet_dev_log.xml new file mode 100644 index 0000000..5464242 --- /dev/null +++ b/android-app/app/src/main/res/layout/sheet_dev_log.xml @@ -0,0 +1,74 @@ +<?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="match_parent" + android:orientation="vertical" + android:background="?attr/colorSurface"> + + <!-- Header: title + action buttons --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="8dp" + android:paddingTop="16dp" + android:paddingBottom="8dp" + android:gravity="center_vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Dev Log" + android:textSize="16sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_clear" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Clear" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_copy" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Copy" /> + + </LinearLayout> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/colorOutline" + android:alpha="0.3" /> + + <ScrollView + android:id="@+id/scroll_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="24dp" + android:clipToPadding="false"> + + <TextView + android:id="@+id/log_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fontFamily="monospace" + android:textSize="11sp" + android:textColor="?attr/colorOnSurface" + android:lineSpacingMultiplier="1.35" + android:textIsSelectable="true" /> + + </ScrollView> + +</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 5f444eb..5c6de7e 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" @@ -20,4 +20,8 @@ android:id="@+id/nav_vessel" android:icon="@drawable/ic_vessel" android:title="Vessel" /> + <item + android:id="@+id/nav_learn" + android:icon="@drawable/ic_learn" + android:title="Learn" /> </menu> diff --git a/android-app/app/src/main/res/values-night/colors.xml b/android-app/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..b3f8669 --- /dev/null +++ b/android-app/app/src/main/res/values-night/colors.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- M3 Dark Palette (night mode) --> + <color name="md_theme_primary">#A8C8FF</color> + <color name="md_theme_onPrimary">#00315E</color> + <color name="md_theme_primaryContainer">#004886</color> + <color name="md_theme_onPrimaryContainer">#D6E3FF</color> + + <color name="md_theme_secondary">#BBC7DB</color> + <color name="md_theme_onSecondary">#253140</color> + <color name="md_theme_secondaryContainer">#3B4858</color> + <color name="md_theme_onSecondaryContainer">#DAE2F9</color> + + <color name="md_theme_error">#FFB4AB</color> + <color name="md_theme_onError">#690005</color> + <color name="md_theme_errorContainer">#93000A</color> + <color name="md_theme_onErrorContainer">#FFDAD6</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> + + <!-- Instrument: dark-mode values --> + <color name="instrument_text_normal">#E6E1E5</color> + <color name="instrument_text_secondary">#9A94A0</color> + <color name="instrument_background">#1C1B1F</color> + <color name="instrument_card_background">#2B2930</color> + + <!-- WaveView — night: dark sky + midnight ocean --> + <color name="wave_sky_top">#1C1B1F</color> + <color name="wave_sky_bottom">#162433</color> + <color name="wave_sea_top">#0B3050</color> + <color name="wave_sea_bottom">#0D2137</color> + <color name="wave_shimmer">#4D6FC3E8</color> + <color name="wave_whitecap">#80FFFFFF</color> +</resources> diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index b380d2d..2df4109 100755 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -1,28 +1,28 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <!-- M3 Color Palette --> + <!-- M3 Color Palette (light mode — dark mode overrides in values-night/colors.xml) --> <color name="md_theme_primary">#005FB0</color> <color name="md_theme_onPrimary">#FFFFFF</color> <color name="md_theme_primaryContainer">#D6E3FF</color> <color name="md_theme_onPrimaryContainer">#001B3E</color> - + <color name="md_theme_secondary">#565F71</color> <color name="md_theme_onSecondary">#FFFFFF</color> <color name="md_theme_secondaryContainer">#DAE2F9</color> <color name="md_theme_onSecondaryContainer">#131C2C</color> - + <color name="md_theme_error">#BA1A1A</color> <color name="md_theme_onError">#FFFFFF</color> <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">#FFFBFE</color> + <color name="md_theme_onBackground">#1C1B1F</color> + <color name="md_theme_surface">#FFFBFE</color> + <color name="md_theme_onSurface">#1C1B1F</color> + <color name="md_theme_surfaceVariant">#E7E0EB</color> + <color name="md_theme_onSurfaceVariant">#49454F</color> + <color name="md_theme_outline">#7A757F</color> <!-- Legacy / Functional Colors --> <color name="black">#FF000000</color> @@ -31,13 +31,13 @@ <color name="primary_dark">#002171</color> <color name="accent">#FF6D00</color> - <!-- Instrument Specific --> - <color name="instrument_text_normal">#1A1C1E</color> - <color name="instrument_text_secondary">#44474E</color> + <!-- Instrument Specific (light-mode values; dark overrides in values-night/) --> + <color name="instrument_text_normal">#1C1B1F</color> + <color name="instrument_text_secondary">#6F6878</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_stale">#9A94A0</color> + <color name="instrument_background">#FFFBFE</color> + <color name="instrument_card_background">#F2EDF7</color> <color name="mob_button_background">#BA1A1A</color> <color name="anchor_button_background">#005FB0</color> @@ -48,6 +48,14 @@ <color name="wind_medium">#FF9800</color> <color name="wind_strong">#F44336</color> + <!-- WaveView — day: sunny sky + tropical deep-sea blue --> + <color name="wave_sky_top">#2B8FC4</color> + <color name="wave_sky_bottom">#87C8DF</color> + <color name="wave_sea_top">#0D7A9A</color> + <color name="wave_sea_bottom">#074B68</color> + <color name="wave_shimmer">#50FFFFFF</color> + <color name="wave_whitecap">#80FFFFFF</color> + <!-- Night Vision Mode (Stays Red) --> <color name="night_red_primary">#FFFF0000</color> <color name="night_red_variant">#FFBB0000</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..93f5209 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,79 @@ <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">?attr/colorOnSurfaceVariant</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> + + <!-- Bottom sheet top-only rounded corners --> + <style name="ShapeAppearance.Nav.BottomSheet" parent="ShapeAppearance.Material3.Corner.None"> + <item name="cornerFamily">rounded</item> + <item name="cornerSizeTopLeft">24dp</item> + <item name="cornerSizeTopRight">24dp</item> + <item name="cornerSizeBottomLeft">0dp</item> + <item name="cornerSizeBottomRight">0dp</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 & "Fun" <test>")) + } +} 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 a68e315..98d0bf3 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 @@ -10,41 +10,45 @@ 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() fun computeStats(): TrackStats? { - if (points.size < 2) return null + if (activePoints.size < 2) return null var distNm = 0.0 - for (i in 1 until points.size) { - distNm += haversineNm(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon) + for (i in 1 until activePoints.size) { + distNm += haversineNm(activePoints[i - 1].lat, activePoints[i - 1].lon, activePoints[i].lat, activePoints[i].lon) } - val durationMs = points.last().timestampMs - points.first().timestampMs - val avgSog = if (durationMs > 0) { - points.map { it.sogKnots }.average() - } else 0.0 + val durationMs = activePoints.last().timestampMs - activePoints.first().timestampMs + val avgSog = if (durationMs > 0) activePoints.map { it.sogKnots }.average() else 0.0 return TrackStats(distNm, durationMs, avgSog) } companion object { fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { - val r = 3440.065 // Earth radius in nautical miles + val r = 3440.065 val dLat = Math.toRadians(lat2 - lat1) val dLon = Math.toRadians(lon2 - lon1) val a = sin(dLat / 2) * sin(dLat / 2) + |
