summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-20 21:20:27 +0000
committerClaude <noreply@anthropic.com>2026-05-20 21:20:27 +0000
commit893bb60a4135e5a73a9ccc8a6d49a1953bdf0ef2 (patch)
tree482a355bb4b176ec2c23d72c3566d5f31573ee03 /android-app/app
parentb3e8370e278cd9c4f822453a0a7bae05dd0ca729 (diff)
parent4b0a4c76d94ace1593d66f6fd485b88b3b6c4994 (diff)
Merge main: preserve embedded track detail map, tack detection fixes, 0.12 past track opacity
- Tack detector: STABILITY_MAX=30, START_SKIP_MS=120s cold-start filter (feature branch) - TrackDetailSheet: embedded Fragment with MapView, back button pops stack (feature branch) - layout_track_detail_sheet: MapView top half, detail panel bottom half (feature branch) - MapHandler past track dim opacity: 0.12 (down from 0.22) - SavedTracksFragment: fragment transaction to TrackDetailSheet + SAF setup (both) - VoiceLogFragment: inline track list, logViewModel refs fixed (main)
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/build.gradle1
-rw-r--r--android-app/app/src/main/AndroidManifest.xml20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt59
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt39
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt150
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt73
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt88
-rw-r--r--android-app/app/src/main/res/layout/fragment_saved_tracks.xml29
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml341
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml17
-rwxr-xr-xandroid-app/app/src/main/res/values/colors.xml2
13 files changed, 563 insertions, 274 deletions
diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle
index 472d678..9748d22 100644
--- a/android-app/app/build.gradle
+++ b/android-app/app/build.gradle
@@ -69,6 +69,7 @@ dependencies {
// AndroidX core
implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 5090190..23817bb 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -56,20 +56,17 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
- <!-- Google Files on Android 16 sends application/octet-stream for .gpx because
- MimeTypeMap has no entry for that extension. The ExternalStorageProvider URI
- encodes the full file path, so pathSuffix=".gpx" keeps this targeted.
- Code double-checks via OpenableColumns.DISPLAY_NAME before parsing. -->
+ <!-- Many file managers send application/octet-stream with no path hints.
+ pathSuffix cannot match content URIs (opaque paths); code checks
+ OpenableColumns.DISPLAY_NAME instead. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
- <data android:scheme="content" android:mimeType="application/octet-stream"
- android:pathSuffix=".gpx" />
+ <data android:mimeType="application/octet-stream" />
</intent-filter>
- <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM, not
- intent data, so pathSuffix can't help — these cover apps that type .gpx
- correctly. Code checks display name for the XML variants. -->
+ <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM.
+ Code checks display name for XML and octet-stream variants. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -90,6 +87,11 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/octet-stream" />
+ </intent-filter>
</activity>
</application>
</manifest>
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 05dbf12..6c54500 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
@@ -3,6 +3,7 @@ package org.terst.nav
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
+import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.util.Log
import kotlinx.coroutines.Dispatchers
@@ -113,6 +114,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private val viewModel: MainViewModel by viewModels()
private var pendingServiceStart = false
private var serviceStarted = false
+ private var overlayShowing = false
override fun onResume() {
super.onResume()
@@ -302,14 +304,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
- // "Saved Tracks" button in the instrument sheet
- findViewById<View>(R.id.btn_saved_tracks).setOnClickListener {
- showOverlay(SavedTracksFragment())
- bottomSheetBehavior.isHideable = true
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
- // Note: do NOT set selectedItemId here — it triggers the nav listener
- // which calls hideOverlays(), immediately undoing showOverlay().
- }
}
/** Updates all static unit label TextViews to match current [unitPrefs]. */
@@ -425,19 +419,30 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun showOverlay(fragment: androidx.fragment.app.Fragment) {
+ overlayShowing = true
fragmentContainer.visibility = View.VISIBLE
fabRecordTrack.visibility = View.GONE
fabLayers.visibility = View.GONE
+ fabRecenter.visibility = View.GONE
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
}
+ fun showSavedTracks() {
+ showOverlay(SavedTracksFragment())
+ }
+
internal fun hideOverlays() {
+ overlayShowing = false
fragmentContainer.visibility = View.GONE
fabRecordTrack.visibility = View.VISIBLE
fabLayers.visibility = View.VISIBLE
- // Re-apply cached conditions in case they arrived while the overlay was covering the sheet
+ val following = mapHandler?.isFollowing?.value ?: true
+ fabRecenter.visibility = if (following) View.GONE else View.VISIBLE
+ fabRecenter.alpha = 1f
+ bottomSheetBehavior.isHideable = false
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
lastConditions?.let { applyConditions(it) }
}
@@ -540,12 +545,27 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
pendingServiceStart = true
- // The MediaStore query in init{} may have thrown SecurityException while
- // the permission dialog was active. Reload now that the system is settled.
viewModel.reloadPastTracks()
}
}
+ private val safPickerLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ result.data?.data?.let { treeUri -> viewModel.onSafDirectorySelected(treeUri) }
+ }
+ }
+
+ fun launchSafPicker() {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI,
+ android.provider.MediaStore.Files.getContentUri("external"))
+ }
+ }
+ safPickerLauncher.launch(intent)
+ }
+
private fun startServices() {
serviceStarted = true
val intent = Intent(this, LocationService::class.java).apply {
@@ -577,12 +597,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
mapHandler!!.isFollowing.collect { following ->
mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE
- if (following) {
- fadeOut(fabRecenter, gone = true)
- fadeIn(fabRecordTrack)
- } else {
- fadeOut(fabRecordTrack, gone = true)
- fadeIn(fabRecenter)
+ if (!overlayShowing) {
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(fabRecordTrack)
+ } else {
+ fadeOut(fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
}
}
}
@@ -796,10 +818,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
- // When a saved track is selected: zoom to it and show tack markers
+ // When a saved track is selected: zoom to it, highlight it, and show tack markers
lifecycleScope.launch {
viewModel.selectedTrack.collect { track ->
val style = loadedStyleFlow.value ?: return@collect
+ mapHandler?.setFocusedTrackPoints(track?.points)
if (track != null) {
mapHandler?.zoomToTrackBounds(track.points)
mapHandler?.updateTackLayer(style, track.tacks)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
index 0115a62..67f32f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
@@ -1,5 +1,6 @@
package org.terst.nav.track
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.launch
import org.terst.nav.MainActivity
import org.terst.nav.R
@@ -32,8 +34,10 @@ class SavedTracksFragment : Fragment() {
(requireActivity() as MainActivity).hideOverlays()
}
- val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
- val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
@@ -50,8 +54,35 @@ class SavedTracksFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.savedTracks.collect { tracks ->
adapter.submitList(tracks)
- tvEmpty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
- rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ val empty = tracks.isEmpty()
+ layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE
+ rv.visibility = if (empty) View.GONE else View.VISIBLE
+ }
+ }
+
+ // SAF storage setup prompt — only relevant on API 29+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.safState.collect { state ->
+ when (state) {
+ SafState.NOT_CONFIGURED -> {
+ tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ btnSetup.text = "Set up track storage"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.PERMISSION_LOST -> {
+ tvEmpty.text = "Track storage needs re-authorization."
+ btnSetup.text = "Restore track access"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.CONFIGURED -> {
+ btnSetup.visibility = View.GONE
+ }
+ }
+ }
+ }
+ btnSetup.setOnClickListener {
+ (requireActivity() as MainActivity).launchSafPicker()
}
}
}
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 bd79985..0cc098b 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,6 +1,7 @@
package org.terst.nav.track
import android.content.Context
+import android.net.Uri
import android.util.Log
import kotlin.math.atan2
import kotlin.math.cos
@@ -149,6 +150,12 @@ class TrackRepository(context: Context) {
.filter { it.isNotEmpty() }
.map { it.toSavedTrack() }
+ fun safState(): SafState = storage.safState()
+
+ fun initSafDirectory(treeUri: Uri) {
+ storage.initSafDirectory(treeUri)
+ }
+
companion object {
fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 3440.065 // Earth radius in nautical miles
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
index 146d10f..405bba8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
@@ -2,12 +2,15 @@ package org.terst.nav.track
import android.content.ContentValues
import android.content.Context
+import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
+import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
+import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.terst.nav.ui.NavLogger
@@ -19,16 +22,24 @@ import java.time.format.DateTimeFormatter
private const val TAG = "TrackStorage"
private const val PREF_NAME = "nav_track_uris"
private const val PREF_KEY = "uris"
+private const val PREF_SAF_TREE_URI = "saf_tree_uri"
+
+enum class SafState { CONFIGURED, PERMISSION_LOST, NOT_CONFIGURED }
/**
* Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
*
- * Files written here survive app uninstall because they live in user-owned
- * shared storage rather than app-private storage.
+ * On API 29+, the primary mechanism is Storage Access Framework (SAF): the user
+ * grants access to a directory once via ACTION_OPEN_DOCUMENT_TREE; the permission
+ * survives reboots and is re-grantable with a single tap after reinstall, making
+ * track files genuinely persistent across uninstalls.
+ *
+ * Fallback chain (API 29+):
+ * 1. SAF via DocumentFile (preferred — survives reinstall with one re-auth tap)
+ * 2. MediaStore via persisted URIs (works for tracks saved before SAF was configured)
+ * 3. MediaStore fallback query by OWNER_PACKAGE_NAME (migration path)
*
- * API 29+: uses MediaStore (no permission required for Documents/).
- * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
- * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS/Nav/.
*/
class TrackStorage(private val context: Context) {
@@ -44,33 +55,107 @@ class TrackStorage(private val context: Context) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
- /** Write a completed track to Documents/Nav/. Returns true on success. */
+ // ── SAF state ─────────────────────────────────────────────────────────────
+
+ fun safState(): SafState {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return SafState.CONFIGURED
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return SafState.NOT_CONFIGURED
+ val uri = Uri.parse(uriStr)
+ val hasPermission = context.contentResolver.persistedUriPermissions.any {
+ it.uri == uri && it.isReadPermission && it.isWritePermission
+ }
+ return if (hasPermission) SafState.CONFIGURED else SafState.PERMISSION_LOST
+ }
+
+ fun initSafDirectory(treeUri: Uri) {
+ context.contentResolver.takePersistableUriPermission(
+ treeUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ prefs.edit().putString(PREF_SAF_TREE_URI, treeUri.toString()).apply()
+ NavLogger.i("track", "SAF directory configured: $treeUri")
+ }
+
+ private fun safTreeDoc(): DocumentFile? {
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return null
+ return DocumentFile.fromTreeUri(context, Uri.parse(uriStr))
+ }
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ /** Write a completed track to the configured storage location. Returns true on success. */
fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
if (points.isEmpty()) return false
val name = trackName.format(Instant.ofEpochMilli(startMs))
val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
val gpx = GpxSerializer.serialize(points, name)
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- saveViaMediaStore(fileName, gpx)
- } else {
- saveViaFile(fileName, gpx)
+ return when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && safState() == SafState.CONFIGURED
+ -> saveViaSaf(fileName, gpx)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ -> saveViaMediaStore(fileName, gpx)
+ else -> saveViaFile(fileName, gpx)
}
}
- /** Load all tracks previously saved to Documents/Nav/. */
+ /** Load all tracks from the configured storage location. */
fun loadAllTracks(): List<List<TrackPoint>> {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- loadViaMediaStore()
- } else {
- loadViaFile()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return loadViaFile()
+ return if (safState() == SafState.CONFIGURED) loadViaSaf()
+ else loadViaMediaStore()
+ }
+
+ // ── SAF ───────────────────────────────────────────────────────────────────
+
+ private fun saveViaSaf(fileName: String, gpx: String): Boolean {
+ val dir = safTreeDoc() ?: run {
+ NavLogger.e("track", "saveViaSaf: SAF tree doc is null")
+ return false
+ }
+ // Avoid duplicates — delete if a file with this name already exists
+ dir.findFile(fileName)?.delete()
+
+ val file = dir.createFile("application/gpx+xml", fileName) ?: run {
+ NavLogger.e("track", "saveViaSaf: createFile failed for $fileName")
+ return false
+ }
+ return runCatching {
+ context.contentResolver.openOutputStream(file.uri)?.use {
+ it.write(gpx.toByteArray())
+ } ?: run {
+ file.delete()
+ NavLogger.e("track", "saveViaSaf: openOutputStream null for $fileName")
+ return@runCatching false
+ }
+ persistUri(file.uri)
+ NavLogger.i("track", "saveViaSaf: saved $fileName → ${file.uri}")
+ true
+ }.getOrElse { e ->
+ NavLogger.e("track", "saveViaSaf: write failed for $fileName: ${e.javaClass.simpleName}: ${e.message}")
+ runCatching { file.delete() }
+ false
+ }
+ }
+
+ private fun loadViaSaf(): List<List<TrackPoint>> {
+ val dir = safTreeDoc() ?: return emptyList()
+ val files = dir.listFiles().filter { it.name?.endsWith(".gpx") == true }
+ NavLogger.i("track", "loadViaSaf: ${files.size} gpx files in SAF dir")
+ return files.mapNotNull { doc ->
+ runCatching {
+ context.contentResolver.openInputStream(doc.uri)?.use { GpxParser.parse(it) }
+ ?.takeIf { it.isNotEmpty() }
+ }.getOrElse { e ->
+ NavLogger.e("track", "loadViaSaf: parse error ${doc.name}: ${e.javaClass.simpleName}: ${e.message}")
+ null
+ }
}
}
- // ── API 29+ ──────────────────────────────────────────────────────────────
+ // ── MediaStore (API 29+ fallback) ─────────────────────────────────────────
private fun saveViaMediaStore(fileName: String, gpx: String): Boolean {
- // Guard: external storage must be mounted before touching MediaStore
val storageState = Environment.getExternalStorageState()
if (storageState != Environment.MEDIA_MOUNTED) {
val msg = "storage not mounted (state=$storageState) — cannot save $fileName"
@@ -78,9 +163,6 @@ class TrackStorage(private val context: Context) {
return false
}
- // IS_PENDING marks the entry as in-progress, preventing a race condition on
- // Android 10-11 where insert() succeeds but openOutputStream() returns null
- // because the file hasn't been physically created on disk yet.
val values = ContentValues().apply {
put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml")
@@ -106,16 +188,10 @@ class TrackStorage(private val context: Context) {
}
stream.use { it.write(gpx.toByteArray()) }
- // Clear IS_PENDING so the file is visible to other apps and file managers
val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
context.contentResolver.update(uri, update, null, null)
- // Persist the URI so future loads don't need a MediaStore query.
- // Querying MediaStore.Files requires READ_EXTERNAL_STORAGE on Android ≤ 12
- // and has no clean permission path on Android 13+; owning the URI directly
- // lets openInputStream() work on any version without any permission.
persistUri(uri)
-
val msg = "saved $fileName (${gpx.length} bytes) → $uri"
Log.d(TAG, msg); NavLogger.i("track", msg)
true
@@ -130,8 +206,6 @@ class TrackStorage(private val context: Context) {
private fun loadViaMediaStore(): List<List<TrackPoint>> {
val tracks = mutableListOf<List<TrackPoint>>()
- // Primary: load from persisted URIs — no MediaStore query, no permissions needed.
- // Each URI was stored at save time and grants direct read access to the file.
val loaded = mutableSetOf<String>()
for (uriString in storedUris()) {
val fileUri = Uri.parse(uriString)
@@ -156,21 +230,12 @@ class TrackStorage(private val context: Context) {
}
NavLogger.i("track", "loadViaMediaStore: ${tracks.size} tracks from stored URIs")
- // Fallback: MediaStore query for tracks saved before URI persistence was added.
- // May fail with SecurityException on some Android versions (caught below).
- // Any track found here is immediately persisted so future loads skip the query.
val baseUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.RELATIVE_PATH,
MediaStore.MediaColumns.IS_PENDING
)
- // Do NOT filter by RELATIVE_PATH — equality matching is fragile across
- // Android versions and devices (trailing-slash normalisation, volume prefix,
- // etc.). The display-name pattern is specific enough on its own.
- // Filtering by OWNER_PACKAGE_NAME tells the system we only want our own files.
- // Android grants access to a package's own MediaStore entries without any
- // storage permission, including after a reinstall with the same package name.
val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ? AND " +
"${MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME} = ?"
val args = arrayOf("nav_%.gpx", context.packageName)
@@ -190,7 +255,6 @@ class TrackStorage(private val context: Context) {
val path = if (pathCol >= 0) c.getString(pathCol) else "?"
val pending = if (pendingCol >= 0) c.getInt(pendingCol) else -1
val fileUri = Uri.withAppendedPath(baseUri, id.toString())
- // Skip files already loaded via stored URIs
if (fileUri.toString() in loaded) continue
NavLogger.i("track", "loadViaMediaStore: fallback id=$id path=$path pending=$pending")
runCatching {
@@ -203,7 +267,6 @@ class TrackStorage(private val context: Context) {
NavLogger.i("track", "loadViaMediaStore: fallback id=$id → ${points.size} points")
if (points.isNotEmpty()) {
tracks.add(points)
- // Migrate: persist this URI so future launches skip the query
persistUri(fileUri)
}
}
@@ -259,15 +322,8 @@ class TrackStorage(private val context: Context) {
} ?: emptyList()
}
- // ── Post-reinstall recovery ───────────────────────────────────────────────
+ // ── Post-reinstall recovery (MediaStore path only) ────────────────────────
- /**
- * Asks the MediaScanner to re-index Documents/Nav/. When triggered by our
- * app, the scanner re-registers each file with OWNER_PACKAGE_NAME set to
- * our package, restoring ownership that Android clears on uninstall.
- * After this returns, the MediaStore query in [loadAllTracks] will find
- * the files again.
- */
suspend fun triggerNavDirRescan() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
val navDir = File(
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 52344f2..01cec43 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -1,5 +1,6 @@
package org.terst.nav.ui
+import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -14,6 +15,7 @@ import org.terst.nav.ais.CpaThresholds
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.thermal.ThermalReading
import org.terst.nav.thermal.ThermalState
+import org.terst.nav.track.SafState
import org.terst.nav.track.SavedTrack
import org.terst.nav.track.TrackPoint
import org.terst.nav.track.TrackRepository
@@ -107,6 +109,15 @@ class MainViewModel(
}
}
+ private val _safState = MutableStateFlow(trackRepository.safState())
+ val safState: StateFlow<SafState> = _safState.asStateFlow()
+
+ fun onSafDirectorySelected(treeUri: Uri) {
+ trackRepository.initSafDirectory(treeUri)
+ _safState.value = trackRepository.safState()
+ viewModelScope.launch { reloadPastTracks() }
+ }
+
private val _thermalState = MutableStateFlow(ThermalState.OK)
val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 1a0b9b6..7614a49 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -72,10 +72,12 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val USER_POS_LAYER_ID = "user-pos-layer"
private val USER_ICON_ID = "user-icon"
- private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
- private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
- private val TRACK_PAST_SOURCE_ID = "track-past-source"
- private val TRACK_PAST_LAYER_ID = "track-line-past"
+ private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
+ private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
+ private val TRACK_PAST_DIM_SOURCE_ID = "track-past-dim-source"
+ private val TRACK_PAST_DIM_LAYER_ID = "track-line-past-dim"
+ private val TRACK_PAST_FOCUS_SOURCE_ID = "track-past-focus-source"
+ private val TRACK_PAST_FOCUS_LAYER_ID = "track-line-past-focus"
private val WIND_SOURCE_ID = "wind-arrows-source"
private val WIND_LAYER_ID = "wind-arrows"
@@ -91,7 +93,8 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private var tidalCurrentSource: GeoJsonSource? = null
private var userPosSource: GeoJsonSource? = null
private var trackActiveSource: GeoJsonSource? = null
- private var trackPastSource: GeoJsonSource? = null
+ private var trackPastDimSource: GeoJsonSource? = null
+ private var trackPastFocusSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
private var windGridSource: GeoJsonSource? = null
private var tackSource: GeoJsonSource? = null
@@ -306,46 +309,68 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
* Updates the GPS track polyline on the map. Lazily initialises the layers on first call.
*/
fun updateTrackLayer(style: Style, activePoints: List<TrackPoint>, pastTracks: List<List<TrackPoint>>) {
- if (trackActiveSource == null) {
- trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
- style.addSource(trackActiveSource!!)
- style.addLayer(LineLayer(TRACK_ACTIVE_LAYER_ID, TRACK_ACTIVE_SOURCE_ID).apply {
+ if (trackPastDimSource == null) {
+ trackPastDimSource = GeoJsonSource(TRACK_PAST_DIM_SOURCE_ID)
+ style.addSource(trackPastDimSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_DIM_LAYER_ID, TRACK_PAST_DIM_SOURCE_ID).apply {
setProperties(
- PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineColor("#0B3050"),
+ PropertyFactory.lineOpacity(0.12f),
+ PropertyFactory.lineWidth(2.5f),
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
+ )
+ })
+ }
+
+ if (trackPastFocusSource == null) {
+ trackPastFocusSource = GeoJsonSource(TRACK_PAST_FOCUS_SOURCE_ID)
+ style.addSource(trackPastFocusSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_FOCUS_LAYER_ID, TRACK_PAST_FOCUS_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#2B8FC4"),
+ PropertyFactory.lineOpacity(1.0f),
PropertyFactory.lineWidth(4f),
- PropertyFactory.lineCap("round")
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
)
})
}
- if (trackPastSource == null) {
- trackPastSource = GeoJsonSource(TRACK_PAST_SOURCE_ID)
- style.addSource(trackPastSource!!)
- style.addLayer(LineLayer(TRACK_PAST_LAYER_ID, TRACK_PAST_SOURCE_ID).apply {
+ if (trackActiveSource == null) {
+ trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
+ style.addSource(trackActiveSource!!)
+ style.addLayer(LineLayer(TRACK_ACTIVE_LAYER_ID, TRACK_ACTIVE_SOURCE_ID).apply {
setProperties(
- PropertyFactory.lineColor("#0B3050"),
- PropertyFactory.lineOpacity(0.12f),
- PropertyFactory.lineWidth(2.5f),
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(4f),
PropertyFactory.lineCap("round"),
PropertyFactory.lineJoin("round")
)
})
}
+ val dimFeatures = pastTracks.map { track ->
+ Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
+ }
+ trackPastDimSource?.setGeoJson(FeatureCollection.fromFeatures(dimFeatures))
+
if (activePoints.size >= 2) {
val coords = activePoints.map { Point.fromLngLat(it.lon, it.lat) }
trackActiveSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
} else {
trackActiveSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
+ }
- if (pastTracks.isNotEmpty()) {
- val features = pastTracks.map { track ->
- Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
- }
- trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ /** Highlights [points] in the focus layer (full-opacity bright blue over the dim layer). */
+ fun setFocusedTrackPoints(points: List<TrackPoint>?) {
+ if (points != null && points.size >= 2) {
+ trackPastFocusSource?.setGeoJson(
+ Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) }))
+ )
} else {
- trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ trackPastFocusSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
}
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 193dbff..cf6805a 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
@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
@@ -18,24 +19,35 @@ import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.launch
+import org.terst.nav.MainActivity
import org.terst.nav.R
import org.terst.nav.logbook.VoiceLogState
import org.terst.nav.logbook.VoiceLogViewModel
+import org.terst.nav.track.SafState
+import org.terst.nav.track.SavedTrackAdapter
+import org.terst.nav.track.TrackDetailSheet
+import org.terst.nav.ui.MainViewModel
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
class VoiceLogFragment : Fragment() {
- private val viewModel by lazy {
+ private val logViewModel by lazy {
VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
}
+ private val mainViewModel: MainViewModel by activityViewModels()
+ private lateinit var layoutLogEntry: View
private lateinit var speechRecognizer: SpeechRecognizer
private lateinit var etNote: TextInputEditText
private lateinit var fabMic: FloatingActionButton
@@ -48,7 +60,6 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnSave: MaterialButton
private lateinit var btnClear: MaterialButton
private lateinit var tvSavedConfirmation: TextView
- private lateinit var btnGenerateReport: MaterialButton
private var pendingPhotoPath: String? = null
@@ -81,6 +92,7 @@ class VoiceLogFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ layoutLogEntry = view.findViewById(R.id.layout_log_entry)
etNote = view.findViewById(R.id.et_note)
fabMic = view.findViewById(R.id.fab_mic)
btnCamera = view.findViewById(R.id.btn_camera)
@@ -92,7 +104,6 @@ class VoiceLogFragment : Fragment() {
btnSave = view.findViewById(R.id.btn_save)
btnClear = view.findViewById(R.id.btn_clear)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
- btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
@@ -101,19 +112,74 @@ class VoiceLogFragment : Fragment() {
btnGallery.setOnClickListener { galleryLauncher.launch("image/*") }
btnRemovePhoto.setOnClickListener { clearPhoto() }
btnSave.setOnClickListener {
- viewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath)
+ logViewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath)
}
btnClear.setOnClickListener { clearEntry() }
- btnGenerateReport.setOnClickListener {
+ view.findViewById<MaterialButton>(R.id.btn_generate_report).setOnClickListener {
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
.addToBackStack(null)
.commit()
}
+ // Show log-entry section only while recording
viewLifecycleOwner.lifecycleScope.launch {
- viewModel.state.collect { state -> renderState(state) }
+ mainViewModel.isRecording.collect { recording ->
+ layoutLogEntry.visibility = if (recording) View.VISIBLE else View.GONE
+ }
+ }
+
+ // Inline track list
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
+
+ rv.layoutManager = LinearLayoutManager(requireContext())
+ rv.isNestedScrollingEnabled = false
+ rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+
+ val tracksAdapter = SavedTrackAdapter { track ->
+ mainViewModel.selectTrack(track)
+ (requireActivity() as MainActivity).hideOverlays()
+ TrackDetailSheet.newInstance().show(parentFragmentManager, "track_detail")
+ }
+ rv.adapter = tracksAdapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.savedTracks.collect { tracks ->
+ tracksAdapter.submitList(tracks)
+ layoutEmpty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
+ rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.safState.collect { state ->
+ when (state) {
+ SafState.NOT_CONFIGURED -> {
+ tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ btnSetup.text = "Set up track storage"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.PERMISSION_LOST -> {
+ tvEmpty.text = "Track storage needs re-authorization."
+ btnSetup.text = "Restore track access"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.CONFIGURED -> btnSetup.visibility = View.GONE
+ }
+ }
+ }
+ btnSetup.setOnClickListener {
+ (requireActivity() as MainActivity).launchSafPicker()
+ }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ logViewModel.state.collect { state -> renderState(state) }
}
}
@@ -127,14 +193,14 @@ class VoiceLogFragment : Fragment() {
}
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext())
speechRecognizer.setRecognitionListener(object : RecognitionListener {
- override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() }
+ override fun onReadyForSpeech(params: Bundle?) { logViewModel.onListeningStarted() }
override fun onResults(results: Bundle?) {
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull() ?: ""
- if (text.isNotBlank()) viewModel.onSpeechRecognized(text)
- else viewModel.onRecognitionError("Could not understand speech")
+ if (text.isNotBlank()) logViewModel.onSpeechRecognized(text)
+ else logViewModel.onRecognitionError("Could not understand speech")
}
- override fun onError(error: Int) { viewModel.onRecognitionError("Recognition error: $error") }
+ override fun onError(error: Int) { logViewModel.onRecognitionError("Recognition error: $error") }
override fun onBeginningOfSpeech() {}
override fun onBufferReceived(b: ByteArray?) {}
override fun onEndOfSpeech() {}
@@ -177,7 +243,7 @@ class VoiceLogFragment : Fragment() {
private fun clearEntry() {
etNote.setText("")
clearPhoto()
- viewModel.retry()
+ logViewModel.retry()
tvSavedConfirmation.text = ""
tvStatus.text = ""
}
diff --git a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
index 7485e59..a95b368 100644
--- a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
+++ b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
@@ -34,15 +34,32 @@
</LinearLayout>
- <TextView
- android:id="@+id/tv_empty"
+ <LinearLayout
+ android:id="@+id/layout_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:orientation="vertical"
android:gravity="center"
- android:text="No saved tracks yet.\nRecord a trip to see it here."
- android:textAlignment="center"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:visibility="gone" />
+ android:padding="32dp"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_setup_storage"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:text="Set up track storage"
+ android:visibility="gone" />
+
+ </LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_saved_tracks"
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 c1275a6..389add1 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -1,156 +1,221 @@
<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout 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"
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="20dp">
+ android:layout_height="match_parent">
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Log Entry"
- android:textAppearance="?attr/textAppearanceHeadlineSmall"
- android:layout_marginBottom="16dp" />
-
- <!-- Note text — voice fills this; user can also type directly -->
- <com.google.android.material.textfield.TextInputLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="12dp"
- style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/et_note"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:minLines="3"
- android:maxLines="6"
- android:gravity="top"
- android:hint="Tap mic or type a note…"
- android:inputType="textMultiLine|textCapSentences"
- android:scrollbars="vertical" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <!-- Action row: mic, camera, gallery, status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="12dp">
-
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_mic"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:fabSize="mini"
- android:src="@android:drawable/ic_btn_speak_now"
- android:contentDescription="Start voice recognition"
- android:layout_marginEnd="12dp" />
+ android:orientation="vertical"
+ android:padding="20dp">
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_camera"
- style="@style/Widget.Material3.Button.IconButton.Outlined"
- android:layout_width="wrap_content"
+ <!-- ── Log entry section — visible only while recording ── -->
+ <LinearLayout
+ android:id="@+id/layout_log_entry"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:contentDescription="Take photo"
- app:icon="@android:drawable/ic_menu_camera"
- android:layout_marginEnd="8dp" />
-
+ android:orientation="vertical"
+ android:visibility="gone">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Log Entry"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:layout_marginBottom="16dp" />
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/et_note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minLines="3"
+ android:maxLines="6"
+ android:gravity="top"
+ android:hint="Tap mic or type a note…"
+ android:inputType="textMultiLine|textCapSentences"
+ android:scrollbars="vertical" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <!-- Action row: mic, camera, gallery, status -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mic"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:fabSize="mini"
+ android:src="@android:drawable/ic_btn_speak_now"
+ android:contentDescription="Start voice recognition"
+ android:layout_marginEnd="12dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_camera"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Take photo"
+ app:icon="@android:drawable/ic_menu_camera"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_gallery"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Attach photo from gallery"
+ app:icon="@android:drawable/ic_menu_gallery"
+ android:layout_marginEnd="12dp" />
+
+ <TextView
+ android:id="@+id/tv_status"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <!-- Photo thumbnail — hidden until a photo is attached -->
+ <FrameLayout
+ android:id="@+id/frame_photo"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_photo"
+ android:layout_width="120dp"
+ android:layout_height="90dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Attached photo" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_remove_photo"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_gravity="top|end"
+ android:contentDescription="Remove photo"
+ app:icon="@drawable/ic_close" />
+
+ </FrameLayout>
+
+ <!-- Save / Clear row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_save"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Save"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_clear"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Clear" />
+
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/tv_saved_confirmation"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorPrimary"
+ android:layout_marginBottom="20dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutline"
+ android:layout_marginBottom="20dp" />
+
+ </LinearLayout>
+ <!-- end layout_log_entry -->
+
+ <!-- Generate Trip Report — always accessible -->
<com.google.android.material.button.MaterialButton
- android:id="@+id/btn_gallery"
- style="@style/Widget.Material3.Button.IconButton.Outlined"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:contentDescription="Attach photo from gallery"
- app:icon="@android:drawable/ic_menu_gallery"
- android:layout_marginEnd="12dp" />
+ android:id="@+id/btn_generate_report"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:text="GENERATE TRIP REPORT"
+ android:layout_marginBottom="24dp" />
+ <!-- ── Trips section ── -->
<TextView
- android:id="@+id/tv_status"
- android:layout_width="0dp"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text=""
- android:textSize="14sp"
- android:textColor="?attr/colorOnSurfaceVariant" />
-
- </LinearLayout>
-
- <!-- Photo thumbnail — hidden until a photo is attached -->
- <FrameLayout
- android:id="@+id/frame_photo"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="12dp"
- android:visibility="gone">
-
- <ImageView
- android:id="@+id/iv_photo"
- android:layout_width="120dp"
- android:layout_height="90dp"
- android:scaleType="centerCrop"
- android:contentDescription="Attached photo" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_remove_photo"
- style="@style/Widget.Material3.Button.IconButton"
- android:layout_width="32dp"
- android:layout_height="32dp"
- android:layout_gravity="top|end"
- android:contentDescription="Remove photo"
- app:icon="@drawable/ic_close" />
-
- </FrameLayout>
-
- <!-- Save / Clear row -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="12dp">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_save"
- android:layout_width="0dp"
+ android:text="Trips"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <!-- Empty state -->
+ <LinearLayout
+ android:id="@+id/layout_empty"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text="Save"
- android:layout_marginEnd="8dp" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_clear"
- style="@style/Widget.Material3.Button.TextButton"
- android:layout_width="wrap_content"
+ android:orientation="vertical"
+ android:gravity="center_horizontal"
+ android:paddingVertical="24dp"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_setup_storage"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="Set up track storage"
+ android:visibility="gone" />
+
+ </LinearLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_saved_tracks"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="Clear" />
+ android:nestedScrollingEnabled="false"
+ android:clipToPadding="false"
+ android:paddingBottom="80dp" />
</LinearLayout>
- <!-- Saved confirmation -->
- <TextView
- android:id="@+id/tv_saved_confirmation"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text=""
- android:textSize="14sp"
- android:textColor="?attr/colorPrimary"
- android:layout_marginBottom="24dp" />
-
- <View
- android:layout_width="match_parent"
- android:layout_height="1dp"
- android:background="?attr/colorOutline"
- android:layout_marginBottom="24dp" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_generate_report"
- style="@style/Widget.Material3.Button.TonalButton"
- android:layout_width="match_parent"
- android:layout_height="60dp"
- android:text="GENERATE TRIP REPORT" />
-
-</LinearLayout>
+</androidx.core.widget.NestedScrollView>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index f8dc365..5e58009 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -183,8 +183,7 @@
android:layout_marginEnd="-16dp"
app:layout_constraintTop_toBottomOf="@id/wave_divider"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintBottom_toBottomOf="parent">
+ app:layout_constraintEnd_toEndOf="parent">
<!-- Current -->
<LinearLayout
@@ -339,19 +338,5 @@
</LinearLayout>
- <!-- Saved Tracks button -->
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_saved_tracks"
- style="@style/Widget.Material3.Button.OutlinedButton"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="16dp"
- android:layout_marginTop="12dp"
- android:layout_marginBottom="8dp"
- android:text="Saved Tracks"
- android:textAllCaps="false"
- app:layout_constraintTop_toBottomOf="@id/forecast_row"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index 2df4109..c3b115c 100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -52,7 +52,7 @@
<color name="wave_sky_top">#2B8FC4</color>
<color name="wave_sky_bottom">#87C8DF</color>
<color name="wave_sea_top">#0D7A9A</color>
- <color name="wave_sea_bottom">#074B68</color>
+ <color name="wave_sea_bottom">#0D2137</color>
<color name="wave_shimmer">#50FFFFFF</color>
<color name="wave_whitecap">#80FFFFFF</color>