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 /android-app | |
| 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
Diffstat (limited to 'android-app')
91 files changed, 7656 insertions, 877 deletions
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)) + } +} |
