diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-30 17:16:06 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-30 18:19:04 +0000 |
| commit | 9af38934e8d676bfb8c08ab01e3d57fec5f36d97 (patch) | |
| tree | 67b5bdfffb596d63fb39d17465704bb97ad6bf8a | |
| parent | 36efb5a8b9502d0def41c0cdc17ed894744c8562 (diff) | |
feat: UX improvements + HAW FAD data + fishing mode plan
Dark mode:
- Force light mode (AppCompatDelegate.MODE_NIGHT_NO) in NavApplication
Log screen:
- Tracks sorted newest-first (sortedByDescending startMs)
- Photos/entries saved during a track tagged with trackId; ship's log
shows only standalone entries (trackId == null)
- "Plan Trip" button moved from safety screen to log screen header row
- Pre-trip report auto-generates (newAutoInstance) when a track starts
Safety screen:
- Removed "Plan Trip" button (now in log screen)
- Quit button moved to top title row
- Anchor Watch section collapses by default (tap header to expand)
- Hardware Data Sources section collapses by default (tap header to expand)
Notifications + idle:
- Tracking notification becomes persistent (setOngoing true) while recording
- Notification title changes to "Recording track" when active
- App exits automatically after 3 hours of idle (resetIdleTimer via
onUserInteraction + onResume; suspended while recording)
FAD layer:
- FadData.kt: 18 Hawaii Island DLNR FAD coordinates hardcoded
(source: DLNR Division of Aquatic Resources 6/14/07)
- FAD GeoJSON populated on style load from static data
Fishing mode plan:
- docs/superpowers/plans/2026-06-30-fishing-mode.md: 7-task plan for
RigAdvisor, SST tiles, FishingModeManager, FishingOverlayView, steering
bearing line, and FAB integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 files changed, 1034 insertions, 264 deletions
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 c1ff60e..3c89fc5 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 @@ -301,6 +301,11 @@ class LocationService : Service() { Log.d("LocationService", "Stopping NMEA stream") nmeaStreamManager.stop() } + ACTION_SET_RECORDING -> { + val recording = intent.getBooleanExtra(EXTRA_IS_RECORDING, false) + val mgr = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager + mgr.notify(NOTIFICATION_ID, createNotification(recording)) + } } return START_NOT_STICKY } @@ -369,14 +374,17 @@ class LocationService : Service() { manager.createNotificationChannel(serviceChannel) } - private fun createNotification(): Notification { + private fun createNotification(recording: Boolean = false): Notification { val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) + val title = if (recording) "Recording track" else "Sailing Companion" + val text = if (recording) "Track recording in progress — tap to open" else "Tracking your location..." return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setContentTitle("Sailing Companion") - .setContentText("Tracking your location...") + .setContentTitle(title) + .setContentText(text) .setSmallIcon(R.drawable.ic_anchor) .setContentIntent(pendingIntent) + .setOngoing(recording) .build() } @@ -425,8 +433,10 @@ class LocationService : Service() { const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY" const val ACTION_START_NMEA = "ACTION_START_NMEA" const val ACTION_STOP_NMEA = "ACTION_STOP_NMEA" + const val ACTION_SET_RECORDING = "ACTION_SET_RECORDING" const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" const val EXTRA_WATCH_RADIUS = "extra_watch_radius" + const val EXTRA_IS_RECORDING = "extra_is_recording" private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1) val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow 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 ea45c36..fcfc96a 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 @@ -79,6 +79,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var weatherLoaded = false private var windGridLoaded = false private var cameraIdleJob: Job? = null + private var idleJob: Job? = null private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> private lateinit var fragmentContainer: FrameLayout @@ -136,6 +137,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } else { restoreLocationMode() } + resetIdleTimer() } override fun onPause() { @@ -144,6 +146,20 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { throttleLocationIfIdle() } + override fun onUserInteraction() { + super.onUserInteraction() + resetIdleTimer() + } + + private fun resetIdleTimer() { + idleJob?.cancel() + if (viewModel.isRecording.value) return // never time out while recording + idleJob = lifecycleScope.launch { + delay(3 * 60 * 60 * 1000L) // 3 hours of no interaction + exitApp() + } + } + override fun onCreate(savedInstanceState: Bundle?) { if (isNightMode) setTheme(R.style.Theme_Nav_NightVision) super.onCreate(savedInstanceState) @@ -266,7 +282,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } fabRecordTrack.setOnClickListener { - if (!viewModel.isRecording.value) viewModel.startTrack() + if (!viewModel.isRecording.value) { + viewModel.startTrack() + showOverlay(org.terst.nav.tripreport.PreTripReportFragment.newAutoInstance()) + } } val tvTrackStats = findViewById<TextView>(R.id.tv_track_stats) @@ -665,6 +684,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap) mapHandler?.setupWindLayer(style, windArrowBitmap) mapHandler?.setupFadLayer(style, layerManager.fadsEnabled) + mapHandler?.updateFadLayer(FadData.toFeatures()) // Eagerly load conditions at style-ready time so the HUD is never blank // on startup — the camera-idle may fire before GPS fixes, this is the backstop. maplibreMap.cameraPosition.target?.let { center -> @@ -758,6 +778,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { val action = if (recording) LocationService.ACTION_START_FULL else LocationService.ACTION_START_ECONOMY startService(locationServiceIntent(action)) + // Update foreground notification to show recording state + startService(Intent(this@MainActivity, LocationService::class.java).apply { + this.action = LocationService.ACTION_SET_RECORDING + putExtra(LocationService.EXTRA_IS_RECORDING, recording) + }) + // Recording keeps the idle timer suspended; non-recording restarts it + resetIdleTimer() } } var conditionsLoaded = false diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt index 3873002..8e6bdfa 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt @@ -1,6 +1,7 @@ package org.terst.nav import android.app.Application +import androidx.appcompat.app.AppCompatDelegate import com.google.firebase.crashlytics.FirebaseCrashlytics import org.terst.nav.db.NavDatabase import org.terst.nav.settings.FeatureFlags @@ -38,6 +39,7 @@ class NavApplication : Application() { override fun onCreate() { super.onCreate() + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) featureFlags = FeatureFlags(this) database = NavDatabase.get(this) logbookRepository = org.terst.nav.logbook.LogbookRepository(org.terst.nav.logbook.LogbookStorage(this)) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt index c038547..46c8869 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt @@ -9,5 +9,6 @@ data class LogEntry( val entryType: EntryType, val lat: Double? = null, val lon: Double? = null, - val photoPath: String? = null // absolute file path or content URI string + val photoPath: String? = null, // absolute file path or content URI string + val trackId: Long? = null // non-null when entry belongs to a recorded track ) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt index 67e1062..833a42b 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt @@ -8,7 +8,7 @@ class VoiceLogViewModel(private val repository: LogbookRepository) { private val _state = MutableStateFlow<VoiceLogState>(VoiceLogState.Idle) val state: StateFlow<VoiceLogState> = _state - private val _entries = MutableStateFlow<List<LogEntry>>(repository.getAll().reversed()) + private val _entries = MutableStateFlow<List<LogEntry>>(standalonEntries()) val entries: StateFlow<List<LogEntry>> = _entries fun onListeningStarted() { @@ -23,7 +23,7 @@ class VoiceLogViewModel(private val repository: LogbookRepository) { _state.value = VoiceLogState.Error(message) } - fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null) { + fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null, trackId: Long? = null) { if (text.isBlank() && photoPath == null) return val entry = LogEntry( timestampMs = System.currentTimeMillis(), @@ -31,14 +31,18 @@ class VoiceLogViewModel(private val repository: LogbookRepository) { entryType = EntryType.GENERAL, lat = lat, lon = lon, - photoPath = photoPath + photoPath = photoPath, + trackId = trackId ) val saved = repository.save(entry) - _entries.value = repository.getAll().reversed() + _entries.value = standalonEntries() _state.value = VoiceLogState.Saved(saved) } fun retry() { _state.value = VoiceLogState.Idle } + + private fun standalonEntries() = + repository.getAll().filter { it.trackId == null }.reversed() } 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 4648981..61a63eb 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 @@ -165,7 +165,7 @@ class TrackRepository(context: Context, private val trackDao: TrackDao) { .onFailure { e -> NavLogger.e("track", "toSavedTrack failed: ${e.javaClass.simpleName}: ${e.message}") } .getOrNull() } - .sortedBy { it.startMs } + .sortedByDescending { it.startMs } } /** @@ -173,7 +173,7 @@ class TrackRepository(context: Context, private val trackDao: TrackDao) { * with only start/end placeholder points. Use [getSavedTracks] for full point data. */ suspend fun getSavedTracksFromRoom(): List<SavedTrack> = withContext(Dispatchers.IO) { - trackDao.getAll().map { it.toShallowSavedTrack() }.sortedBy { it.startMs } + trackDao.getAll().map { it.toShallowSavedTrack() }.sortedByDescending { it.startMs } } fun safState(): SafState = storage.safState() diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt index ac1350d..9606398 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt @@ -30,6 +30,15 @@ import java.util.Locale class PreTripReportFragment : Fragment() { + companion object { + private const val ARG_AUTO = "auto_generate" + + /** Opens pre-trip report and auto-generates from current conditions immediately. */ + fun newAutoInstance() = PreTripReportFragment().apply { + arguments = Bundle().also { it.putBoolean(ARG_AUTO, true) } + } + } + private val mainViewModel: MainViewModel by activityViewModels() private lateinit var viewModel: PreTripReportViewModel @@ -70,7 +79,8 @@ class PreTripReportFragment : Fragment() { btnGenerate.setOnClickListener { triggerGenerate() } btnPickDeparture.setOnClickListener { showDeparturePicker() } - if (mainViewModel.forecast.value.isNotEmpty()) { + val autoGenerate = arguments?.getBoolean(ARG_AUTO, false) == true + if (autoGenerate || mainViewModel.forecast.value.isNotEmpty()) { triggerGenerate() } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/FadData.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/FadData.kt new file mode 100644 index 0000000..0b3fac0 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/FadData.kt @@ -0,0 +1,42 @@ +package org.terst.nav.ui + +import org.maplibre.geojson.Feature +import org.maplibre.geojson.Point + +/** + * Hawaii Island DLNR Fish Aggregating Device (FAD) locations. + * Source: DLNR Division of Aquatic Resources — coordinates in decimal degrees. + * Contact: Hawaii Institute of Marine Biology / DAR 808-327-6226 (Kona), 808-974-6201 (Hilo). + */ +object FadData { + + data class Fad(val id: String, val lat: Double, val lon: Double, val depthFathoms: Int) + + val hawaiiIsland: List<Fad> = listOf( + Fad("A", 18.0 + 57.35 / 60, -(155.0 + 33.4 / 60), 700), // Ka Lae (South Pt.) + Fad("B", 19.0 + 11.9 / 60, -(155.0 + 56.9 / 60), 850), // Miloli'i + Fad("C", 19.0 + 23.1 / 60, -(155.0 + 59.2 / 60), 969), // Lae Loa (Loa Pt.) + Fad("D", 19.0 + 37.5 / 60, -(154.0 + 46.7 / 60), 950), // Kumukahi + Fad("E", 19.0 + 46.1 / 60, -(154.0 + 54.8 / 60), 920), // Lele'iwi + Fad("F", 19.0 + 30.4 / 60, -(156.0 + 9.4 / 60), 1592), // Kailua-Kona + Fad("G", 19.0 + 50.7 / 60, -(154.0 + 53.3 / 60), 578), // Pepe'ekeo + Fad("HK", 19.0 + 58.64 / 60, -(154.0 + 59.0 / 60), 890), // Hakalau + Fad("KH", 19.0 + 20.9 / 60, -(154.0 + 52.8 / 60), 940), // Kehena + Fad("OTEC", 19.0 + 52.6 / 60, -(156.0 + 11.6 / 60), 714), // Waikoloa OTEC + Fad("QQ", 19.0 + 39.2 / 60, -(154.0 + 53.5 / 60), 950), // Maku'u + Fad("RN", 19.0 + 7.8 / 60, -(155.0 + 23.5 / 60), 733), // Pālima Pt. + Fad("SS", 19.0 + 11.6 / 60, -(155.0 + 13.1 / 60), 515), // 'Āpua Pt. + Fad("TT", 19.0 + 4.6 / 60, -(155.0 + 57.4 / 60), 700), // Kānewa'a Pt. + Fad("UU", 19.0 + 16.8 / 60, -(155.0 + 57.1 / 60), 650), // 'Au'au Pt. + Fad("VV", 19.0 + 35.1 / 60, -(156.0 + 1.9 / 60), 600), // Kahalu'u + Fad("XX", 20.0 + 1.4 / 60, -(156.0 + 1.3 / 60), 345), // Puakō + Fad("ZZ", 19.0 + 56.9 / 60, -(155.0 + 57.7 / 60), 214), // Waimā Pt. + ) + + fun toFeatures(): List<Feature> = hawaiiIsland.map { fad -> + Feature.fromGeometry(Point.fromLngLat(fad.lon, fad.lat)).apply { + addStringProperty("id", fad.id) + addNumberProperty("depth_fathoms", fad.depthFathoms) + } + } +} 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 c4b6e1e..e4b2d97 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 @@ -7,6 +7,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText +import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment @@ -55,8 +56,23 @@ class SafetyFragment : Fragment() { listener?.onActivateMob() } - val etDepth = view.findViewById<EditText>(R.id.et_anchor_depth) - val etRode = view.findViewById<EditText>(R.id.et_anchor_rode) + view.findViewById<MaterialButton>(R.id.button_quit).setOnClickListener { + listener?.onQuitRequested() + } + + // ── Anchor Watch — collapsible ──────────────────────────────────────── + val headerAnchor = view.findViewById<LinearLayout>(R.id.header_anchor) + val bodyAnchor = view.findViewById<LinearLayout>(R.id.body_anchor) + val chevronAnchor = view.findViewById<TextView>(R.id.tv_anchor_chevron) + + headerAnchor.setOnClickListener { + val expanding = bodyAnchor.visibility == View.GONE + bodyAnchor.visibility = if (expanding) View.VISIBLE else View.GONE + chevronAnchor.text = if (expanding) "▼" else "▶" + } + + val etDepth = view.findViewById<EditText>(R.id.et_anchor_depth) + val etRode = view.findViewById<EditText>(R.id.et_anchor_rode) val tvRadius = view.findViewById<TextView>(R.id.tv_anchor_suggested_radius) val watcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit @@ -75,6 +91,17 @@ class SafetyFragment : Fragment() { etDepth.addTextChangedListener(watcher) etRode.addTextChangedListener(watcher) + // ── Hardware Data Sources — collapsible ─────────────────────────────── + val headerSources = view.findViewById<LinearLayout>(R.id.header_sources) + val bodySources = view.findViewById<LinearLayout>(R.id.body_sources) + val chevronSources = view.findViewById<TextView>(R.id.tv_sources_chevron) + + headerSources.setOnClickListener { + val expanding = bodySources.visibility == View.GONE + bodySources.visibility = if (expanding) View.VISIBLE else View.GONE + chevronSources.text = if (expanding) "▼" else "▶" + } + val flags = (requireActivity().application as NavApplication).featureFlags val switchAis = view.findViewById<MaterialSwitch>(R.id.switch_ais) @@ -90,17 +117,6 @@ class SafetyFragment : Fragment() { flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, checked) listener?.onHardwareSourceChanged(HardwareSource.NMEA_INSTRUMENTS, checked) } - - view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment()) - .addToBackStack(null) - .commit() - } - - view.findViewById<MaterialButton>(R.id.button_quit).setOnClickListener { - listener?.onQuitRequested() - } } fun updateAnchorStatus(statusText: String) { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt index 220b288..51a0685 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt @@ -153,11 +153,13 @@ class VoiceLogFragment : Fragment() { btnSave.setOnClickListener { val pos = mainViewModel.currentPosition.value + val trackId = if (mainViewModel.isRecording.value) mainViewModel.trackStartMs else null logViewModel.save( text = etNote.text?.toString()?.trim() ?: "", photoPath = pendingPhotoPath, lat = pos?.first, - lon = pos?.second + lon = pos?.second, + trackId = trackId ) } btnClear.setOnClickListener { clearEntry() } @@ -186,6 +188,13 @@ class VoiceLogFragment : Fragment() { } } + view.findViewById<MaterialButton>(R.id.btn_plan_trip).setOnClickListener { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment()) + .addToBackStack(null) + .commit() + } + // Inline track list val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks) val layoutEmpty = view.findViewById<View>(R.id.layout_empty) 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 1d499ab..420ccef 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -1,335 +1,395 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.core.widget.NestedScrollView + xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="24dp" android:background="?attr/colorSurface"> - <TextView - android:id="@+id/safety_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Safety Dashboard" - android:textSize="24sp" - android:textStyle="bold" - android:textColor="?attr/colorOnSurface" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintStart_toStartOf="parent" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/button_help_safety" - style="@style/Widget.Material3.Button.TextButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="HELP" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> - - <!-- MOB Section --> - <com.google.android.material.card.MaterialCardView - android:id="@+id/card_mob" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="24dp" - app:cardCornerRadius="16dp" - app:layout_constraintTop_toBottomOf="@id/safety_title"> + android:orientation="vertical" + android:padding="24dp"> + <!-- ── Title row ── --> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp" - android:gravity="center"> + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="24dp"> <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Safety Dashboard" + android:textSize="24sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/button_quit" + style="@style/Widget.Material3.Button.TextButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="MAN OVERBOARD" - android:textColor="@color/md_theme_error" - android:textStyle="bold" /> + android:text="Quit" + android:textColor="?attr/colorOnSurfaceVariant" + app:icon="@drawable/ic_close" /> <com.google.android.material.button.MaterialButton - android:id="@+id/button_mob_activate" - android:layout_width="match_parent" - android:layout_height="80dp" - android:layout_marginTop="16dp" - android:text="ACTIVATE MOB" - android:textSize="20sp" - app:backgroundTint="@color/md_theme_error" /> + android:id="@+id/button_help_safety" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="HELP" /> </LinearLayout> - </com.google.android.material.card.MaterialCardView> - - <!-- CPA / Collision Alert Section --> - <com.google.android.material.card.MaterialCardView - android:id="@+id/card_cpa" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - app:cardCornerRadius="16dp" - app:layout_constraintTop_toBottomOf="@id/card_mob"> - - <LinearLayout + <!-- MOB Section --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_mob" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp"> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="COLLISION AVOIDANCE" - android:textStyle="bold" /> + android:layout_marginBottom="16dp" + app:cardCornerRadius="16dp"> - <TextView - android:id="@+id/tv_cpa_status" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="No traffic alerts" - android:textColor="?attr/colorOnSurfaceVariant" - android:fontFamily="monospace" - android:textSize="13sp" /> + android:orientation="vertical" + android:padding="16dp" + android:gravity="center"> - </LinearLayout> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="MAN OVERBOARD" + android:textColor="@color/md_theme_error" + android:textStyle="bold" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/button_mob_activate" + android:layout_width="match_parent" + android:layout_height="80dp" + android:layout_marginTop="16dp" + android:text="ACTIVATE MOB" + android:textSize="20sp" + app:backgroundTint="@color/md_theme_error" /> - </com.google.android.material.card.MaterialCardView> + </LinearLayout> - <!-- Anchor Section --> - <com.google.android.material.card.MaterialCardView - android:id="@+id/card_anchor" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - app:cardCornerRadius="16dp" - app:layout_constraintTop_toBottomOf="@id/card_cpa"> + </com.google.android.material.card.MaterialCardView> - <LinearLayout + <!-- CPA / Collision Alert Section --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_cpa" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp"> + android:layout_marginBottom="16dp" + app:cardCornerRadius="16dp"> - <TextView - android:layout_width="wrap_content" + <LinearLayout + android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="ANCHOR WATCH" - android:textStyle="bold" /> + android:orientation="vertical" + android:padding="16dp"> - <TextView - android:id="@+id/anchor_status_summary" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="Status: Inactive" - android:textColor="?attr/colorOnSurfaceVariant" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="COLLISION AVOIDANCE" + android:textStyle="bold" /> + + <TextView + android:id="@+id/tv_cpa_status" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:text="No traffic alerts" + android:textColor="?attr/colorOnSurfaceVariant" + android:fontFamily="monospace" + android:textSize="13sp" /> + + </LinearLayout> + + </com.google.android.material.card.MaterialCardView> + + <!-- Anchor Section (collapsible) --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_anchor" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + app:cardCornerRadius="16dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:layout_marginTop="16dp"> + android:orientation="vertical" + android:padding="16dp"> + <!-- Collapsible header --> <LinearLayout - android:layout_width="0dp" + android:id="@+id/header_anchor" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" - android:orientation="vertical" - android:layout_marginEnd="8dp"> + android:orientation="horizontal" + android:gravity="center_vertical" + android:clickable="true" + android:focusable="true"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="ANCHOR WATCH" + android:textStyle="bold" /> <TextView + android:id="@+id/tv_anchor_chevron" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/anchor_depth_label" + android:text="▶" android:textSize="12sp" android:textColor="?attr/colorOnSurfaceVariant" /> - <EditText - android:id="@+id/et_anchor_depth" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/anchor_depth_hint" - android:inputType="numberDecimal" - android:importantForAutofill="no" /> - </LinearLayout> + <!-- Collapsible body — hidden by default --> <LinearLayout - android:layout_width="0dp" + android:id="@+id/body_anchor" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" android:orientation="vertical" - android:layout_marginStart="8dp"> + android:visibility="gone"> <TextView + android:id="@+id/anchor_status_summary" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/anchor_rode_label" - android:textSize="12sp" + android:layout_marginTop="8dp" + android:text="Status: Inactive" android:textColor="?attr/colorOnSurfaceVariant" /> - <EditText - android:id="@+id/et_anchor_rode" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="@string/anchor_rode_hint" - android:inputType="numberDecimal" - android:importantForAutofill="no" /> - - </LinearLayout> - - </LinearLayout> + android:orientation="horizontal" + android:layout_marginTop="16dp"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:layout_marginEnd="8dp"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/anchor_depth_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <EditText + android:id="@+id/et_anchor_depth" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_depth_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" /> + + </LinearLayout> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:layout_marginStart="8dp"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/anchor_rode_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <EditText + android:id="@+id/et_anchor_rode" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_rode_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" /> + + </LinearLayout> + + </LinearLayout> - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="12dp" - android:text="@string/anchor_suggested_radius_label" - android:textSize="12sp" - android:textColor="?attr/colorOnSurfaceVariant" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:text="@string/anchor_suggested_radius_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> - <TextView - android:id="@+id/tv_anchor_suggested_radius" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="2dp" - android:text="@string/anchor_suggested_radius_empty" - android:textSize="16sp" - android:textStyle="bold" - android:textColor="?attr/colorOnSurface" /> + <TextView + android:id="@+id/tv_anchor_suggested_radius" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:text="@string/anchor_suggested_radius_empty" + android:textSize="16sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> - </LinearLayout> + </LinearLayout> - </com.google.android.material.card.MaterialCardView> + </LinearLayout> - <!-- Hardware Data Sources --> - <com.google.android.material.card.MaterialCardView - android:id="@+id/card_data_sources" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - app:cardCornerRadius="16dp" - app:layout_constraintTop_toBottomOf="@id/card_anchor"> + </com.google.android.material.card.MaterialCardView> - <LinearLayout + <!-- Hardware Data Sources (collapsible) --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/card_data_sources" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp"> + android:layout_marginBottom="16dp" + app:cardCornerRadius="16dp"> - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="HARDWARE DATA SOURCES" - android:textStyle="bold" /> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="4dp" - android:layout_marginBottom="12dp" - android:text="Enable only the hardware you have connected" - android:textSize="12sp" - android:textColor="?attr/colorOnSurfaceVariant" /> - - <!-- AIS Receiver row --> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center_vertical" - android:layout_marginBottom="8dp"> + android:orientation="vertical" + android:padding="16dp"> + <!-- Collapsible header --> <LinearLayout - android:layout_width="0dp" + android:id="@+id/header_sources" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" - android:orientation="vertical"> + android:orientation="horizontal" + android:gravity="center_vertical" + android:clickable="true" + android:focusable="true"> <TextView - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" - android:text="AIS Receiver" - android:textSize="15sp" - android:textColor="?attr/colorOnSurface" /> + android:layout_weight="1" + android:text="HARDWARE DATA SOURCES" + android:textStyle="bold" /> <TextView + android:id="@+id/tv_sources_chevron" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Vessel tracking, collision alerts" + android:text="▶" android:textSize="12sp" android:textColor="?attr/colorOnSurfaceVariant" /> </LinearLayout> - <com.google.android.material.materialswitch.MaterialSwitch - android:id="@+id/switch_ais" - android:layout_width="wrap_content" - android:layout_height="wrap_content" /> - - </LinearLayout> - - <!-- NMEA Instruments row --> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center_vertical"> - + <!-- Collapsible body — hidden by default --> <LinearLayout - android:layout_width="0dp" + android:id="@+id/body_sources" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" - android:orientation="vertical"> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="NMEA Instruments" - android:textSize="15sp" - android:textColor="?attr/colorOnSurface" /> + android:orientation="vertical" + android:visibility="gone"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Wind, depth, boat speed" + android:layout_marginTop="4dp" + android:layout_marginBottom="12dp" + android:text="Enable only the hardware you have connected" android:textSize="12sp" android:textColor="?attr/colorOnSurfaceVariant" /> - </LinearLayout> + <!-- AIS Receiver row --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="8dp"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="AIS Receiver" + android:textSize="15sp" + android:textColor="?attr/colorOnSurface" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Vessel tracking, collision alerts" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + + <com.google.android.material.materialswitch.MaterialSwitch + android:id="@+id/switch_ais" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + + <!-- NMEA Instruments row --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="NMEA Instruments" + android:textSize="15sp" + android:textColor="?attr/colorOnSurface" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Wind, depth, boat speed" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + </LinearLayout> + + <com.google.android.material.materialswitch.MaterialSwitch + android:id="@+id/switch_nmea" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> - <com.google.android.material.materialswitch.MaterialSwitch - android:id="@+id/switch_nmea" - android:layout_width="wrap_content" - android:layout_height="wrap_content" /> + </LinearLayout> </LinearLayout> - </LinearLayout> + </com.google.android.material.card.MaterialCardView> - </com.google.android.material.card.MaterialCardView> - - <com.google.android.material.button.MaterialButton - android:id="@+id/button_plan_trip" - style="@style/Widget.Material3.Button.TonalButton" - android:layout_width="match_parent" - android:layout_height="60dp" - android:layout_marginTop="24dp" - android:text="PLAN TRIP (PRE-TRIP REPORT)" - app:layout_constraintTop_toBottomOf="@id/card_data_sources" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/button_quit" - style="@style/Widget.Material3.Button.TextButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Quit" - android:textColor="?attr/colorOnSurfaceVariant" - app:icon="@drawable/ic_close" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> + </LinearLayout> -</androidx.constraintlayout.widget.ConstraintLayout> +</androidx.core.widget.NestedScrollView> diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml index 346ba81..b1c440c 100644 --- a/android-app/app/src/main/res/layout/fragment_voice_log.xml +++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml @@ -189,15 +189,32 @@ android:layout_marginBottom="16dp" /> <!-- ── Trips section ── --> - <TextView + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Trips" - android:textSize="13sp" - android:textAllCaps="true" - android:letterSpacing="0.08" - android:textColor="?attr/colorOnSurfaceVariant" - android:layout_marginBottom="8dp" /> + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="8dp"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Trips" + android:textSize="13sp" + android:textAllCaps="true" + android:letterSpacing="0.08" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_plan_trip" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Plan Trip" + android:textSize="12sp" /> + + </LinearLayout> <!-- Empty state --> <LinearLayout diff --git a/docs/superpowers/plans/2026-06-30-fishing-mode.md b/docs/superpowers/plans/2026-06-30-fishing-mode.md new file mode 100644 index 0000000..e863cd3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-fishing-mode.md @@ -0,0 +1,572 @@ +# Fishing Mode 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 a "Fishing Mode" to the nav app that highlights productive fishing lanes, recommends rigs and approach tactics, and gives live steering guidance for fast pelagic species (Ono/wahoo and mahi-mahi). + +**Architecture:** Fishing mode is a persistent overlay state toggled from the map screen. When active, it adds SST (sea surface temperature) raster tiles to the map, computes target zones from the conditions API response, and displays a floating card with rig suggestions and steering bearing. A `FishingModeManager` owns the state and is wired through `MapLayerManager` for the map layer and through a new `FishingOverlayView` for the heads-up card. + +**Tech Stack:** Kotlin, MapLibre raster layers (NOAA CoastWatch SST tiles), existing `MarineConditions` model, existing `LayerPickerSheet`, new `FishingModeManager` + `FishingOverlayView`. + +--- + +## File Structure + +| File | Role | +|------|------| +| `ui/fishing/FishingModeManager.kt` | State singleton: active flag, SST layer visibility, target zone computation | +| `ui/fishing/FishingOverlayView.kt` | Floating card drawn over the map: target bearing, species, rig, speed cue | +| `ui/fishing/RigAdvisor.kt` | Pure function: conditions → `RigRecommendation` | +| `ui/fishing/FishingTarget.kt` | Data class: bearing, distance, zone description | +| `ui/MapLayerManager.kt` | Add `SOURCE_SST`/`LAYER_SST` and `sstEnabled` pref | +| `res/layout/view_fishing_overlay.xml` | Card layout for the fishing HUD | +| `res/layout/activity_main.xml` | Add `FishingOverlayView` stacked over the MapView | +| `MainActivity.kt` | Wire fishing FAB, observe conditions→overlay, pass bearing to map | +| `res/drawable/ic_fish.xml` | Fish icon for FAB (24dp vector) | + +--- + +## Task 1: FishingTarget + RigAdvisor (pure logic, no Android) + +**Files:** +- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/fishing/FishingTarget.kt` +- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/fishing/RigAdvisor.kt` + +- [ ] **Write the failing test** + +```kotlin +// test/kotlin/org/terst/nav/ui/fishing/RigAdvisorTest.kt +class RigAdvisorTest { + @Test fun `warm water fast current favors Ono rig`() { + val rec = RigAdvisor.recommend(tempC = 26.0, currentKt = 1.5, windKt = 10.0) + assertEquals(FishingSpecies.ONO, rec.primarySpecies) + assertTrue(rec.trollingSpeedKt in 7.0..10.0) + } + @Test fun `cooler water moderate current favors mahi`() { + val rec = RigAdvisor.recommend(tempC = 23.0, currentKt = 0.5, windKt = 8.0) + assertEquals(FishingSpecies.MAHI, rec.primarySpecies) + } +} +``` + +- [ ] **Run test to verify it fails:** `./gradlew testDebugUnitTest --tests "*.RigAdvisorTest" -q` + +- [ ] **Implement FishingTarget.kt** + +```kotlin +package org.terst.nav.ui.fishing + +enum class FishingSpecies(val displayName: String) { + ONO("Ono (Wahoo)"), MAHI("Mahi-Mahi"), BOTH("Ono & Mahi") +} + +data class RigRecommendation( + val primarySpecies: FishingSpecies, + val rigDescription: String, // e.g. "Blue/white 9" lure, mono 80# leader" + val trollingSpeedKt: Double, // optimal trolling speed + val approachNote: String // one-line tactic +) + +data class FishingTarget( + val bearingDeg: Double, // magnetic bearing toward best zone + val distanceNm: Double, // estimated distance to zone + val zoneDescription: String, // "temp break 76°F / color edge" + val rig: RigRecommendation +) +``` + +- [ ] **Implement RigAdvisor.kt** + +```kotlin +package org.terst.nav.ui.fishing + +object RigAdvisor { + /** + * Returns a rig recommendation based on current conditions. + * Ono (wahoo): prefer 75–82°F, current edges, troll 8–10 kt with fast lures. + * Mahi: prefer 70–78°F, floating debris/weed lines, troll 6–9 kt with skirts. + */ + fun recommend(tempC: Double, currentKt: Double, windKt: Double): RigRecommendation { + val tempF = tempC * 9.0 / 5.0 + 32.0 + val species = when { + tempF >= 75.0 && currentKt >= 1.0 -> FishingSpecies.ONO + tempF in 70.0..78.0 -> FishingSpecies.MAHI + else -> FishingSpecies.BOTH + } + return when (species) { + FishingSpecies.ONO -> RigRecommendation( + primarySpecies = FishingSpecies.ONO, + rigDescription = "Blue/white 9\" Rapala or jet head, 80# mono leader, 50–80# stand-up", + trollingSpeedKt = if (currentKt > 1.0) 9.0 else 8.0, + approachNote = "Troll along current edge; watch for color change (blue→green). Ono hit fast." + ) + FishingSpecies.MAHI -> RigRecommendation( + primarySpecies = FishingSpecies.MAHI, + rigDescription = "Green/yellow skirt or ballyhoo, 50# mono, medium-action", + trollingSpeedKt = 7.0, + approachNote = "Look for floating debris or weed lines. Mahi school; keep one hooked in water." + ) + FishingSpecies.BOTH -> RigRecommendation( + primarySpecies = FishingSpecies.BOTH, + rigDescription = "Mix: one Ono lure (fast), one mahi skirt (slow)", + trollingSpeedKt = 8.0, + approachNote = "Spread lures at different depths. Watch for birds and color changes." + ) + } + } +} +``` + +- [ ] **Run tests:** `./gradlew testDebugUnitTest --tests "*.RigAdvisorTest" -q` + Expected: 2 PASSED + +- [ ] **Commit** +```bash +git add android-app/app/src/main/kotlin/org/terst/nav/ui/fishing/ \ + android-app/app/src/test/kotlin/org/terst/nav/ui/fishing/ +git commit -m "feat(fishing): FishingTarget + RigAdvisor logic" +``` + +--- + +## Task 2: SST raster layer in MapLayerManager + +**Files:** +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt` +- Modify: `android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml` +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt` + +SST tile source: NOAA CoastWatch ERDDAP griddap XYZ tiles — `https://coastwatch.pfeg.noaa.gov/erddap/griddap/jplMURSST41.largePng?analysed_sst%5B(2024-01-01T00:00:00Z)%5D%5B({lat_min}):({lat_max})%5D%5B({lon_min}):({lon_max})%5D&.draw=surface&.vars=longitude|latitude|analysed_sst&.colorBar=KT_thermal|||19|32|` — but this is a WMS-style endpoint, not XYZ tiles. Use NOAA Tiler instead: `https://api.tidetopography.com/tiles/sst/{z}/{x}/{y}.png` — this is a fictional URL. The real approach for SST tiles: + +**Recommended source:** NOAA Environmental Research Division's CoastWatch WMS: +`https://coastwatch.pfeg.noaa.gov/erddap/wms/jplMURSST41/request?SERVICE=WMS&REQUEST=GetMap&LAYERS=jplMURSST41:analysed_sst&BBOX={bbox-epsg-3857}&FORMAT=image/png&TRANSPARENT=TRUE&CRS=EPSG:3857&WIDTH=256&HEIGHT=256&VERSION=1.3.0&STYLES=` + +This is a WMS endpoint, which MapLibre supports as a raster source with `TileSet` using the WMS URL pattern. + +- [ ] **Add SST constants to MapLayerManager companion object** + +```kotlin +const val SOURCE_SST = "sst-source" +const val LAYER_SST = "sst-layer" +private const val KEY_SST = "sst_enabled" +private const val URL_SST = + "https://coastwatch.pfeg.noaa.gov/erddap/wms/jplMURSST41/request" + + "?SERVICE=WMS&REQUEST=GetMap&LAYERS=jplMURSST41:analysed_sst" + + "&BBOX={bbox-epsg-3857}&FORMAT=image/png&TRANSPARENT=TRUE" + + "&CRS=EPSG:3857&WIDTH=256&HEIGHT=256&VERSION=1.3.0&STYLES=" +``` + +- [ ] **Add `sstEnabled` property and `setSstEnabled()` method** (same pattern as `depthEnabled`/`setDepthEnabled`) + +- [ ] **Add SST layer in `addToStyleBuilder()`** — `RasterLayer(LAYER_SST, SOURCE_SST)` with `rasterOpacity(0.65f)`, above wind layer, below depth layer. Visibility set from `sstEnabled`. + +- [ ] **Add SST toggle to `layout_layer_picker_sheet.xml`** — new switch row after "Past tracks" titled "SST (sea surface temp)" with subtitle "Temperature overlay — NOAA CoastWatch". + +- [ ] **Wire SST switch in `LayerPickerSheet.kt`** — same pattern as `onDepthChanged`. + +- [ ] **Compile:** `./gradlew compileDebugKotlin` + +- [ ] **Commit:** `git commit -m "feat(fishing): SST raster layer from NOAA CoastWatch"` + +--- + +## Task 3: FishingModeManager + +**Files:** +- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/fishing/FishingModeManager.kt` + +- [ ] **Write the failing test** + +```kotlin +// FishingModeManagerTest.kt +class FishingModeManagerTest { + @Test fun `target zone computed from current bearing and temp break`() { + val mgr = FishingModeManager() + // conditions: heading 270°, current flowing 180°, temp 25°C + val target = mgr.computeTarget( + boatLat = 19.5, boatLon = -156.0, boatCogDeg = 270.0, + tempC = 25.0, currentKt = 1.2, currentDirDeg = 180.0 + ) + assertNotNull(target) + // Target should be roughly perpendicular to current (cross-current) for temp breaks + assertTrue(target!!.distanceNm in 0.5..20.0) + } +} +``` + +- [ ] **Run test to verify it fails** + +- [ ] **Implement FishingModeManager.kt** + +```kotlin +package org.terst.nav.ui.fishing + +import android.content.Context +import kotlin.math.* + +class FishingModeManager(context: Context) { + + private val prefs = context.getSharedPreferences("fishing_mode", Context.MODE_PRIVATE) + + var isActive: Boolean + get() = prefs.getBoolean("active", false) + set(v) { prefs.edit().putBoolean("active", v).apply() } + + /** + * Computes a target fishing zone based on current conditions. + * Strategy: steer perpendicular to current flow toward the nearest estimated + * temperature break (assumed 3–8 nm away across the current axis). + * Returns null when conditions are insufficient. + */ + fun computeTarget( + boatLat: Double, boatLon: Double, boatCogDeg: Double, + tempC: Double, currentKt: Double, currentDirDeg: Double + ): FishingTarget? { + if (currentKt < 0.1) return null // no current data — can't predict break + + // Temp break tends to be perpendicular to current flow and typically 3–8 nm away + val crossCurrentBearing = (currentDirDeg + 90.0) % 360.0 + val estimatedDistNm = (3.0 + currentKt * 2.5).coerceIn(3.0, 8.0) + + val rig = RigAdvisor.recommend(tempC, currentKt, windKt = 0.0) + + val tempF = tempC * 9.0 / 5.0 + 32.0 + val zoneDesc = "%.0f°F water, %.1f kt current — steer to color edge".format(tempF, currentKt) + + return FishingTarget( + bearingDeg = crossCurrentBearing, + distanceNm = estimatedDistNm, + zoneDescription = zoneDesc, + rig = rig + ) + } +} +``` + +- [ ] **Run tests:** `./gradlew testDebugUnitTest --tests "*.FishingModeManagerTest" -q` → PASSED + +- [ ] **Commit:** `git commit -m "feat(fishing): FishingModeManager with target zone computation"` + +--- + +## Task 4: FishingOverlayView layout + view class + +**Files:** +- Create: `android-app/app/src/main/res/layout/view_fishing_overlay.xml` +- Create: `android-app/app/src/main/kotlin/org/terst/nav/ui/fishing/FishingOverlayView.kt` + +- [ ] **Create `view_fishing_overlay.xml`** + +```xml +<?xml version="1.0" encoding="utf-8"?> +<com.google.android.material.card.MaterialCardView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="16dp" + app:cardCornerRadius="12dp" + app:cardElevation="4dp" + android:background="#CC004D40"> + + <LinearLayout + android:layout_width="220dp" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="12dp"> + + <!-- Species + bearing row --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="6dp"> + + <TextView + android:id="@+id/tv_fish_species" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Ono & Mahi" + android:textSize="14sp" + android:textStyle="bold" + android:textColor="#FFFFFFFF" /> + + <TextView + android:id="@+id/tv_fish_bearing" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="270°" + android:textSize="20sp" + android:textStyle="bold" + android:textColor="#FF80CBC4" /> + + </LinearLayout> + + <!-- Zone description --> + <TextView + android:id="@+id/tv_fish_zone" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="76°F water, 1.2 kt current" + android:textSize="11sp" + android:textColor="#99FFFFFF" + android:layout_marginBottom="8dp" /> + + <!-- Rig description --> + <TextView + android:id="@+id/tv_fish_rig" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Blue/white 9″ jet head, 80# mono" + android:textSize="11sp" + android:textColor="#CCFFFFFF" + android:layout_marginBottom="4dp" /> + + <!-- Speed + approach --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <TextView + android:id="@+id/tv_fish_speed" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Troll 8–10 kt" + android:textSize="11sp" + android:textColor="#CCFFFFFF" /> + + <TextView + android:id="@+id/tv_fish_distance" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="~5 nm" + android:textSize="11sp" + android:textColor="#99FFFFFF" /> + + </LinearLayout> + + <!-- Approach note --> + <TextView + android:id="@+id/tv_fish_approach" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Troll along current edge; watch for color change" + android:textSize="10sp" + android:textColor="#88FFFFFF" + android:layout_marginTop="6dp" /> + + </LinearLayout> + +</com.google.android.material.card.MaterialCardView> +``` + +- [ ] **Create `FishingOverlayView.kt`** + +```kotlin +package org.terst.nav.ui.fishing + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.TextView +import org.terst.nav.R + +class FishingOverlayView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + init { + LayoutInflater.from(context).inflate(R.layout.view_fishing_overlay, this, true) + } + + fun bind(target: FishingTarget) { + findViewById<TextView>(R.id.tv_fish_species).text = target.rig.primarySpecies.displayName + findViewById<TextView>(R.id.tv_fish_bearing).text = "%.0f°".format(target.bearingDeg) + findViewById<TextView>(R.id.tv_fish_zone).text = target.zoneDescription + findViewById<TextView>(R.id.tv_fish_rig).text = target.rig.rigDescription + findViewById<TextView>(R.id.tv_fish_speed).text = "Troll %.0f kt".format(target.rig.trollingSpeedKt) + findViewById<TextView>(R.id.tv_fish_distance).text = "~%.0f nm".format(target.distanceNm) + findViewById<TextView>(R.id.tv_fish_approach).text = target.rig.approachNote + } +} +``` + +- [ ] **Compile:** `./gradlew compileDebugKotlin` +- [ ] **Commit:** `git commit -m "feat(fishing): FishingOverlayView card UI"` + +--- + +## Task 5: Wire fishing mode in MainActivity + +**Files:** +- Modify: `android-app/app/src/main/res/layout/activity_main.xml` +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` + +- [ ] **Add `FishingOverlayView` to `activity_main.xml`** — inside the root `FrameLayout`, after the MapView, gravity `bottom|start`, initial `visibility="gone"`: + +```xml +<org.terst.nav.ui.fishing.FishingOverlayView + android:id="@+id/fishing_overlay" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|start" + android:layout_marginBottom="120dp" + android:visibility="gone" /> +``` + +- [ ] **Add fishing FAB to `activity_main.xml`** — `FloatingActionButton` with `ic_fish` icon, `gravity="bottom|start"`, `layout_marginStart="16dp"`, `layout_marginBottom="80dp"`: + +```xml +<com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/fab_fishing" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|start" + android:layout_marginStart="16dp" + android:layout_marginBottom="80dp" + android:src="@drawable/ic_fish" + app:fabSize="mini" + android:contentDescription="Fishing mode" /> +``` + +- [ ] **Add fishing mode fields to `MainActivity`**: + +```kotlin +private lateinit var fishingOverlay: org.terst.nav.ui.fishing.FishingOverlayView +private lateinit var fabFishing: FloatingActionButton +private lateinit var fishingModeManager: org.terst.nav.ui.fishing.FishingModeManager +``` + +- [ ] **Initialize in `initializeUI()`** — bind views, init `FishingModeManager`, set FAB click listener: + +```kotlin +fishingOverlay = findViewById(R.id.fishing_overlay) +fabFishing = findViewById(R.id.fab_fishing) +fishingModeManager = org.terst.nav.ui.fishing.FishingModeManager(this) + +fabFishing.setOnClickListener { + val active = !fishingModeManager.isActive + fishingModeManager.isActive = active + fishingOverlay.visibility = if (active) View.VISIBLE else View.GONE + // Also enable SST layer when fishing mode activates + loadedStyleFlow.value?.let { style -> + layerManager.setSstEnabled(style, active) + } + updateFishingOverlay() +} + +// Restore fishing overlay if it was active +if (fishingModeManager.isActive) { + fishingOverlay.visibility = View.VISIBLE +} +``` + +- [ ] **Add `updateFishingOverlay()` helper**: + +```kotlin +private fun updateFishingOverlay() { + if (!fishingModeManager.isActive) return + val cond = viewModel.marineConditions.value ?: return + val pos = viewModel.currentPosition.value ?: return + val target = fishingModeManager.computeTarget( + boatLat = pos.first, + boatLon = pos.second, + boatCogDeg = lastCogDeg, // add var lastCogDeg in observeDataSources GPS collect + tempC = cond.tempC ?: return, + currentKt = cond.currentSpeedKt ?: 0.0, + currentDirDeg = cond.currentDirDeg ?: 0.0 + ) ?: return + fishingOverlay.bind(target) +} +``` + +- [ ] **Call `updateFishingOverlay()` inside `marineConditions.collect { }` block** + +- [ ] **Compile:** `./gradlew compileDebugKotlin` + +- [ ] **Commit:** `git commit -m "feat(fishing): wire fishing mode FAB and overlay in MainActivity"` + +--- + +## Task 6: Fishing mode asset — ic_fish.xml + +**Files:** +- Create: `android-app/app/src/main/res/drawable/ic_fish.xml` + +- [ ] **Create vector drawable** + +```xml +<!-- ic_fish.xml — simple silhouette fish icon 24×24dp --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/white" + android:pathData="M20,12 C18,9 14,7 10,8 L8,5 L6,8 C4,8.5 2,10 2,12 C2,14 4,15.5 6,16 L8,19 L10,16 C14,17 18,15 20,12 Z M7,12 C6.4,12 6,11.6 6,11 C6,10.4 6.4,10 7,10 C7.6,10 8,10.4 8,11 C8,11.6 7.6,12 7,12 Z" /> +</vector> +``` + +- [ ] **Compile and visual check:** `./gradlew compileDebugKotlin` +- [ ] **Commit:** `git commit -m "feat(fishing): add ic_fish vector drawable"` + +--- + +## Task 7: Steering guidance on map — fishing lane bearing line + +**Files:** +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt` +- Modify: `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt` + +Add a dashed heading line on the map pointing toward the fishing target bearing. + +- [ ] **Add constants and source to MapHandler**: + +```kotlin +private val FISHING_LINE_SOURCE_ID = "fishing-line-source" +private val FISHING_LINE_LAYER_ID = "fishing-line-layer" +private var fishingLineSource: GeoJsonSource? = null +``` + +- [ ] **Add `setupFishingLayer(style: Style)` to MapHandler** — `LineLayer` with dashed stroke (`lineDasharray([4f, 2f])`), `lineColor("#FF6D00")`, `lineWidth(2f)` + +- [ ] **Add `updateFishingBearing(lat: Double, lon: Double, bearingDeg: Double, lengthNm: Double = 10.0)` to MapHandler** — compute endpoint from start + bearing + distance (haversine inverse), update `fishingLineSource` + +- [ ] **Call `setupFishingLayer()` in style-load callback in MainActivity** (after `setupFadLayer`) + +- [ ] **Call `updateFishingBearing()` in `updateFishingOverlay()`** + +- [ ] **Compile:** `./gradlew compileDebugKotlin` + +- [ ] **Commit:** `git commit -m "feat(fishing): dashed bearing line on map for fishing target"` + +--- + +## Testing + +```bash +./gradlew testDebugUnitTest # unit tests for RigAdvisor + FishingModeManager +./gradlew assembleDebug # full APK build +``` + +Manual test flow: +1. Open app → tap fish FAB → fishing overlay card appears +2. SST layer loads on map (requires network; may take a few seconds) +3. Overlay shows species, bearing, rig, speed +4. Tap FAB again → overlay hides, SST layer hides +5. Conditions update (conditions API returns) → overlay auto-refreshes + +--- + +## Notes on SST Tile Quality + +The NOAA CoastWatch WMS tile URL may have latency (2–10 sec tile load). Consider caching tiles locally with MapLibre's tile cache or using a CDN proxy. If tile loading is too slow: +- Alternative: Copernicus Marine Environment (CMEMS) SST — requires API key +- Fallback: Disable SST by default, show only the rig/bearing overlay from conditions data |
