diff options
15 files changed, 1176 insertions, 203 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/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/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 & 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 & 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 & Practice (external links) ──────────── --> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="ASA TRAINING & 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 & 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 & 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> |
