From 9af38934e8d676bfb8c08ab01e3d57fec5f36d97 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 30 Jun 2026 17:16:06 +0000 Subject: 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 --- .../main/kotlin/org/terst/nav/LocationService.kt | 16 +++++++-- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 29 ++++++++++++++- .../main/kotlin/org/terst/nav/NavApplication.kt | 2 ++ .../main/kotlin/org/terst/nav/logbook/LogEntry.kt | 3 +- .../org/terst/nav/logbook/VoiceLogViewModel.kt | 12 ++++--- .../kotlin/org/terst/nav/track/TrackRepository.kt | 4 +-- .../terst/nav/tripreport/PreTripReportFragment.kt | 12 ++++++- .../src/main/kotlin/org/terst/nav/ui/FadData.kt | 42 ++++++++++++++++++++++ .../org/terst/nav/ui/safety/SafetyFragment.kt | 42 +++++++++++++++------- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 11 +++++- 10 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/FadData.kt (limited to 'android-app/app/src/main/kotlin') 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(replay = 1) val locationFlow: SharedFlow 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 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(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.Idle) val state: StateFlow = _state - private val _entries = MutableStateFlow>(repository.getAll().reversed()) + private val _entries = MutableStateFlow>(standalonEntries()) val entries: StateFlow> = _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 = 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 = 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 = 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(R.id.et_anchor_depth) - val etRode = view.findViewById(R.id.et_anchor_rode) + view.findViewById(R.id.button_quit).setOnClickListener { + listener?.onQuitRequested() + } + + // ── Anchor Watch — collapsible ──────────────────────────────────────── + val headerAnchor = view.findViewById(R.id.header_anchor) + val bodyAnchor = view.findViewById(R.id.body_anchor) + val chevronAnchor = view.findViewById(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(R.id.et_anchor_depth) + val etRode = view.findViewById(R.id.et_anchor_rode) val tvRadius = view.findViewById(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(R.id.header_sources) + val bodySources = view.findViewById(R.id.body_sources) + val chevronSources = view.findViewById(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(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(R.id.button_plan_trip).setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment()) - .addToBackStack(null) - .commit() - } - - view.findViewById(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(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(R.id.rv_saved_tracks) val layoutEmpty = view.findViewById(R.id.layout_empty) -- cgit v1.2.3