From ca57e40adc0b89e7dc5409475f7510c0c188d715 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 18:09:53 +0000 Subject: feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 66d1abe..552bf99 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -68,4 +68,17 @@ app:layout_anchor="@id/bottom_navigation" app:layout_anchorGravity="top|start" /> + + + -- cgit v1.2.3 From 2e0c420020526873906fedd024b50baef56011fa Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 2 Apr 2026 21:53:08 +0000 Subject: fix(ui): anchor both FABs to instrument sheet (squash merge recovery) Both fab_mob and fab_record_track now anchor to instrument_bottom_sheet so they sit above the sheet peek zone rather than being occluded by the sheet's 16dp elevation. Resolves stash conflict in settings.local.json. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 4 +++- android-app/app/src/main/res/layout/activity_main.xml | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bcb3577..4fe9a49 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -57,7 +57,9 @@ "Bash(screen -list)", "Bash(adb logcat:*)", "Bash(grep -v \"^--$\")", - "Bash(./gradlew assembleDebug)" + "Bash(./gradlew assembleDebug)", + "Skill(commit-commands:commit-push-pr)", + "Skill(code-review:code-review)" ] } } diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 552bf99..68abc60 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -65,7 +65,7 @@ android:contentDescription="Man Overboard" app:srcCompat="@android:drawable/ic_dialog_alert" app:backgroundTint="@color/mob_button_background" - app:layout_anchor="@id/bottom_navigation" + app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|start" /> @@ -78,7 +78,7 @@ android:focusable="true" android:contentDescription="Record Track" app:srcCompat="@drawable/ic_track_record" - app:layout_anchor="@id/bottom_navigation" + app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|end" /> -- cgit v1.2.3 From 04d990f8066312e79532cad9f70f7b4c92a3d9c8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 02:44:25 +0000 Subject: fix(ui): raise both FABs fully above instrument sheet top edge anchorGravity=top centers the FAB on the sheet edge, leaving half the button occluded. Increase marginBottom to 44dp (28dp to clear the FAB radius + 16dp gap) so both buttons sit fully above the sheet. Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 68abc60..edac466 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,7 +59,8 @@ android:id="@+id/fab_mob" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_margin="16dp" + android:layout_marginStart="16dp" + android:layout_marginBottom="44dp" android:clickable="true" android:focusable="true" android:contentDescription="Man Overboard" @@ -73,7 +74,8 @@ android:id="@+id/fab_record_track" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_margin="16dp" + android:layout_marginEnd="16dp" + android:layout_marginBottom="44dp" android:clickable="true" android:focusable="true" android:contentDescription="Record Track" -- cgit v1.2.3 From e9df7afa1d96fde80c482e497d7c17617c2d95c3 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 02:58:19 +0000 Subject: fix(ui): raise FAB elevation above CardView sheet to fix z-order The instrument sheet CardView has cardElevation=16dp which was rendering on top of the FABs (default ~6dp elevation). Set app:elevation=20dp on both FABs so they always appear above the sheet. Also reverts oversized marginBottom back to standard 16dp all-round now that elevation stacking is correct. Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/res/layout/activity_main.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index edac466..b4221ed 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,13 +59,13 @@ android:id="@+id/fab_mob" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginBottom="44dp" + android:layout_margin="16dp" android:clickable="true" android:focusable="true" android:contentDescription="Man Overboard" app:srcCompat="@android:drawable/ic_dialog_alert" app:backgroundTint="@color/mob_button_background" + app:elevation="20dp" app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|start" /> @@ -74,12 +74,12 @@ android:id="@+id/fab_record_track" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="16dp" - android:layout_marginBottom="44dp" + android:layout_margin="16dp" android:clickable="true" android:focusable="true" android:contentDescription="Record Track" app:srcCompat="@drawable/ic_track_record" + app:elevation="20dp" app:layout_anchor="@id/instrument_bottom_sheet" app:layout_anchorGravity="top|end" /> -- cgit v1.2.3 From bf2223827c53fbc0e77d1af2a7d4654a7c248ee0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 2 Apr 2026 21:08:04 -1000 Subject: feat(map): interactive map with auto-follow, recenter button, and UI immersive mode (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add map interaction design spec Co-Authored-By: Claude Sonnet 4.6 * docs: add map interaction implementation plan Co-Authored-By: Claude Sonnet 4.6 * feat(map): add isFollowing state and gesture-driven manual mode to MapHandler * feat(ui): add fab_recenter pill button to map layout * feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing * fix(map): prevent fadeIn flash on cold start; consolidate fab_mob listener * fix(map): preserve user zoom level on recenter Capture the current camera zoom when the user gestures (entering manual mode) and pass it back to centerOnLocation in recenter(), so tapping Recenter returns to the user's chosen zoom rather than always snapping to the default 14. Co-Authored-By: Claude Sonnet 4.6 * fix(map): capture lastZoom on camera idle, not gesture start OnCameraMoveStartedListener fires before the gesture completes, so it captured the pre-gesture zoom. OnCameraIdleListener fires after the camera settles, giving the user's final intended zoom level. Only update lastZoom while in manual mode (isFollowing=false). Co-Authored-By: Claude Sonnet 4.6 * fix(map): guard recenter against null island and add KDoc - Skip recenter() if no GPS fix received (lastLat/lastLon still 0.0) to avoid animating to 0°N, 0°E - Add KDoc comment to recenter() consistent with other public methods Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 48 +++- .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 36 +++ .../app/src/main/res/layout/activity_main.xml | 16 ++ .../plans/2026-04-03-map-interaction.md | 256 +++++++++++++++++++++ .../specs/2026-04-03-map-interaction-design.md | 59 +++++ 5 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-03-map-interaction.md create mode 100644 docs/superpowers/specs/2026-04-03-map-interaction-design.md (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 f9d4dbd..252761e 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 @@ -15,9 +15,11 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.button.MaterialButton import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import kotlinx.coroutines.Dispatchers @@ -53,6 +55,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomSheetBehavior: BottomSheetBehavior private lateinit var fragmentContainer: FrameLayout private lateinit var fabRecordTrack: FloatingActionButton + private lateinit var fabMob: FloatingActionButton + private lateinit var fabRecenter: MaterialButton + private lateinit var bottomSheet: CardView + private lateinit var bottomNav: BottomNavigationView private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -83,14 +89,20 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupBottomNavigation() setupHandlers() - findViewById(R.id.fab_mob).setOnClickListener { - onActivateMob() - } - fabRecordTrack = findViewById(R.id.fab_record_track) fabRecordTrack.setOnClickListener { if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() } + + fabMob = findViewById(R.id.fab_mob) + fabMob.setOnClickListener { onActivateMob() } + fabRecenter = findViewById(R.id.fab_recenter) + bottomSheet = findViewById(R.id.instrument_bottom_sheet) + bottomNav = findViewById(R.id.bottom_navigation) + + fabRecenter.setOnClickListener { + mapHandler?.recenter() + } // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -232,6 +244,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapView?.onCreate(null) mapView?.getMapAsync { maplibreMap -> mapHandler = MapHandler(maplibreMap) + lifecycleScope.launch { + mapHandler!!.isFollowing.collect { following -> + if (following) { + fadeOut(fabRecenter, gone = true) + fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack) + } else { + fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true) + fadeIn(fabRecenter) + } + } + } val style = Style.Builder() .fromUri("https://tiles.openfreemap.org/styles/liberty") .withSource(RasterSource("openseamap-source", @@ -309,6 +332,23 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { return PolarTable(curves) } + private fun fadeOut(vararg views: View, gone: Boolean = false) { + views.forEach { v -> + v.animate().alpha(0f).setDuration(150).withEndAction { + v.visibility = if (gone) View.GONE else View.INVISIBLE + }.start() + } + } + + private fun fadeIn(vararg views: View) { + views.forEach { v -> + if (v.visibility == View.VISIBLE && v.alpha == 1f) return@forEach + v.alpha = 0f + v.visibility = View.VISIBLE + v.animate().alpha(1f).setDuration(150).start() + } + } + override fun onStart() { super.onStart(); mapView?.onStart() } override fun onPause() { super.onPause(); mapView?.onPause() } override fun onStop() { super.onStop(); mapView?.onStop() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index cbc2e90..7c82808 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -24,12 +24,35 @@ import org.terst.nav.TidalCurrentState import org.terst.nav.track.TrackPoint import kotlin.math.cos import kotlin.math.sin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Handles MapLibre initialization, layers, and updates. */ class MapHandler(private val maplibreMap: MapLibreMap) { + init { + maplibreMap.addOnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { + _isFollowing.value = false + } + } + maplibreMap.addOnCameraIdleListener { + if (!_isFollowing.value) { + lastZoom = maplibreMap.cameraPosition.zoom + } + } + } + + private val _isFollowing = MutableStateFlow(true) + val isFollowing: StateFlow = _isFollowing.asStateFlow() + + private var lastLat: Double = 0.0 + private var lastLon: Double = 0.0 + private var lastZoom: Double = 14.0 + private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source" private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source" private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer" @@ -95,6 +118,9 @@ class MapHandler(private val maplibreMap: MapLibreMap) { * Centers the map on the specified location. */ fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) { + lastLat = lat + lastLon = lon + if (!_isFollowing.value) return val position = CameraPosition.Builder() .target(LatLng(lat, lon)) .zoom(zoom) @@ -102,6 +128,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) { maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000) } + /** + * Restores auto-follow mode and animates the camera back to the last known GPS position. + * No-op if no GPS fix has been received yet. + */ + fun recenter() { + if (lastLat == 0.0 && lastLon == 0.0) return + _isFollowing.value = true + centerOnLocation(lastLat, lastLon, lastZoom) + } + /** * Updates the anchor watch visualization on the map. */ diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index b4221ed..a3d347f 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -25,6 +25,22 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + + diff --git a/docs/superpowers/plans/2026-04-03-map-interaction.md b/docs/superpowers/plans/2026-04-03-map-interaction.md new file mode 100644 index 0000000..9f8fa13 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-map-interaction.md @@ -0,0 +1,256 @@ +# Map Interaction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add manual pan/zoom with auto-follow GPS centering, a Recenter button that appears when the user pans away, and full UI fade-out in manual mode. + +**Architecture:** `MapHandler` owns an `isFollowing: StateFlow` and registers a `MapLibreMap.OnCameraMoveStartedListener` to detect gesture-driven camera moves. `MainActivity` collects the flow and animates the bottom sheet, nav bar, FABs, and recenter button in/out. + +**Tech Stack:** MapLibre Android SDK (`MapLibreMap.OnCameraMoveStartedListener`, `REASON_API_GESTURE`), Kotlin `StateFlow`, Android `View.animate()`. + +--- + +## File Map + +| File | What changes | +|------|-------------| +| `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon`, gesture listener, `recenter()`, guard in `centerOnLocation()` | +| `android-app/app/src/main/res/layout/activity_main.xml` | Add `fab_recenter` pill button inside the map ConstraintLayout | +| `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click | + +--- + +### Task 1: Extend MapHandler with follow state and gesture detection + +**Files:** +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` + +- [ ] **Step 1: Add imports and new fields** + +Open `MapHandler.kt`. Add these imports at the top (after existing imports): + +```kotlin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.maplibre.android.maps.MapLibreMap +``` + +Add these fields inside the `MapHandler` class, before `setupLayers`: + +```kotlin +private val _isFollowing = MutableStateFlow(true) +val isFollowing: StateFlow = _isFollowing.asStateFlow() + +private var lastLat: Double = 0.0 +private var lastLon: Double = 0.0 +``` + +- [ ] **Step 2: Register the gesture listener in the constructor** + +Replace the class declaration line: +```kotlin +class MapHandler(private val maplibreMap: MapLibreMap) { +``` +with: +```kotlin +class MapHandler(private val maplibreMap: MapLibreMap) { + + init { + maplibreMap.addOnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { + _isFollowing.value = false + } + } + } +``` + +- [ ] **Step 3: Guard centerOnLocation and store last position** + +Replace the existing `centerOnLocation` method: +```kotlin +fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) { + lastLat = lat + lastLon = lon + if (!_isFollowing.value) return + val position = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom) + .build() + maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000) +} +``` + +- [ ] **Step 4: Add recenter()** + +Add this method after `centerOnLocation`: +```kotlin +fun recenter() { + _isFollowing.value = true + centerOnLocation(lastLat, lastLon) +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +git commit -m "feat(map): add isFollowing state and gesture-driven manual mode to MapHandler" +``` + +--- + +### Task 2: Add the Recenter button to the layout + +**Files:** +- Modify: `android-app/app/src/main/res/layout/activity_main.xml` + +- [ ] **Step 1: Add fab_recenter inside the ConstraintLayout** + +In `activity_main.xml`, find the ConstraintLayout that wraps `mapView` (around line 10). It currently contains `mapView` and `fragment_container`. Add the recenter button as a third child, before the closing `` tag: + +```xml + +``` + +- [ ] **Step 2: Commit** + +```bash +git add android-app/app/src/main/res/layout/activity_main.xml +git commit -m "feat(ui): add fab_recenter pill button to map layout" +``` + +--- + +### Task 3: Wire MainActivity to animate UI on follow state changes + +**Files:** +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` + +- [ ] **Step 1: Add imports** + +Add these imports to `MainActivity.kt` (with existing imports): +```kotlin +import androidx.cardview.widget.CardView +import com.google.android.material.button.MaterialButton +``` + +- [ ] **Step 2: Add view fields** + +In the `MainActivity` class body, alongside the existing `fabRecordTrack` declaration, add: +```kotlin +private lateinit var fabMob: FloatingActionButton +private lateinit var fabRecenter: MaterialButton +private lateinit var bottomSheet: CardView +private lateinit var bottomNav: BottomNavigationView +``` + +- [ ] **Step 3: Add the fade helpers** + +Add these two private methods to `MainActivity` (before `onStart`): + +```kotlin +private fun fadeOut(vararg views: View, gone: Boolean = false) { + views.forEach { v -> + v.animate().alpha(0f).setDuration(150).withEndAction { + v.visibility = if (gone) View.GONE else View.INVISIBLE + }.start() + } +} + +private fun fadeIn(vararg views: View) { + views.forEach { v -> + v.alpha = 0f + v.visibility = View.VISIBLE + v.animate().alpha(1f).setDuration(150).start() + } +} +``` + +- [ ] **Step 4: Wire up views and observe isFollowing in initializeUI** + +In `initializeUI()`, after the existing `fabRecordTrack` setup, add: + +```kotlin +fabMob = findViewById(R.id.fab_mob) +fabRecenter = findViewById(R.id.fab_recenter) +bottomSheet = findViewById(R.id.instrument_bottom_sheet) +bottomNav = findViewById(R.id.bottom_navigation) + +fabRecenter.setOnClickListener { + mapHandler?.recenter() +} +``` + +- [ ] **Step 5: Observe isFollowing after mapHandler is created** + +In `setupMap()`, inside the `getMapAsync` lambda, after `mapHandler = MapHandler(maplibreMap)`, add: + +```kotlin +lifecycleScope.launch { + mapHandler!!.isFollowing.collect { following -> + if (following) { + fadeOut(fabRecenter, gone = true) + fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack) + } else { + fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true) + fadeIn(fabRecenter) + } + } +} +``` + +- [ ] **Step 6: Manual smoke test** + +Build and install. Verify: +1. App opens — bottom sheet, nav, FABs visible; no Recenter button +2. Pan the map — all UI fades out, Recenter button fades in +3. Tap Recenter — UI fades back in, map animates to GPS position, Recenter gone +4. GPS updates while in manual mode — map does NOT jump back to GPS position + +- [ ] **Step 7: Commit** + +```bash +git add android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +git commit -m "feat(map): wire UI fade-out and recenter button to MapHandler.isFollowing" +``` + +--- + +### Task 4: Request Gemini review, fix issues, loop until clean, merge + +- [ ] **Step 1: Push branch and open PR** +```bash +git push local main +git push github main +``` +Then open a PR (or request review inline if working on main). + +- [ ] **Step 2: Request code review using code-review:code-review skill** + +Invoke `code-review:code-review` skill against the PR. Address all issues with confidence ≥ 80. + +- [ ] **Step 3: Loop until review returns clean** + +Re-request review after each fix. Stop when no issues ≥ 80 are found. + +- [ ] **Step 4: Merge and push to both remotes** +```bash +gh pr merge --repo thepeterstone/nav --squash --delete-branch +git checkout main && git pull github main +git push local main +``` diff --git a/docs/superpowers/specs/2026-04-03-map-interaction-design.md b/docs/superpowers/specs/2026-04-03-map-interaction-design.md new file mode 100644 index 0000000..02e38e1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-map-interaction-design.md @@ -0,0 +1,59 @@ +# Map Interaction Design +_2026-04-03_ + +## Overview + +Enable free map pan/zoom while keeping GPS auto-follow as the default. When the user gestures on the map, the UI hides and a Recenter button appears. Tapping Recenter restores auto-follow and the full UI. + +## State + +A single `isFollowing: Boolean` flag owned by `MapHandler`. Starts `true`. This is the only piece of new state. + +## Entering Manual Mode + +`MapLibreMap.addOnCameraMoveStartedListener` fires with `REASON_API_GESTURE` when the user initiates a pan, pinch-zoom, or rotate. On that event: + +- `MapHandler.isFollowing` → `false` +- `MapHandler` emits via a new `StateFlow isFollowing` exposed to MainActivity +- `MapHandler.centerOnLocation()` becomes a no-op while `!isFollowing` — GPS updates still arrive, last position is stored + +## Returning to Auto-Follow + +`fab_recenter` click handler: + +- Calls `MapHandler.recenter()` — sets `isFollowing = true`, calls `centerOnLocation(lastLat, lastLon)` +- `isFollowing` StateFlow emits `true` → MainActivity restores UI + +## UI Changes + +### New element: `fab_recenter` + +- Pill-shaped button (`wrap_content` width, 40dp height, 20dp corner radius) +- Label: "⊙ Recenter" +- Position: centered horizontally, `24dp` above the bottom of the map `ConstraintLayout` +- `android:visibility="gone"` by default +- Elevation: 20dp (above the instrument sheet) + +### Visibility toggling (MainActivity) + +When `isFollowing` → `false`: +- Fade out (alpha 0, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track` +- Show `fab_recenter` (visibility VISIBLE, fade in 150ms) + +When `isFollowing` → `true`: +- Hide `fab_recenter` (fade out 150ms, then GONE) +- Fade in (alpha 1, 150ms): `instrument_bottom_sheet`, `bottom_navigation`, `fab_mob`, `fab_record_track` + +## Files to Change + +| File | Change | +|------|--------| +| `MapHandler.kt` | Add `isFollowing` StateFlow, `lastLat`/`lastLon` storage, `recenter()`, guard in `centerOnLocation()`, register `OnCameraMoveStartedListener` | +| `activity_main.xml` | Add `fab_recenter` pill button | +| `MainActivity.kt` | Observe `mapHandler.isFollowing`, animate UI in/out, wire `fab_recenter` click | + +## Out of Scope + +- Tapping the map (without gesture) to restore UI — not requested +- Timeout to auto-restore UI — not requested +- Zoom-level persistence across recenter — not requested -- cgit v1.2.3 From a8d851e5bfb78b065f10d457bf3ce8f2c771bb4c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 4 Apr 2026 04:53:50 +0000 Subject: refactor(ui): stabilize layout by moving BottomNav outside CoordinatorLayout --- .../app/src/main/res/layout/activity_main.xml | 158 +++++++++++---------- 1 file changed, 82 insertions(+), 76 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index a3d347f..b8df5c9 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -1,102 +1,108 @@ - - - + android:layout_height="0dp" + android:layout_weight="1"> - + + android:layout_height="match_parent"> - - + - + + - + - - + - + + android:layout_height="wrap_content" + app:behavior_hideable="false" + app:behavior_peekHeight="120dp" + app:cardElevation="16dp" + app:cardCornerRadius="24dp" + app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - + + + + + + + + + + + - - - - - - - + -- cgit v1.2.3 From 5ee2dd8925afa858f466ae63db3f7df5c7516953 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 7 Apr 2026 06:42:51 +0000 Subject: fix(track): fix silent GPX save failure + add stop friction + quit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackStorage: openOutputStream null returned true (file never written). Added IS_PENDING flag to fix Android 10-11 race where insert succeeds but file isn't physically created yet. Added storage-mounted guard. TrackRepository now logs save failures. Stop tracking now requires a long press (haptic feedback) — prevents accidental mid-sail stops from a single tap. Quit button (top-right, tonal X) stops LocationService and calls finishAffinity(). Prompts if a track is in progress. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 32 +++++++++++++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 4 ++- .../kotlin/org/terst/nav/track/TrackStorage.kt | 39 ++++++++++++++++++++-- .../app/src/main/res/layout/activity_main.xml | 18 ++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 0309364..6263e13 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 @@ -9,6 +9,7 @@ import android.media.MediaPlayer import android.os.Build import android.os.Bundle import android.util.Log +import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout import androidx.activity.result.contract.ActivityResultContracts @@ -51,6 +52,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var fabRecordTrack: FloatingActionButton private lateinit var fabMob: FloatingActionButton private lateinit var fabRecenter: MaterialButton + private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } @@ -81,6 +83,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack = findViewById(R.id.fab_record_track) fabMob = findViewById(R.id.fab_mob) fabRecenter = findViewById(R.id.fab_recenter) + btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) @@ -89,8 +92,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupHandlers() setupMap() + // Single tap starts; long press stops (requires deliberate intent mid-sail) fabRecordTrack.setOnClickListener { - if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() + if (!viewModel.isRecording.value) viewModel.startTrack() + } + fabRecordTrack.setOnLongClickListener { + if (viewModel.isRecording.value) { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + viewModel.stopTrack() + true + } else false } fabMob.setOnClickListener { onActivateMob() } @@ -98,6 +109,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecenter.setOnClickListener { mapHandler?.recenter() } + + btnQuit.setOnClickListener { onQuitRequested() } // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -177,6 +190,23 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { showOverlay(fragment) } + private fun onQuitRequested() { + if (viewModel.isRecording.value) { + androidx.appcompat.app.AlertDialog.Builder(this) + .setMessage("Recording in progress. Quit and discard the current track?") + .setPositiveButton("Quit") { _, _ -> exitApp() } + .setNegativeButton("Cancel", null) + .show() + } else { + exitApp() + } + } + + private fun exitApp() { + stopService(Intent(this, LocationService::class.java)) + finishAffinity() + } + override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 228a484..7ef67af 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -1,6 +1,7 @@ package org.terst.nav.track import android.content.Context +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -45,7 +46,8 @@ class TrackRepository(context: Context) { if (summary.durationMs < 2 * 60_000L) return@withContext null _pastTracks.add(0, points) - storage.saveTrack(points, trackStartMs) + val saved = storage.saveTrack(points, trackStartMs) + if (!saved) Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart") summary } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt index 620431c..08e1dc9 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt @@ -5,11 +5,14 @@ import android.content.Context import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import java.io.File import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +private const val TAG = "TrackStorage" + /** * Persists completed tracks as GPX files in the shared Documents/Nav/ folder. * @@ -56,19 +59,49 @@ class TrackStorage(private val context: Context) { // ── API 29+ ────────────────────────────────────────────────────────────── private fun saveViaMediaStore(fileName: String, gpx: String): Boolean { + // Guard: external storage must be mounted before touching MediaStore + val storageState = Environment.getExternalStorageState() + if (storageState != Environment.MEDIA_MOUNTED) { + Log.e(TAG, "External storage not mounted (state=$storageState) — cannot save $fileName") + return false + } + + // IS_PENDING marks the entry as in-progress, preventing a race condition on + // Android 10-11 where insert() succeeds but openOutputStream() returns null + // because the file hasn't been physically created on disk yet. val values = ContentValues().apply { put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml") put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/") + put(MediaStore.MediaColumns.IS_PENDING, 1) } val uri = context.contentResolver.insert( MediaStore.Files.getContentUri("external"), values - ) ?: return false + ) ?: run { + Log.e(TAG, "MediaStore insert returned null for $fileName") + return false + } return runCatching { - context.contentResolver.openOutputStream(uri)?.use { it.write(gpx.toByteArray()) } + val stream = context.contentResolver.openOutputStream(uri) + if (stream == null) { + context.contentResolver.delete(uri, null, null) + Log.e(TAG, "openOutputStream null for $fileName — deleted orphan entry") + return@runCatching false + } + stream.use { it.write(gpx.toByteArray()) } + + // Clear IS_PENDING so the file is visible to other apps and file managers + val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + context.contentResolver.update(uri, update, null, null) + + Log.d(TAG, "Saved $fileName (${gpx.length} bytes) → $uri") true - }.getOrDefault(false) + }.getOrElse { e -> + Log.e(TAG, "Write failed for $fileName: ${e.message}") + runCatching { context.contentResolver.delete(uri, null, null) } + false + } } private fun loadViaMediaStore(): List> { diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index b8df5c9..0734476 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -30,6 +30,24 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + + + Date: Fri, 10 Apr 2026 09:30:55 +0000 Subject: Area conditions HUD redesign: map-center conditions refresh + boat HUD strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persistent top-of-map HUD strip (SOG/COG/BSP/Depth) replaces instrument grid in bottom sheet - Bottom sheet now shows area conditions only (Wind/Temp/Baro + wave forecast) — always visible - loadConditions() fires on every camera idle event so panning the map refreshes conditions - Crosshair appears at map center while panning; hides when following GPS - Added tempC field to MarineConditions (already fetched from Open-Meteo hourly) - InstrumentHandler slimmed to area conditions only; updateDisplay() removed - LocationService pipes nmeaBoatSpeedData from NmeaStreamManager to companion flow https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../main/kotlin/org/terst/nav/LocationService.kt | 11 ++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 86 ++++++---- .../org/terst/nav/data/model/MarineConditions.kt | 1 + .../terst/nav/data/repository/WeatherRepository.kt | 1 + .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 86 ++++------ .../app/src/main/res/drawable/ic_crosshair.xml | 28 +++ .../app/src/main/res/layout/activity_main.xml | 24 +++ .../main/res/layout/layout_instruments_sheet.xml | 187 +++------------------ .../app/src/main/res/layout/layout_nav_hud.xml | 161 ++++++++++++++++++ 9 files changed, 329 insertions(+), 256 deletions(-) create mode 100644 android-app/app/src/main/res/drawable/ic_crosshair.xml create mode 100644 android-app/app/src/main/res/layout/layout_nav_hud.xml (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index b18db8d..41fb2ec 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.asStateFlow import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager +import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData @@ -128,6 +129,13 @@ class LocationService : Service() { } } + // Collect NMEA Boat Speed Data + serviceScope.launch { + nmeaStreamManager.nmeaBoatSpeedData.collectLatest { bspData -> + _nmeaBoatSpeedDataFlow.emit(bspData) + } + } + locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> @@ -393,6 +401,9 @@ class LocationService : Service() { private val _nmeaHeadingDataFlow = MutableSharedFlow(replay = 1) val nmeaHeadingDataFlow: SharedFlow get() = _nmeaHeadingDataFlow + private val _nmeaBoatSpeedDataFlow = MutableSharedFlow(replay = 1) + val nmeaBoatSpeedData: SharedFlow get() = _nmeaBoatSpeedDataFlow + private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) val currentPowerMode: StateFlow get() = _currentPowerMode } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 6d02014..21aa55b 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 @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout +import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -57,6 +58,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView + private lateinit var mapCrosshair: View + + // HUD TextViews — wired to layout_nav_hud.xml + private lateinit var hudSog: TextView + private lateinit var hudCog: TextView + private lateinit var hudBsp: TextView + private lateinit var hudDepth: TextView + private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -88,6 +97,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) + mapCrosshair = findViewById(R.id.map_crosshair) + + // HUD views (inside the included layout_nav_hud) + hudSog = findViewById(R.id.hud_sog) + hudCog = findViewById(R.id.hud_cog) + hudBsp = findViewById(R.id.hud_bsp) + hudDepth = findViewById(R.id.hud_depth) + + // Initialise HUD with dashes + hudSog.text = "—" + hudCog.text = "—" + hudBsp.text = "—" + hudDepth.text = "—" setupBottomSheet() setupBottomNavigation() @@ -184,12 +206,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fragmentContainer.visibility = View.GONE } - private fun showReport(fragment: androidx.fragment.app.Fragment) { - bottomSheetBehavior.isHideable = true - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - showOverlay(fragment) - } - private fun onQuitRequested() { if (viewModel.isRecording.value) { androidx.appcompat.app.AlertDialog.Builder(this) @@ -222,18 +238,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun setupHandlers() { instrumentHandler = InstrumentHandler( - valueAws = findViewById(R.id.value_aws), - valueTws = findViewById(R.id.value_tws), - valueHdg = findViewById(R.id.value_hdg), - valueCog = findViewById(R.id.value_cog), - valueBsp = findViewById(R.id.value_bsp), - valueSog = findViewById(R.id.value_sog), - valueDepth = findViewById(R.id.value_depth), - valueBaro = findViewById(R.id.value_baro), - arrowAws = findViewById(R.id.arrow_aws), - arrowTws = findViewById(R.id.arrow_tws), - arrowHdg = findViewById(R.id.arrow_hdg), - arrowCog = findViewById(R.id.arrow_cog), + valueTws = findViewById(R.id.value_tws), + arrowTws = findViewById(R.id.arrow_tws), + bearingTws = findViewById(R.id.bearing_tws), + valueTemp = findViewById(R.id.value_temp), + valueBaro = findViewById(R.id.value_baro), valueCurrSpd = findViewById(R.id.value_curr_spd), valueWaveHt = findViewById(R.id.value_wave_ht), valueSwellHt = findViewById(R.id.value_swell_ht), @@ -246,12 +255,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bearingSwell = findViewById(R.id.bearing_swell), waveView = findViewById(R.id.wave_divider) ) - instrumentHandler?.updateDisplay( - aws = "—", tws = "—", hdg = "—", - cog = "—", bsp = "—", sog = "—", - baro = "—" + instrumentHandler?.updateConditions( + tws = "—", baro = "—", currSpd = "—" ) - instrumentHandler?.updateConditions(currSpd = "—") } private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() @@ -302,11 +308,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { mapHandler!!.isFollowing.collect { following -> + mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE if (following) { fadeOut(fabRecenter, gone = true) - fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack) + fadeIn(bottomNav, fabMob, fabRecordTrack) } else { - fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true) + fadeOut(bottomNav, fabMob, fabRecordTrack, gone = true) fadeIn(fabRecenter) } } @@ -339,6 +346,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { corners.minOf { it.longitude }, corners.maxOf { it.longitude } ) } + // Refresh conditions for current map center (works in both follow + pan mode) + maplibreMap.cameraPosition.target?.let { center -> + viewModel.loadConditions(center.latitude, center.longitude) + } } maplibreMap.addOnMapLongClickListener { _ -> @@ -360,11 +371,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat()) viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog) - instrumentHandler?.updateDisplay( - sog = "%.1f".format(Locale.getDefault(), gpsData.sog), - cog = "%.0f°".format(Locale.getDefault(), gpsData.cog), - cogBearingDeg = gpsData.cog.toFloat() - ) + // HUD — SOG and COG come from GPS + hudSog.text = "%.1f".format(Locale.getDefault(), gpsData.sog) + hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) if (!conditionsLoaded) { conditionsLoaded = true viewModel.loadConditions(gpsData.latitude, gpsData.longitude) @@ -391,18 +400,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.barometerStatus.collect { status -> if (status.history.isNotEmpty()) { - instrumentHandler?.updateDisplay(baro = status.formatPressure()) + instrumentHandler?.updateConditions(baro = status.formatPressure()) } } } lifecycleScope.launch { viewModel.marineConditions.collect { c -> if (c == null) return@collect - instrumentHandler?.updateDisplay( - tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, - twsBearingDeg = c.windDirDeg?.toFloat() - ) instrumentHandler?.updateConditions( + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, + twsBearingDeg = c.windDirDeg?.toFloat(), + tempC = c.tempC, currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—", currDirDeg = c.currentDirDeg?.toFloat(), waveHeightM = c.waveHeightM, @@ -420,7 +428,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } lifecycleScope.launch { LocationService.nmeaDepthDataFlow.collect { depthData -> - instrumentHandler?.updateDisplay(depthM = depthData.depthMeters) + // Update HUD depth (converted to feet) + hudDepth.text = "%.1f".format(Locale.getDefault(), depthData.depthMeters * 3.28084) + } + } + lifecycleScope.launch { + LocationService.nmeaBoatSpeedData.collect { bspData -> + hudBsp.text = "%.1f".format(Locale.getDefault(), bspData.bspKnots) } } lifecycleScope.launch { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt index 3cde023..557d6a2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt @@ -7,6 +7,7 @@ package org.terst.nav.data.model data class MarineConditions( val windSpeedKt: Double?, val windDirDeg: Double?, + val tempC: Double?, val waveHeightM: Double?, val waveDirDeg: Double?, val swellHeightM: Double?, diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt index c79366d..dc47a20 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -59,6 +59,7 @@ class WeatherRepository( MarineConditions( windSpeedKt = w.windspeed10m.firstOrNull(), windDirDeg = w.winddirection10m.firstOrNull(), + tempC = w.temperature2m.firstOrNull(), waveHeightM = m.waveHeight.firstOrNull(), waveDirDeg = m.waveDirection.firstOrNull(), swellHeightM = m.swellWaveHeight.firstOrNull(), diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt index cb59a3a..48ebb3b 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt @@ -25,10 +25,11 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = // ── InstrumentHandler ──────────────────────────────────────────────────────── /** - * Drives all text fields, direction arrows, and the wave view in the - * instrument bottom sheet. + * Drives the area-conditions bottom sheet: wind header row, wave view, + * and the forecast section (current / waves / swell). * - * Forecast [DirectionArrowView] instances are initialised with OCEAN style. + * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly + * in MainActivity via the HUD strip. * * Units contract: * - Speed values: pre-formatted strings in knots (caller's responsibility) @@ -37,34 +38,26 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = * into bearing TextViews by this class */ class InstrumentHandler( - // ── Instrument section TextViews ───────────────────────────────── - private val valueAws: TextView, - private val valueTws: TextView, - private val valueHdg: TextView, - private val valueCog: TextView, - private val valueBsp: TextView, - private val valueSog: TextView, - private val valueDepth: TextView, - private val valueBaro: TextView, - // ── Instrument section DirectionArrowViews ─────────────────────── - private val arrowAws: DirectionArrowView, - private val arrowTws: DirectionArrowView, - private val arrowHdg: DirectionArrowView, - private val arrowCog: DirectionArrowView, - // ── Forecast section TextViews ─────────────────────────────────── + // ── Conditions header ──────────────────────────────────────────────── + private val valueTws: TextView, + private val arrowTws: DirectionArrowView, + private val bearingTws: TextView, + private val valueTemp: TextView, + private val valueBaro: TextView, + // ── Forecast section TextViews ─────────────────────────────────────── private val valueCurrSpd: TextView, private val valueWaveHt: TextView, private val valueSwellHt: TextView, private val valueSwellPer: TextView, - // ── Forecast section DirectionArrowViews ───────────────────────── + // ── Forecast section DirectionArrowViews ───────────────────────────── private val arrowCurr: DirectionArrowView, private val arrowWaves: DirectionArrowView, private val arrowSwell: DirectionArrowView, - // ── Forecast section bearing TextViews ─────────────────────────── + // ── Forecast section bearing TextViews ─────────────────────────────── private val bearingCurr: TextView, private val bearingWaves: TextView, private val bearingSwell: TextView, - // ── Wave view ──────────────────────────────────────────────────── + // ── Wave view ──────────────────────────────────────────────────────── private val waveView: WaveView ) { init { @@ -74,48 +67,27 @@ class InstrumentHandler( } /** - * Updates instrument-section text and arrows. + * Updates all area-conditions fields. * Null arguments leave the current value unchanged. - * [depthM] is raw metres — converted to feet internally. - */ - fun updateDisplay( - aws: String? = null, awsBearingDeg: Float? = null, - tws: String? = null, twsBearingDeg: Float? = null, - hdg: String? = null, hdgBearingDeg: Float? = null, - cog: String? = null, cogBearingDeg: Float? = null, - bsp: String? = null, - sog: String? = null, - depthM: Double? = null, - baro: String? = null - ) { - aws?.let { valueAws.text = it } - tws?.let { valueTws.text = it } - hdg?.let { valueHdg.text = it } - cog?.let { valueCog.text = it } - bsp?.let { valueBsp.text = it } - sog?.let { valueSog.text = it } - baro?.let { valueBaro.text = it } - depthM?.let { valueDepth.text = formatFt(metresToFeet(it)) } - - awsBearingDeg?.let { arrowAws.bearing = it } - twsBearingDeg?.let { arrowTws.bearing = it } - hdgBearingDeg?.let { arrowHdg.bearing = it } - cogBearingDeg?.let { arrowCog.bearing = it } - } - - /** - * Updates the forecast section. * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally. */ fun updateConditions( - currSpd: String? = null, - currDirDeg: Float? = null, - waveHeightM: Double? = null, - waveDirDeg: Float? = null, - swellHeightM: Double? = null, - swellDirDeg: Float? = null, + tws: String? = null, twsBearingDeg: Float? = null, + tempC: Double? = null, + baro: String? = null, + currSpd: String? = null, currDirDeg: Float? = null, + waveHeightM: Double? = null, waveDirDeg: Float? = null, + swellHeightM: Double? = null, swellDirDeg: Float? = null, swellPeriodS: Double? = null ) { + tws?.let { valueTws.text = it } + baro?.let { valueBaro.text = it } + tempC?.let { valueTemp.text = "%.0f".format(Locale.getDefault(), it) } + twsBearingDeg?.let { + arrowTws.bearing = it + bearingTws.text = formatBearing(it.toDouble()) + } + currSpd?.let { valueCurrSpd.text = it } currDirDeg?.let { arrowCurr.bearing = it diff --git a/android-app/app/src/main/res/drawable/ic_crosshair.xml b/android-app/app/src/main/res/drawable/ic_crosshair.xml new file mode 100644 index 0000000..609538e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_crosshair.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 1741c62..5147506 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -29,6 +29,30 @@ android:clickable="false" android:focusable="false" /> + + + + + + - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - + + - + - + tools:text="18" /> + + android:background="@color/md_theme_outline" /> + diff --git a/android-app/app/src/main/res/layout/layout_nav_hud.xml b/android-app/app/src/main/res/layout/layout_nav_hud.xml new file mode 100644 index 0000000..4623bb1 --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- cgit v1.2.3 From bf57713ef0e378ebedd518f4fb243328de08179d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 16:27:42 +0000 Subject: Add unit settings, wind particles toggle, and recenter-to-top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New UnitPrefs: persisted C/F, ft/m, hPa/inHg, kt/mph/kph — defaults to °F - InstrumentHandler now accepts raw values (knots, metres, hPa, °C) and formats via UnitPrefs on every update - LayerPickerSheet expanded with wind particles toggle and unit chip selectors; wrapped in ScrollView to handle the additional height - MapLayerManager tracks particlesEnabled (persisted); setParticlesEnabled() added - MainActivity: caches last raw values so refreshUnits() can reformat everything instantly when the user changes a unit without waiting for new sensor data - Recenter button moved to top (below HUD strip) so it's never obscured by the bottom sheet or bottom nav - Unit label TextViews given IDs in HUD and instrument sheet for live updates https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 190 +++++++---- .../app/src/main/kotlin/org/terst/nav/UnitPrefs.kt | 83 +++++ .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 79 ++--- .../kotlin/org/terst/nav/ui/LayerPickerSheet.kt | 95 +++++- .../kotlin/org/terst/nav/ui/MapLayerManager.kt | 14 +- .../app/src/main/res/layout/activity_main.xml | 4 +- .../main/res/layout/layout_instruments_sheet.xml | 12 +- .../main/res/layout/layout_layer_picker_sheet.xml | 352 ++++++++++++++++----- .../app/src/main/res/layout/layout_nav_hud.xml | 3 + 9 files changed, 643 insertions(+), 189 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt (limited to 'android-app/app/src/main/res/layout/activity_main.xml') 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 21aa55b..86dd531 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 @@ -37,6 +37,7 @@ import org.terst.nav.ui.map.ParticleWindView import org.terst.nav.ui.safety.SafetyFragment import org.terst.nav.ui.voicelog.VoiceLogFragment import java.util.* +import org.terst.nav.data.model.MarineConditions import org.terst.nav.safety.AnchorWatchState class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { @@ -60,12 +61,35 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomNav: BottomNavigationView private lateinit var mapCrosshair: View - // HUD TextViews — wired to layout_nav_hud.xml + // HUD value TextViews private lateinit var hudSog: TextView private lateinit var hudCog: TextView private lateinit var hudBsp: TextView private lateinit var hudDepth: TextView + // HUD unit label TextViews + private lateinit var unitHudSog: TextView + private lateinit var unitHudBsp: TextView + private lateinit var unitHudDepth: TextView + + // Instrument sheet unit label TextViews + private lateinit var unitTws: TextView + private lateinit var unitTemp: TextView + private lateinit var unitBaro: TextView + private lateinit var unitCurrSpd: TextView + private lateinit var unitWaveHt: TextView + private lateinit var unitSwellHt: TextView + + // Unit preferences + private lateinit var unitPrefs: UnitPrefs + + // Cached raw values for unit-change refresh + private var lastConditions: MarineConditions? = null + private var lastBaroHpa: Float? = null + private var lastSogKnots: Double? = null + private var lastBspKnots: Double? = null + private var lastDepthMeters: Double? = null + private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -85,6 +109,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { MapLibre.getInstance(this) setContentView(R.layout.activity_main) + unitPrefs = UnitPrefs(this) checkForegroundPermissions() initializeUI() } @@ -99,24 +124,37 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomNav = findViewById(R.id.bottom_navigation) mapCrosshair = findViewById(R.id.map_crosshair) - // HUD views (inside the included layout_nav_hud) + // HUD value views hudSog = findViewById(R.id.hud_sog) hudCog = findViewById(R.id.hud_cog) hudBsp = findViewById(R.id.hud_bsp) hudDepth = findViewById(R.id.hud_depth) - // Initialise HUD with dashes + // HUD unit label views + unitHudSog = findViewById(R.id.unit_hud_sog) + unitHudBsp = findViewById(R.id.unit_hud_bsp) + unitHudDepth = findViewById(R.id.unit_hud_depth) + + // Instrument sheet unit label views + unitTws = findViewById(R.id.unit_tws) + unitTemp = findViewById(R.id.unit_temp) + unitBaro = findViewById(R.id.unit_baro) + unitCurrSpd = findViewById(R.id.unit_curr_spd) + unitWaveHt = findViewById(R.id.unit_wave_ht) + unitSwellHt = findViewById(R.id.unit_swell_ht) + + // Init HUD with dashes; apply correct unit labels from stored prefs hudSog.text = "—" hudCog.text = "—" hudBsp.text = "—" hudDepth.text = "—" + applyUnitLabels() setupBottomSheet() setupBottomNavigation() setupHandlers() setupMap() - // Single tap starts; long press stops (requires deliberate intent mid-sail) fabRecordTrack.setOnClickListener { if (!viewModel.isRecording.value) viewModel.startTrack() } @@ -129,12 +167,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } fabMob.setOnClickListener { onActivateMob() } - - fabRecenter.setOnClickListener { - mapHandler?.recenter() - } - + fabRecenter.setOnClickListener { mapHandler?.recenter() } btnQuit.setOnClickListener { onQuitRequested() } + lifecycleScope.launch { viewModel.isRecording.collect { recording -> val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record @@ -151,11 +186,73 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } + /** Updates all static unit label TextViews to match current [unitPrefs]. */ + private fun applyUnitLabels() { + unitHudSog.text = unitPrefs.speedUnitLabel() + unitHudBsp.text = unitPrefs.speedUnitLabel() + unitHudDepth.text = unitPrefs.depthUnitLabel() + unitTws.text = unitPrefs.speedUnitLabel() + unitTemp.text = unitPrefs.tempUnitLabel() + unitBaro.text = unitPrefs.pressureUnitLabel() + unitCurrSpd.text = unitPrefs.speedUnitLabel() + unitWaveHt.text = unitPrefs.depthUnitLabel() + unitSwellHt.text = unitPrefs.depthUnitLabel() + } + + /** + * Re-formats every cached raw value with the current unit prefs. + * Called after the user changes a unit in the settings sheet. + */ + private fun refreshUnits() { + applyUnitLabels() + lastSogKnots?.let { hudSog.text = unitPrefs.formatSpeed(it) } + lastBspKnots?.let { hudBsp.text = unitPrefs.formatSpeed(it) } + lastDepthMeters?.let { hudDepth.text = unitPrefs.formatDepth(it) } + lastConditions?.let { applyConditions(it) } + lastBaroHpa?.let { instrumentHandler?.updateConditions(baroHpa = it) } + } + + private fun applyConditions(c: MarineConditions) { + instrumentHandler?.updateConditions( + twsKt = c.windSpeedKt, + twsBearingDeg = c.windDirDeg?.toFloat(), + tempC = c.tempC, + currSpdKt = c.currentSpeedKt, + currDirDeg = c.currentDirDeg?.toFloat(), + waveHeightM = c.waveHeightM, + waveDirDeg = c.waveDirDeg?.toFloat(), + swellHeightM = c.swellHeightM, + swellDirDeg = c.swellDirDeg?.toFloat(), + swellPeriodS = c.swellPeriodS + ) + instrumentHandler?.updateWaveState( + swellHeightM = c.swellHeightM?.toFloat() ?: 0.9f, + swellPeriodSec = c.swellPeriodS?.toFloat() ?: 10f, + windWaveHeightM = c.waveHeightM?.toFloat() ?: 0.45f, + windSpeedKt = c.windSpeedKt?.toFloat() ?: 0f + ) + } + private fun setupBottomSheet() { bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } + private fun buildLayerPickerSheet(): LayerPickerSheet { + val currentStyle = loadedStyleFlow.value + return LayerPickerSheet( + manager = layerManager, + onBaseChanged = { preset -> currentStyle?.let { layerManager.setBasePreset(it, preset) } }, + onWindChanged = { enabled -> currentStyle?.let { layerManager.setWindEnabled(it, enabled) } }, + onParticlesChanged = { enabled -> + layerManager.setParticlesEnabled(enabled) + particleWindView?.visibility = if (enabled) View.VISIBLE else View.GONE + }, + unitPrefs = unitPrefs, + onUnitsChanged = { refreshUnits() } + ) + } + private fun setupBottomNavigation() { bottomNav.setOnItemSelectedListener { item -> when (item.itemId) { @@ -167,14 +264,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { true } R.id.nav_layers -> { - val currentStyle = loadedStyleFlow.value - if (currentStyle != null) { - LayerPickerSheet( - manager = layerManager, - onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) }, - onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) } - ).show(supportFragmentManager, "layer_picker") - } + buildLayerPickerSheet().show(supportFragmentManager, "layer_picker") bottomNav.post { bottomNav.selectedItemId = R.id.nav_map } true } @@ -253,20 +343,18 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bearingCurr = findViewById(R.id.bearing_curr), bearingWaves = findViewById(R.id.bearing_waves), bearingSwell = findViewById(R.id.bearing_swell), - waveView = findViewById(R.id.wave_divider) - ) - instrumentHandler?.updateConditions( - tws = "—", baro = "—", currSpd = "—" + waveView = findViewById(R.id.wave_divider), + unitPrefs = unitPrefs ) } private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() private fun checkForegroundPermissions() { - val fineLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) - val coarseLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + val fineLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + val coarseLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) - if (fineLocationPermission == PackageManager.PERMISSION_GRANTED || coarseLocationPermission == PackageManager.PERMISSION_GRANTED) { + if (fineLocation == PackageManager.PERMISSION_GRANTED || coarseLocation == PackageManager.PERMISSION_GRANTED) { startServices() } else { requestPermissionLauncher.launch(arrayOf( @@ -299,6 +387,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { layerManager = MapLayerManager(this) mapView = findViewById(R.id.mapView) particleWindView = findViewById(R.id.particle_wind_view) + + // Apply persisted particles preference immediately + if (!layerManager.particlesEnabled) { + particleWindView?.visibility = View.GONE + } + if (NavApplication.isTesting) return mapView?.onCreate(null) @@ -325,9 +419,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { maplibreMap.setStyle(styleBuilder) { style -> loadedStyleFlow.value = style - val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) - val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) - val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow) + val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) + val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) + val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow) val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow) mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap) mapHandler?.setupWindLayer(style, windArrowBitmap) @@ -346,19 +440,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { corners.minOf { it.longitude }, corners.maxOf { it.longitude } ) } - // Refresh conditions for current map center (works in both follow + pan mode) maplibreMap.cameraPosition.target?.let { center -> viewModel.loadConditions(center.latitude, center.longitude) } } maplibreMap.addOnMapLongClickListener { _ -> - val currentStyle = loadedStyleFlow.value ?: return@addOnMapLongClickListener true - LayerPickerSheet( - manager = layerManager, - onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) }, - onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) } - ).show(supportFragmentManager, "layer_picker") + buildLayerPickerSheet().show(supportFragmentManager, "layer_picker") true } } @@ -371,8 +459,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat()) viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog) - // HUD — SOG and COG come from GPS - hudSog.text = "%.1f".format(Locale.getDefault(), gpsData.sog) + // HUD — SOG and COG from GPS + lastSogKnots = gpsData.sog + hudSog.text = unitPrefs.formatSpeed(gpsData.sog) hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) if (!conditionsLoaded) { conditionsLoaded = true @@ -400,41 +489,28 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.barometerStatus.collect { status -> if (status.history.isNotEmpty()) { - instrumentHandler?.updateConditions(baro = status.formatPressure()) + lastBaroHpa = status.currentPressureHpa + instrumentHandler?.updateConditions(baroHpa = status.currentPressureHpa) } } } lifecycleScope.launch { viewModel.marineConditions.collect { c -> if (c == null) return@collect - instrumentHandler?.updateConditions( - tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, - twsBearingDeg = c.windDirDeg?.toFloat(), - tempC = c.tempC, - currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—", - currDirDeg = c.currentDirDeg?.toFloat(), - waveHeightM = c.waveHeightM, - waveDirDeg = c.waveDirDeg?.toFloat(), - swellHeightM = c.swellHeightM, - swellDirDeg = c.swellDirDeg?.toFloat(), - swellPeriodS = c.swellPeriodS - ) - val swellHtFt = c.swellHeightM?.let { (it * 3.28084f).toFloat() } ?: 3f - val windHtFt = c.waveHeightM?.let { (it * 3.28084f).toFloat() } ?: 1.5f - val swellPeriod = c.swellPeriodS?.toFloat() ?: 10f - val windKt = c.windSpeedKt?.toFloat() ?: 0f - instrumentHandler?.updateWaveState(swellHtFt, swellPeriod, windHtFt, windKt) + lastConditions = c + applyConditions(c) } } lifecycleScope.launch { LocationService.nmeaDepthDataFlow.collect { depthData -> - // Update HUD depth (converted to feet) - hudDepth.text = "%.1f".format(Locale.getDefault(), depthData.depthMeters * 3.28084) + lastDepthMeters = depthData.depthMeters + hudDepth.text = unitPrefs.formatDepth(depthData.depthMeters) } } lifecycleScope.launch { LocationService.nmeaBoatSpeedData.collect { bspData -> - hudBsp.text = "%.1f".format(Locale.getDefault(), bspData.bspKnots) + lastBspKnots = bspData.bspKnots + hudBsp.text = unitPrefs.formatSpeed(bspData.bspKnots) } } lifecycleScope.launch { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt new file mode 100644 index 0000000..8d60ae0 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt @@ -0,0 +1,83 @@ +package org.terst.nav + +import android.content.Context +import java.util.Locale + +enum class TempUnit { CELSIUS, FAHRENHEIT } +enum class DepthUnit { FEET, METERS } +enum class PressureUnit { HPA, INHG } +enum class SpeedUnit { KNOTS, MPH, KPH } + +/** + * Persists and applies the user's preferred display units. + * Defaults: Fahrenheit, feet, hPa, knots. + */ +class UnitPrefs(context: Context) { + + private val prefs = context.getSharedPreferences("unit_prefs", Context.MODE_PRIVATE) + + var tempUnit: TempUnit + get() = TempUnit.valueOf(prefs.getString(KEY_TEMP, TempUnit.FAHRENHEIT.name)!!) + set(v) { prefs.edit().putString(KEY_TEMP, v.name).apply() } + + var depthUnit: DepthUnit + get() = DepthUnit.valueOf(prefs.getString(KEY_DEPTH, DepthUnit.FEET.name)!!) + set(v) { prefs.edit().putString(KEY_DEPTH, v.name).apply() } + + var pressureUnit: PressureUnit + get() = PressureUnit.valueOf(prefs.getString(KEY_PRESSURE, PressureUnit.HPA.name)!!) + set(v) { prefs.edit().putString(KEY_PRESSURE, v.name).apply() } + + var speedUnit: SpeedUnit + get() = SpeedUnit.valueOf(prefs.getString(KEY_SPEED, SpeedUnit.KNOTS.name)!!) + set(v) { prefs.edit().putString(KEY_SPEED, v.name).apply() } + + fun formatTemp(tempC: Double): String = when (tempUnit) { + TempUnit.CELSIUS -> "%.0f".format(Locale.getDefault(), tempC) + TempUnit.FAHRENHEIT -> "%.0f".format(Locale.getDefault(), tempC * 9.0 / 5.0 + 32.0) + } + + fun tempUnitLabel(): String = when (tempUnit) { + TempUnit.CELSIUS -> "°C" + TempUnit.FAHRENHEIT -> "°F" + } + + fun formatDepth(metres: Double): String = when (depthUnit) { + DepthUnit.FEET -> "%.1f".format(Locale.getDefault(), metres * 3.28084) + DepthUnit.METERS -> "%.1f".format(Locale.getDefault(), metres) + } + + fun depthUnitLabel(): String = when (depthUnit) { + DepthUnit.FEET -> "ft" + DepthUnit.METERS -> "m" + } + + fun formatPressure(hpa: Float): String = when (pressureUnit) { + PressureUnit.HPA -> "%.1f".format(Locale.getDefault(), hpa) + PressureUnit.INHG -> "%.2f".format(Locale.getDefault(), hpa * 0.02953f) + } + + fun pressureUnitLabel(): String = when (pressureUnit) { + PressureUnit.HPA -> "hPa" + PressureUnit.INHG -> "inHg" + } + + fun formatSpeed(knots: Double): String = when (speedUnit) { + SpeedUnit.KNOTS -> "%.1f".format(Locale.getDefault(), knots) + SpeedUnit.MPH -> "%.1f".format(Locale.getDefault(), knots * 1.15078) + SpeedUnit.KPH -> "%.1f".format(Locale.getDefault(), knots * 1.852) + } + + fun speedUnitLabel(): String = when (speedUnit) { + SpeedUnit.KNOTS -> "kt" + SpeedUnit.MPH -> "mph" + SpeedUnit.KPH -> "kph" + } + + companion object { + private const val KEY_TEMP = "temp_unit" + private const val KEY_DEPTH = "depth_unit" + private const val KEY_PRESSURE = "pressure_unit" + private const val KEY_SPEED = "speed_unit" + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt index 48ebb3b..ccb3a91 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt @@ -1,19 +1,11 @@ package org.terst.nav.ui import android.widget.TextView +import org.terst.nav.UnitPrefs import java.util.Locale // ── Pure formatting helpers (top-level for testability) ────────────────────── -private const val M_TO_FT = 3.28084 - -/** Converts metres to feet. */ -fun metresToFeet(metres: Double): Double = metres * M_TO_FT - -/** Formats a feet value to one decimal place. */ -fun formatFt(ft: Double, locale: Locale = Locale.getDefault()): String = - "%.1f".format(locale, ft) - /** Formats a bearing to zero decimal places with a degree symbol. */ fun formatBearing(deg: Double, locale: Locale = Locale.getDefault()): String = "%.0f°".format(locale, deg) @@ -31,11 +23,9 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly * in MainActivity via the HUD strip. * - * Units contract: - * - Speed values: pre-formatted strings in knots (caller's responsibility) - * - Height values: caller passes raw metres; this class converts to feet - * - Bearing values: raw degrees as Float, rotated into arrows and formatted - * into bearing TextViews by this class + * All numeric values are accepted in SI / nautical base units: + * speeds in knots, heights in metres, pressure in hPa, temperature in °C. + * [unitPrefs] is consulted on each update to produce the correct formatted string. */ class InstrumentHandler( // ── Conditions header ──────────────────────────────────────────────── @@ -58,47 +48,60 @@ class InstrumentHandler( private val bearingWaves: TextView, private val bearingSwell: TextView, // ── Wave view ──────────────────────────────────────────────────────── - private val waveView: WaveView + private val waveView: WaveView, + // ── Unit preferences ───────────────────────────────────────────────── + private val unitPrefs: UnitPrefs ) { init { arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN + valueTws.text = "—" + valueTemp.text = "—" + valueBaro.text = "—" + valueCurrSpd.text = "—" + valueWaveHt.text = "—" + valueSwellHt.text = "—" } /** - * Updates all area-conditions fields. - * Null arguments leave the current value unchanged. - * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally. + * Updates all area-conditions fields. Null arguments leave the current value unchanged. + * + * @param twsKt True wind speed in knots + * @param tempC Air temperature in degrees Celsius + * @param baroHpa Barometric pressure in hPa + * @param currSpdKt Ocean current speed in knots + * @param waveHeightM Wind-wave height in metres + * @param swellHeightM Swell height in metres */ fun updateConditions( - tws: String? = null, twsBearingDeg: Float? = null, + twsKt: Double? = null, twsBearingDeg: Float? = null, tempC: Double? = null, - baro: String? = null, - currSpd: String? = null, currDirDeg: Float? = null, + baroHpa: Float? = null, + currSpdKt: Double? = null, currDirDeg: Float? = null, waveHeightM: Double? = null, waveDirDeg: Float? = null, swellHeightM: Double? = null, swellDirDeg: Float? = null, swellPeriodS: Double? = null ) { - tws?.let { valueTws.text = it } - baro?.let { valueBaro.text = it } - tempC?.let { valueTemp.text = "%.0f".format(Locale.getDefault(), it) } + twsKt?.let { valueTws.text = unitPrefs.formatSpeed(it) } + tempC?.let { valueTemp.text = unitPrefs.formatTemp(it) } + baroHpa?.let { valueBaro.text = unitPrefs.formatPressure(it) } twsBearingDeg?.let { arrowTws.bearing = it bearingTws.text = formatBearing(it.toDouble()) } - currSpd?.let { valueCurrSpd.text = it } + currSpdKt?.let { valueCurrSpd.text = unitPrefs.formatSpeed(it) } currDirDeg?.let { arrowCurr.bearing = it bearingCurr.text = formatBearing(it.toDouble()) } - waveHeightM?.let { valueWaveHt.text = formatFt(metresToFeet(it)) } + waveHeightM?.let { valueWaveHt.text = unitPrefs.formatDepth(it) } waveDirDeg?.let { arrowWaves.bearing = it bearingWaves.text = formatBearing(it.toDouble()) } - swellHeightM?.let { valueSwellHt.text = formatFt(metresToFeet(it)) } + swellHeightM?.let { valueSwellHt.text = unitPrefs.formatDepth(it) } swellDirDeg?.let { arrowSwell.bearing = it bearingSwell.text = formatBearing(it.toDouble()) @@ -108,26 +111,28 @@ class InstrumentHandler( /** * Updates the WaveView with current sea state. Call once when conditions load. - * - * View height is set here to reflect swell scale: 1ft → 56dp, 8ft → 160dp. + * Heights are passed in metres; the WaveView scale is computed internally. * [windSpeedKt] gates whitecap rendering (Beaufort 4 threshold = 12 kt). */ fun updateWaveState( - swellHeightFt: Float, + swellHeightM: Float, swellPeriodSec: Float, - windWaveHeightFt: Float, + windWaveHeightM: Float, windSpeedKt: Float = 0f ) { - waveView.swellHeightFt = swellHeightFt + val swellHtFt = swellHeightM * 3.28084f + val windWaveHtFt = windWaveHeightM * 3.28084f + + waveView.swellHeightFt = swellHtFt waveView.swellPeriodSec = swellPeriodSec - waveView.windWaveHeightFt = windWaveHeightFt + waveView.windWaveHeightFt = windWaveHtFt waveView.windSpeedKt = windSpeedKt // Size the view to the swell — bigger swell = taller window - val density = waveView.resources.displayMetrics.density - val heightDp = (32f + swellHeightFt * 16f).coerceIn(56f, 160f) - val lp = waveView.layoutParams - lp.height = (heightDp * density).toInt() + val density = waveView.resources.displayMetrics.density + val heightDp = (32f + swellHtFt * 16f).coerceIn(56f, 160f) + val lp = waveView.layoutParams + lp.height = (heightDp * density).toInt() waveView.layoutParams = lp } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt index 48dc808..78fd70f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt @@ -7,30 +7,33 @@ import android.view.ViewGroup import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.ChipGroup import com.google.android.material.switchmaterial.SwitchMaterial +import org.terst.nav.DepthUnit +import org.terst.nav.PressureUnit import org.terst.nav.R +import org.terst.nav.SpeedUnit +import org.terst.nav.TempUnit +import org.terst.nav.UnitPrefs class LayerPickerSheet( private val manager: MapLayerManager, private val onBaseChanged: (MapBasePreset) -> Unit, - private val onWindChanged: (Boolean) -> Unit + private val onWindChanged: (Boolean) -> Unit, + private val onParticlesChanged: (Boolean) -> Unit, + private val unitPrefs: UnitPrefs, + private val onUnitsChanged: () -> Unit ) : BottomSheetDialogFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = inflater.inflate(R.layout.layout_layer_picker_sheet, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val chipGroup = view.findViewById(R.id.chip_group_base) - val windSwitch = view.findViewById(R.id.switch_wind) - - // Set initial state from manager - val chipId = when (manager.basePreset) { + // ── Base layer chips ────────────────────────────────────────────────── + val chipGroup = view.findViewById(R.id.chip_group_base) + chipGroup.check(when (manager.basePreset) { MapBasePreset.SATELLITE -> R.id.chip_satellite MapBasePreset.CHARTS -> R.id.chip_charts MapBasePreset.HYBRID -> R.id.chip_hybrid - } - chipGroup.check(chipId) - windSwitch.isChecked = manager.windEnabled - + }) chipGroup.setOnCheckedStateChangeListener { _, checkedIds -> val preset = when (checkedIds.firstOrNull()) { R.id.chip_satellite -> MapBasePreset.SATELLITE @@ -41,8 +44,76 @@ class LayerPickerSheet( onBaseChanged(preset) } - windSwitch.setOnCheckedChangeListener { _, checked -> - onWindChanged(checked) + // ── Wind overlay switch ─────────────────────────────────────────────── + val windSwitch = view.findViewById(R.id.switch_wind) + windSwitch.isChecked = manager.windEnabled + windSwitch.setOnCheckedChangeListener { _, checked -> onWindChanged(checked) } + + // ── Particles switch ────────────────────────────────────────────────── + val particlesSwitch = view.findViewById(R.id.switch_particles) + particlesSwitch.isChecked = manager.particlesEnabled + particlesSwitch.setOnCheckedChangeListener { _, checked -> onParticlesChanged(checked) } + + // ── Temperature chips ───────────────────────────────────────────────── + val chipTemp = view.findViewById(R.id.chip_group_temp) + chipTemp.check(when (unitPrefs.tempUnit) { + TempUnit.FAHRENHEIT -> R.id.chip_fahrenheit + TempUnit.CELSIUS -> R.id.chip_celsius + }) + chipTemp.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.tempUnit = when (ids.firstOrNull()) { + R.id.chip_fahrenheit -> TempUnit.FAHRENHEIT + R.id.chip_celsius -> TempUnit.CELSIUS + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Depth chips ─────────────────────────────────────────────────────── + val chipDepth = view.findViewById(R.id.chip_group_depth) + chipDepth.check(when (unitPrefs.depthUnit) { + DepthUnit.FEET -> R.id.chip_feet + DepthUnit.METERS -> R.id.chip_meters + }) + chipDepth.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.depthUnit = when (ids.firstOrNull()) { + R.id.chip_feet -> DepthUnit.FEET + R.id.chip_meters -> DepthUnit.METERS + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Pressure chips ──────────────────────────────────────────────────── + val chipPressure = view.findViewById(R.id.chip_group_pressure) + chipPressure.check(when (unitPrefs.pressureUnit) { + PressureUnit.HPA -> R.id.chip_hpa + PressureUnit.INHG -> R.id.chip_inhg + }) + chipPressure.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.pressureUnit = when (ids.firstOrNull()) { + R.id.chip_hpa -> PressureUnit.HPA + R.id.chip_inhg -> PressureUnit.INHG + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } + + // ── Speed chips ─────────────────────────────────────────────────────── + val chipSpeed = view.findViewById(R.id.chip_group_speed) + chipSpeed.check(when (unitPrefs.speedUnit) { + SpeedUnit.KNOTS -> R.id.chip_knots + SpeedUnit.MPH -> R.id.chip_mph + SpeedUnit.KPH -> R.id.chip_kph + }) + chipSpeed.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.speedUnit = when (ids.firstOrNull()) { + R.id.chip_knots -> SpeedUnit.KNOTS + R.id.chip_mph -> SpeedUnit.MPH + R.id.chip_kph -> SpeedUnit.KPH + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() } } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt index 83cfa70..855233f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt @@ -26,6 +26,9 @@ class MapLayerManager(context: Context) { var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true) private set + var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, true) + private set + // ── Source / Layer IDs ──────────────────────────────────────────────────── companion object { @@ -39,8 +42,9 @@ class MapLayerManager(context: Context) { const val LAYER_WIND = "wind-layer" const val LAYER_SEAMARKS = "openseamap-layer" - private const val KEY_BASE = "base_preset" - private const val KEY_WIND = "wind_enabled" + private const val KEY_BASE = "base_preset" + private const val KEY_WIND = "wind_enabled" + private const val KEY_PARTICLES = "particles_enabled" // Tile URLs private const val URL_SATELLITE = @@ -106,6 +110,12 @@ class MapLayerManager(context: Context) { } } + /** Toggle wind particles (Canvas overlay — no style interaction needed). */ + fun setParticlesEnabled(enabled: Boolean) { + particlesEnabled = enabled + prefs.edit().putBoolean(KEY_PARTICLES, enabled).apply() + } + /** Toggle wind overlay on a live style. */ fun setWindEnabled(style: Style, enabled: Boolean) { windEnabled = enabled diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 5147506..5943949 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -90,10 +90,10 @@ android:visibility="gone" app:cornerRadius="20dp" app:elevation="20dp" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@id/nav_hud" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - android:layout_marginBottom="24dp" /> + android:layout_marginTop="8dp" /> diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml index faec826..03cefb7 100644 --- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml +++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml @@ -56,7 +56,7 @@ android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="15.5" /> - + - + @@ -123,7 +123,7 @@ android:id="@+id/value_baro" style="@style/InstrumentPrimaryValue" tools:text="1013" /> - + @@ -175,7 +175,7 @@ android:id="@+id/value_curr_spd" style="@style/ForecastValue" tools:text="0.8" /> - + - + - + - - - - - - - - - + + + + + + + android:layout_marginBottom="12dp" + android:text="MAP LAYERS" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:fontFamily="sans-serif-light" + android:textColor="@color/instrument_text_secondary" /> - + + android:layout_marginBottom="20dp" + app:singleSelection="true" + app:selectionRequired="true"> - + + + + + + + + + + + + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> - + + + + - - + - - + + + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="8dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + app:singleSelection="true" + app:selectionRequired="true"> + + + - + + + + + + + + + - - + diff --git a/android-app/app/src/main/res/layout/layout_nav_hud.xml b/android-app/app/src/main/res/layout/layout_nav_hud.xml index 4623bb1..90119fa 100644 --- a/android-app/app/src/main/res/layout/layout_nav_hud.xml +++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml @@ -39,6 +39,7 @@ android:fontFamily="sans-serif-medium" tools:text="7.1" /> Date: Fri, 10 Apr 2026 22:47:27 +0000 Subject: Add Learn tab; move quit to Safety; remove persistent MOB FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI cleanup: - Remove always-visible MOB FAB — Safety screen button is sufficient - Remove floating quit button; move to bottom of Safety screen - fragment_container is now clickable/focusable, blocking map touch-through for all overlays (trip reports, log, safety) - Record Track FAB hidden while any overlay is shown, visible on map tab New Learn tab (5th nav item): - Migration guides for Navionics and Sea People (local markdown via DocFragment) - ASA Course Catalog, ASA Online Learning, ColRegs, Sailing Flashcards (open in browser) - Remove blanket assets/ .gitignore so markdown docs are tracked https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- android-app/.gitignore | 4 +- .../app/src/main/assets/docs/migrate_navionics.md | 56 +++++ .../app/src/main/assets/docs/migrate_sea_people.md | 59 ++++++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 20 +- .../kotlin/org/terst/nav/ui/learn/LearnFragment.kt | 56 +++++ .../org/terst/nav/ui/safety/SafetyFragment.kt | 5 + android-app/app/src/main/res/drawable/ic_learn.xml | 9 + .../app/src/main/res/layout/activity_main.xml | 35 +-- .../app/src/main/res/layout/fragment_learn.xml | 235 +++++++++++++++++++++ .../app/src/main/res/layout/fragment_safety.xml | 11 + .../app/src/main/res/menu/bottom_nav_menu.xml | 4 + 11 files changed, 450 insertions(+), 44 deletions(-) create mode 100644 android-app/app/src/main/assets/docs/migrate_navionics.md create mode 100644 android-app/app/src/main/assets/docs/migrate_sea_people.md create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt create mode 100644 android-app/app/src/main/res/drawable/ic_learn.xml create mode 100644 android-app/app/src/main/res/layout/fragment_learn.xml (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/.gitignore b/android-app/.gitignore index 7db1cd2..acd3de6 100644 --- a/android-app/.gitignore +++ b/android-app/.gitignore @@ -11,8 +11,8 @@ build/ *.iws local.properties -# Generated files -app/src/main/assets/ +# Generated assets (add specific patterns here if needed) +# app/src/main/assets/ — removed blanket ignore; docs are tracked # Keystore *.jks diff --git a/android-app/app/src/main/assets/docs/migrate_navionics.md b/android-app/app/src/main/assets/docs/migrate_navionics.md new file mode 100644 index 0000000..83b28fa --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_navionics.md @@ -0,0 +1,56 @@ +# Migrating from Navionics + +Welcome to Nav. This guide covers the key differences and how to move your data over. + +--- + +## What's Different + +| Feature | Navionics | Nav | +|---|---|---| +| Charts | Navionics SonarChart + Raster | OpenFreeMap vector tiles | +| Track recording | In-app GPX | GPX saved to Documents/Nav/ | +| Trip log | Voyage log | Voice log (transcribed) | +| Departure planning | None | Pre-trip briefing with sail plan | +| Wind overlay | Basic | Wind particles + hourly forecast | + +--- + +## Exporting Your Tracks from Navionics + +1. Open Navionics on your phone or tablet +2. Go to **My Charts → Tracks** +3. Tap a track → **Share → Export GPX** +4. Save or AirDrop the `.gpx` file to your device + +--- + +## Importing Tracks into Nav + +Nav automatically reads GPX files from the **Documents/Nav/** folder on your device. + +1. Move your exported `.gpx` files to `Documents/Nav/` using the Files app +2. Re-open Nav — tracks are loaded on startup +3. Similar-conditions trip comparison in the Pre-Trip Briefing will use these tracks + +--- + +## Waypoints + +Nav does not currently manage waypoints. Use Navionics or a dedicated chart plotter for waypoint routing. Nav focuses on departure briefing, track recording, and the sail log. + +--- + +## Charts + +Nav uses **OpenFreeMap** vector tiles. These are free, fast, and work well for coastal cruising. They do not include depth contours or hazard overlays — continue using Navionics or a NOAA chart app for navigation in unfamiliar waters. + +--- + +## Daily Workflow + +**Before you sail:** Open the **Safety → Plan Trip** screen for your departure briefing — sail plan, heading recommendation, watch items, and a look at how conditions will change over your trip window. + +**On the water:** Tap **Log** to dictate voice notes. The GPS track records automatically when you tap the record button. + +**After you return:** The post-trip report summarises distance, duration, and max SOG. diff --git a/android-app/app/src/main/assets/docs/migrate_sea_people.md b/android-app/app/src/main/assets/docs/migrate_sea_people.md new file mode 100644 index 0000000..b1920b4 --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_sea_people.md @@ -0,0 +1,59 @@ +# Migrating from Sea People + +Welcome to Nav. This guide covers what transfers over and what works differently. + +--- + +## What's Different + +| Feature | Sea People | Nav | +|---|---|---| +| Social feed | Community posts | Not applicable — Nav is single-user | +| Trip log | Text entries + photos | Voice log (transcribed audio) | +| Boat profiles | Basic boat card | Detailed sail inventory with reef points | +| Weather | Integrated forecast | Open-Meteo hourly + NOAA marine | +| Departure planning | Manual notes | Pre-trip briefing with sail plan & route | + +--- + +## Exporting Your Log from Sea People + +1. Open Sea People → **Profile → My Logs** +2. Use the **Export** option (CSV or PDF depending on your version) +3. Save entries you want to keep for reference + +Nav's voice log works differently — entries are dictated on the water and transcribed. You won't be able to import Sea People entries directly, but you can review them alongside Nav going forward. + +--- + +## Boat Profiles + +Nav stores a detailed boat profile with your sail inventory: + +- **Headsails** with wind range for each (e.g. 155% Genoa ≤ 13 kt, 100% Jib 10–21 kt, 65% Blade 18+ kt) +- **Main reef points** +- **Hull length** (used for hull-speed estimates in the departure briefing) + +Your boats are pre-seeded on first launch. Profiles are stored locally and used in the **Pre-Trip Briefing** to give sail recommendations specific to your boat. + +--- + +## Trip Log Workflow + +**On the water:** Tap the **Log** tab and use the microphone button to dictate a note. Notes are timestamped with your GPS position. + +**Voice notes work well for:** +- Sea state observations ("1.5m swell, 10s period, comfortable") +- Traffic ("fishing vessel on starboard, gave way") +- Sail changes ("reefed main at 18 kt") +- Time stamps ("rounded the point, heading west") + +--- + +## Daily Workflow + +**Before you sail:** Safety → Plan Trip for your departure briefing. + +**On the water:** Log tab for voice notes; tap the record FAB to start the GPS track. + +**After you return:** The post-trip report generates a narrative summary you can save or share. diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 86dd531..996892e 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 @@ -54,9 +54,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomSheetBehavior: BottomSheetBehavior private lateinit var fragmentContainer: FrameLayout private lateinit var fabRecordTrack: FloatingActionButton - private lateinit var fabMob: FloatingActionButton private lateinit var fabRecenter: MaterialButton - private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private lateinit var mapCrosshair: View @@ -117,9 +115,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun initializeUI() { fragmentContainer = findViewById(R.id.fragment_container) fabRecordTrack = findViewById(R.id.fab_record_track) - fabMob = findViewById(R.id.fab_mob) fabRecenter = findViewById(R.id.fab_recenter) - btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) mapCrosshair = findViewById(R.id.map_crosshair) @@ -166,9 +162,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } else false } - fabMob.setOnClickListener { onActivateMob() } fabRecenter.setOnClickListener { mapHandler?.recenter() } - btnQuit.setOnClickListener { onQuitRequested() } lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -280,6 +274,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN true } + R.id.nav_learn -> { + showOverlay(org.terst.nav.ui.learn.LearnFragment()) + bottomSheetBehavior.isHideable = true + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + true + } else -> false } } @@ -287,6 +287,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun showOverlay(fragment: androidx.fragment.app.Fragment) { fragmentContainer.visibility = View.VISIBLE + fabRecordTrack.visibility = View.GONE supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, fragment) .commit() @@ -294,9 +295,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun hideOverlays() { fragmentContainer.visibility = View.GONE + fabRecordTrack.visibility = View.VISIBLE } - private fun onQuitRequested() { + override fun onQuitRequested() { if (viewModel.isRecording.value) { androidx.appcompat.app.AlertDialog.Builder(this) .setMessage("Recording in progress. Quit and discard the current track?") @@ -405,9 +407,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE if (following) { fadeOut(fabRecenter, gone = true) - fadeIn(bottomNav, fabMob, fabRecordTrack) + fadeIn(bottomNav, fabRecordTrack) } else { - fadeOut(bottomNav, fabMob, fabRecordTrack, gone = true) + fadeOut(bottomNav, fabRecordTrack, gone = true) fadeIn(fabRecenter) } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt new file mode 100644 index 0000000..8440edb --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt @@ -0,0 +1,56 @@ +package org.terst.nav.ui.learn + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.card.MaterialCardView +import org.terst.nav.R +import org.terst.nav.ui.doc.DocFragment + +class LearnFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_learn, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Migration guides — open local markdown docs + view.findViewById(R.id.card_migrate_navionics).setOnClickListener { + openDoc("docs/migrate_navionics.md") + } + view.findViewById(R.id.card_migrate_seapeople).setOnClickListener { + openDoc("docs/migrate_sea_people.md") + } + + // ASA / external links — open in browser + view.findViewById(R.id.card_asa_courses).setOnClickListener { + openUrl("https://www.asa.com/courses/") + } + view.findViewById(R.id.card_asa_online).setOnClickListener { + openUrl("https://www.asa.com/online-courses/") + } + view.findViewById(R.id.card_colregs).setOnClickListener { + openUrl("https://www.navcen.uscg.gov/international-regulations-for-preventing-collisions-at-sea") + } + view.findViewById(R.id.card_flashcards).setOnClickListener { + openUrl("https://quizlet.com/subject/sailing/") + } + } + + private fun openDoc(path: String) { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, DocFragment.newInstance(path)) + .addToBackStack(null) + .commit() + } + + private fun openUrl(url: String) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt index 4bc0c7a..6dd7411 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt @@ -15,6 +15,7 @@ class SafetyFragment : Fragment() { interface SafetyListener { fun onActivateMob() fun onConfigureAnchor() + fun onQuitRequested() } private var listener: SafetyListener? = null @@ -55,6 +56,10 @@ class SafetyFragment : Fragment() { .addToBackStack(null) .commit() } + + view.findViewById(R.id.button_quit).setOnClickListener { + listener?.onQuitRequested() + } } fun updateAnchorStatus(statusText: String) { diff --git a/android-app/app/src/main/res/drawable/ic_learn.xml b/android-app/app/src/main/res/drawable/ic_learn.xml new file mode 100644 index 0000000..1574693 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_learn.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 5943949..1bb88b3 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,26 +59,10 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" + android:clickable="true" + android:focusable="true" android:background="?attr/colorSurface" /> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml index f90420e..60ea1aa 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -113,4 +113,15 @@ android:text="PLAN TRIP (PRE-TRIP REPORT)" app:layout_constraintTop_toBottomOf="@id/card_anchor" /> + + diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml index e7fc15d..3037b7e 100644 --- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml +++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml @@ -16,4 +16,8 @@ android:id="@+id/nav_safety" android:icon="@drawable/ic_safety" android:title="Safety" /> + -- cgit v1.2.3 From e4ae5ac3a83f1da940ab33c93da4ed3e03cd7f14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 09:53:34 +0000 Subject: Fix bottom sheet corners; add full light/dark mode palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounded corners: - Replace androidx CardView with MaterialCardView + ShapeAppearance.Nav.BottomSheet so only the top-left and top-right corners are rounded (24dp); bottom corners are square flush with the screen edge Light/dark mode: - Introduce values-night/colors.xml with the existing dark M3 palette (moved from values/colors.xml); instrument values stay dark (#E6E1E5 text, #1C1B1F bg) - values/colors.xml now carries the light M3 palette: surface #FFFBFE, onSurface #1C1B1F, surfaceVariant #E7E0EB etc.; instrument text flips to dark (#1C1B1F normal, #6F6878 secondary) for readability on light backgrounds - Fix InstrumentUnit hardcoded #6B6070 → ?attr/colorOnSurfaceVariant so the unit labels adapt automatically in both modes - Mark the forecast row (ocean navy #0D2137) and WaveView with android:forceDarkAllowed="false" so the system never inverts them — the ocean section is intentionally always dark regardless of system theme https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../app/src/main/res/layout/activity_main.xml | 6 ++-- .../main/res/layout/layout_instruments_sheet.xml | 6 ++-- .../app/src/main/res/values-night/colors.xml | 32 +++++++++++++++++++ android-app/app/src/main/res/values/colors.xml | 36 +++++++++++----------- android-app/app/src/main/res/values/themes.xml | 11 ++++++- 5 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 android-app/app/src/main/res/values-night/colors.xml (limited to 'android-app/app/src/main/res/layout/activity_main.xml') diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 1bb88b3..5f1c8c2 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -82,14 +82,14 @@ - - + - + - + + + + #A8C8FF + #00315E + #004886 + #D6E3FF + + #BBC7DB + #253140 + #3B4858 + #DAE2F9 + + #FFB4AB + #690005 + #93000A + #FFDAD6 + + #1C1B1F + #E6E1E5 + #1C1B1F + #E6E1E5 + #49454F + #CAC4D0 + #938F99 + + + #E6E1E5 + #9A94A0 + #1C1B1F + #2B2930 + diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index eb62cb2..a470c0c 100755 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -1,28 +1,28 @@ - + #005FB0 #FFFFFF #D6E3FF #001B3E - + #565F71 #FFFFFF #DAE2F9 #131C2C - + #BA1A1A #FFFFFF #FFDAD6 #410002 - - #1C1B1F - #E6E1E5 - #1C1B1F - #E6E1E5 - #49454F - #CAC4D0 - #938F99 + + #FFFBFE + #1C1B1F + #FFFBFE + #1C1B1F + #E7E0EB + #49454F + #7A757F #FF000000 @@ -31,13 +31,13 @@ #002171 #FF6D00 - - #E6E1E5 - #9A94A0 - #FF5449 - #49454F - #1C1B1F - #2B2930 + + #1C1B1F + #6F6878 + #BA1A1A + #9A94A0 + #FFFBFE + #F2EDF7 #BA1A1A #005FB0 diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml index 8baa509..a48a721 100755 --- a/android-app/app/src/main/res/values/themes.xml +++ b/android-app/app/src/main/res/values/themes.xml @@ -63,7 +63,7 @@ + + +