summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-11 02:36:34 +0000
committerClaude <noreply@anthropic.com>2026-04-11 02:36:34 +0000
commitd5e819e869f3b15da0f28ba2d8525e825afeb5a0 (patch)
tree78ae008c64934fadee7fb20f76b4d90237f67cf9 /android-app/app
parentfc7192288109fc3542670cbeeaebe0de2a75eb74 (diff)
parent4a2d0298ab2caa3d62cfbd54c0071ae47eb89ccf (diff)
Merge branch 'claude/particle-wind-simulation-IMVxT'
Particle wind animation, Learn tab, log text/photo, departure picker, outbound link markers, offline ColRegs + sailing reference content. https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/AndroidManifest.xml1
-rw-r--r--android-app/app/src/main/assets/docs/colregs_reference.md230
-rw-r--r--android-app/app/src/main/assets/docs/sailing_reference.md206
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt122
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt116
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt162
-rw-r--r--android-app/app/src/main/res/drawable/ic_calendar.xml44
-rw-r--r--android-app/app/src/main/res/drawable/ic_open_in_new.xml30
-rw-r--r--android-app/app/src/main/res/layout/fragment_learn.xml226
-rw-r--r--android-app/app/src/main/res/layout/fragment_pretrip_report.xml52
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml142
17 files changed, 1194 insertions, 207 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index bcab20c..7cbafc7 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
<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" />
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/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/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 996892e..e6355bd 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
@@ -296,6 +296,8 @@ 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() {
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/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/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
index c88dc48..ac1350d 100644
--- 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
@@ -11,13 +11,21 @@ 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() {
@@ -26,7 +34,9 @@ class PreTripReportFragment : Fragment() {
private lateinit var viewModel: PreTripReportViewModel
private lateinit var chipGroupBoats: ChipGroup
- private lateinit var btnGenerate: com.google.android.material.button.MaterialButton
+ 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
@@ -40,6 +50,8 @@ class PreTripReportFragment : Fragment() {
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)
@@ -47,7 +59,6 @@ class PreTripReportFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- // Construct ViewModel with the application-level repo
viewModel = ViewModelProvider(
requireActivity(),
PreTripReportViewModel.Factory(NavApplication.boatProfileRepository)
@@ -57,28 +68,30 @@ class PreTripReportFragment : Fragment() {
observeViewModel()
btnGenerate.setOnClickListener { triggerGenerate() }
+ btnPickDeparture.setOnClickListener { showDeparturePicker() }
- // Auto-generate if forecast data is already available
if (mainViewModel.forecast.value.isNotEmpty()) {
triggerGenerate()
}
}
private fun bindViews(view: View) {
- chipGroupBoats = view.findViewById(R.id.chip_group_boats)
- 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)
+ 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() {
@@ -91,19 +104,80 @@ class PreTripReportFragment : Fragment() {
}
}
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
+ text = profile.name
isCheckable = true
isChecked = (profile.id == selectedId)
- tag = profile.id
+ tag = profile.id
}
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
@@ -168,10 +242,10 @@ class PreTripReportFragment : Fragment() {
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)
+ 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
@@ -218,4 +292,6 @@ class PreTripReportFragment : Fragment() {
"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
index 2508d44..2840d76 100644
--- 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
@@ -4,7 +4,10 @@ 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 {
@@ -12,13 +15,14 @@ class PreTripReportGenerator {
/**
* Builds a full pre-trip briefing.
*
- * @param lat Current position latitude
- * @param lon Current position longitude
- * @param forecastItems Hourly forecast list; first item = current hour
- * @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 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,
@@ -27,14 +31,19 @@ class PreTripReportGenerator {
conditions: MarineConditions?,
boatProfile: BoatProfile,
pastTracks: List<List<TrackPoint>> = emptyList(),
- durationHrs: Double = 3.0
+ durationHrs: Double = 3.0,
+ departureDateTimeMs: Long = System.currentTimeMillis()
): PreTripReport {
- val current = forecastItems.firstOrNull()
+ // 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 = System.currentTimeMillis(),
+ timestampMs = departureDateTimeMs,
lat = lat,
lon = lon,
windSpeedKt = windKt,
@@ -46,27 +55,71 @@ class PreTripReportGenerator {
return PreTripReport(
summary = summary,
- conditionWindow = buildConditionWindow(forecastItems),
+ conditionWindow = buildConditionWindow(window, departureDateTimeMs),
routeProjection = projectRoute(windDir, windKt,
conditions?.currentSpeedKt ?: 0.0,
conditions?.currentDirDeg ?: 0.0,
durationHrs, boatProfile),
- sailPlan = suggestSailPlan(windKt, forecastItems, boatProfile),
- watchItems = buildWatchList(conditions, forecastItems, 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>): List<ConditionSlice> {
- val labels = listOf("Now", "+1 h", "+2 h", "+3 h")
+ 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 = labels.getOrElse(i) { "+${i} h" },
+ label = label,
windKt = item.windKt,
windDirDeg = item.windDirDeg,
- waveHeightM = null, // marine API doesn't give hourly per slot here; use conditions
+ waveHeightM = null,
weatherDescription = item.weatherDescription()
)
}
@@ -94,7 +147,6 @@ class PreTripReportGenerator {
durationHrs: Double,
boatProfile: BoatProfile
): RouteProjection {
- // Score each candidate heading
data class Candidate(
val heading: Double, val label: String, val note: String,
val twa: Double, val sog: Double, val score: Double
@@ -109,20 +161,17 @@ class PreTripReportGenerator {
val best = scored.maxByOrNull { it.score }!!
- // Return leg is the reciprocal heading
val returnHdg = (best.heading + 180.0) % 360.0
val returnTwa = twa(windDirDeg, returnHdg)
val returnSog = estimatedSogKt(boatProfile.lengthFt, twsKt, returnTwa)
- // Outbound leg distance (half the trip duration at outbound SOG)
val outboundHrs = durationHrs / 2.0
val outboundNm = best.sog * outboundHrs
- // Current component along outbound heading
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 adds %.1f kt outbound.".format(currentComponent)
currentComponent < -0.15 -> "Current opposes %.1f kt outbound.".format(-currentComponent)
else -> ""
}
@@ -203,20 +252,16 @@ class PreTripReportGenerator {
): List<SailSuggestion> {
val suggestions = mutableListOf<SailSuggestion>()
- // Pick best-fit headsail from inventory (highest maxWindKt that still covers windKt,
- // falling back to smallest sail if wind exceeds all)
val headsail = boatProfile.headsails
.sortedBy { it.maxWindKt }
.firstOrNull { windKt <= it.maxWindKt }
?: boatProfile.headsails.minByOrNull { it.maxWindKt }
if (headsail != null) {
- // Build a rationale note for this headsail choice
val note = headsailNote(headsail, windKt, forecastItems, boatProfile)
suggestions.add(SailSuggestion(headsail.name, note))
}
- // Main reef recommendation
val mainAction = when {
windKt > 21 && boatProfile.mainReefs >= 2 -> "2nd Reef"
windKt > 15 && boatProfile.mainReefs >= 1 -> "1st Reef"
@@ -225,7 +270,6 @@ class PreTripReportGenerator {
}
suggestions.add(SailSuggestion("Main", mainAction))
- // If wind is likely to build into a higher band during the trip, add a heads-up
val maxForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
if (maxForecastWind > windKt + 4) {
val nextSail = boatProfile.headsails
@@ -250,7 +294,6 @@ class PreTripReportGenerator {
): String {
val maxForecast = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
- // E23-specific 155% overlap note
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"
@@ -270,12 +313,12 @@ class PreTripReportGenerator {
private fun buildWatchList(
conditions: MarineConditions?,
forecastItems: List<ForecastItem>,
- boatProfile: BoatProfile
+ boatProfile: BoatProfile,
+ departureDateTimeMs: Long = System.currentTimeMillis()
): List<WatchItem> {
val items = mutableListOf<WatchItem>()
val windKt = forecastItems.firstOrNull()?.windKt ?: 0.0
- // Swell hazards
conditions?.swellHeightM?.let { swell ->
if (swell > 2.0)
items += WatchItem("⚠️", "Significant swell %.1fm — expect motion".format(swell))
@@ -285,7 +328,6 @@ class PreTripReportGenerator {
items += WatchItem("⚠️", "Short swell period %.0fs — choppy, uncomfortable".format(period))
}
- // Building wind in the trip window
val peakForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
val peakHour = forecastItems.take(4).indexOfFirst { it.windKt == peakForecastWind }
if (peakForecastWind > windKt + 5) {
@@ -293,25 +335,23 @@ class PreTripReportGenerator {
"Wind building to ${peakForecastWind.toInt()} kt in ~${peakHour} h — plan to be back before then")
}
- // Rain
val maxPrecip = forecastItems.take(4).maxOfOrNull { it.precipProbabilityPct } ?: 0
if (maxPrecip > 30)
items += WatchItem("⚠️", "Rain likely ($maxPrecip% chance) — visibility may reduce")
- // Time-of-day trade build
- val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
- if (hour in 11..14)
+ // 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 (hour >= 15)
+ else if (departureHour >= 15)
items += WatchItem("ℹ️", "Afternoon departure — trades may be at their strongest; build extra time margin")
- // E23 155% genoa overpower warning
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")
}
- // General storm check
if (windKt > 30)
items += WatchItem("⚠️", "Winds ${windKt.toInt()} kt — consider staying in port")
else if (windKt > 22)
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
index 5d4cd14..64a739a 100644
--- 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
@@ -33,6 +33,9 @@ class PreTripReportViewModel(
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
@@ -44,6 +47,10 @@ class PreTripReportViewModel(
_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
@@ -53,12 +60,13 @@ class PreTripReportViewModel(
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
+ 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) {
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
index 8440edb..da3f020 100644
--- 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
@@ -28,6 +28,14 @@ class LearnFragment : Fragment() {
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")
+ }
+
// ASA / external links — open in browser
view.findViewById<MaterialCardView>(R.id.card_asa_courses).setOnClickListener {
openUrl("https://www.asa.com/courses/")
@@ -35,9 +43,6 @@ class LearnFragment : Fragment() {
view.findViewById<MaterialCardView>(R.id.card_asa_online).setOnClickListener {
openUrl("https://www.asa.com/online-courses/")
}
- view.findViewById<MaterialCardView>(R.id.card_colregs).setOnClickListener {
- openUrl("https://www.navcen.uscg.gov/international-regulations-for-preventing-collisions-at-sea")
- }
view.findViewById<MaterialCardView>(R.id.card_flashcards).setOnClickListener {
openUrl("https://quizlet.com/subject/sailing/")
}
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 24b25db..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
@@ -38,10 +39,12 @@ 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 = 3.5f
@@ -62,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 ────────────────────────────────────────────────────────────
@@ -86,6 +91,11 @@ class ParticleWindView @JvmOverloads constructor(
val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
val latRange = latNorth - latSouth
+ // 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()
@@ -109,7 +119,7 @@ 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
@@ -137,7 +147,9 @@ class ParticleWindView @JvmOverloads constructor(
LatLng(particleLat[i].toDouble(), particleLon[i].toDouble())
)
- val alpha = ((1f - particleAge[i] / MAX_AGE) * 220).toInt().coerceIn(40, 220)
+ // 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)
@@ -170,7 +182,7 @@ class ParticleWindView @JvmOverloads constructor(
val lonEast = maxOf(nR.longitude, fR.longitude).toFloat()
val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f
- for (i in 0 until N) {
+ for (i in 0 until activeN) {
particleLat[i] = latSouth + Random.nextFloat() * latRange
var newLon = lonWest + Random.nextFloat() * lonSpan
if (newLon > 180f) newLon -= 360f
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 1c797d5..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,37 +12,75 @@ 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 = 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,
container: ViewGroup?,
@@ -50,20 +90,33 @@ 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)
+ 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())
@@ -76,18 +129,20 @@ class VoiceLogFragment : Fragment() {
}
}
+ // ── 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")
}
@@ -95,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) {}
})
}
@@ -119,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
}
}
@@ -162,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/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_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/fragment_learn.xml b/android-app/app/src/main/res/layout/fragment_learn.xml
index f41e577..8813ba2 100644
--- a/android-app/app/src/main/res/layout/fragment_learn.xml
+++ b/android-app/app/src/main/res/layout/fragment_learn.xml
@@ -96,11 +96,11 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
- <!-- ── ASA Training & Practice ──────────────────────────────── -->
+ <!-- ── Reference (offline) ──────────────────────────────────── -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="ASA TRAINING &amp; PRACTICE"
+ android:text="REFERENCE"
android:textSize="11sp"
android:textAllCaps="true"
android:letterSpacing="0.12"
@@ -108,7 +108,7 @@
android:layout_marginBottom="8dp" />
<com.google.android.material.card.MaterialCardView
- android:id="@+id/card_asa_courses"
+ android:id="@+id/card_colregs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
@@ -118,59 +118,102 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="vertical"
- android:padding="16dp">
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
- <TextView
- android:layout_width="wrap_content"
+ <LinearLayout
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:text="ASA Course Catalog"
- android:textAppearance="?attr/textAppearanceTitleSmall" />
+ 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="ASA 101–114: keelboat through offshore passagemaking"
- android:textSize="12sp"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:layout_marginTop="2dp" />
+ 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_asa_online"
+ android:id="@+id/card_sailing_reference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="8dp"
+ android:layout_marginBottom="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="vertical"
- android:padding="16dp">
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
- <TextView
- android:layout_width="wrap_content"
+ <LinearLayout
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:text="ASA Online Learning"
- android:textAppearance="?attr/textAppearanceTitleSmall" />
+ 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 &amp; 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="Self-paced online courses and study guides"
- android:textSize="12sp"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:layout_marginTop="2dp" />
+ android:text="›"
+ android:textSize="20sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+ <!-- ── ASA Training &amp; Practice (external links) ──────────── -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ASA TRAINING &amp; PRACTICE"
+ 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:id="@+id/card_asa_courses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
@@ -180,22 +223,85 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="vertical"
- android:padding="16dp">
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
- <TextView
- android:layout_width="wrap_content"
+ <LinearLayout
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:text="ColRegs — Rules of the Road"
- android:textAppearance="?attr/textAppearanceTitleSmall" />
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ASA Course Catalog"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ASA 101–114: keelboat through offshore passagemaking"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:layout_width="18dp"
+ android:layout_height="18dp"
+ android:src="@drawable/ic_open_in_new"
+ android:tint="?attr/colorOnSurfaceVariant"
+ android:contentDescription="Opens in browser" />
- <TextView
- android:layout_width="wrap_content"
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_asa_online"
+ 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:text="USCG international collision regulations reference"
- android:textSize="12sp"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:layout_marginTop="2dp" />
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ASA Online Learning"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Self-paced online courses and study guides"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:layout_width="18dp"
+ android:layout_height="18dp"
+ android:src="@drawable/ic_open_in_new"
+ android:tint="?attr/colorOnSurfaceVariant"
+ android:contentDescription="Opens in browser" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -211,22 +317,38 @@
<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="Sailing Flashcards"
- android:textAppearance="?attr/textAppearanceTitleSmall" />
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
- <TextView
- android:layout_width="wrap_content"
+ <LinearLayout
+ android:layout_width="0dp"
android:layout_height="wrap_content"
- android:text="Quizlet decks for knots, rules, lights &amp; signals"
- android:textSize="12sp"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:layout_marginTop="2dp" />
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Sailing Flashcards"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Quizlet decks for knots, rules, lights &amp; signals"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:layout_width="18dp"
+ android:layout_height="18dp"
+ android:src="@drawable/ic_open_in_new"
+ android:tint="?attr/colorOnSurfaceVariant"
+ android:contentDescription="Opens in browser" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
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
index 510411b..8cb094a 100644
--- a/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
+++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
@@ -54,6 +54,58 @@
</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"
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 6d136be..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,75 +1,150 @@
<?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="#DDDDDD"
- android:layout_marginTop="32dp"
- android:layout_marginBottom="32dp" />
+ android:background="?attr/colorOutline"
+ android:layout_marginBottom="24dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_generate_report"
@@ -77,4 +152,5 @@
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="GENERATE TRIP REPORT" />
+
</LinearLayout>