summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-30 17:16:06 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-30 18:19:04 +0000
commit9af38934e8d676bfb8c08ab01e3d57fec5f36d97 (patch)
tree67b5bdfffb596d63fb39d17465704bb97ad6bf8a /android-app/app/src
parent36efb5a8b9502d0def41c0cdc17ed894744c8562 (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>
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt29
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/FadData.kt42
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt42
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt11
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml522
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml31
12 files changed, 462 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