From 06fc6564b73ff2403eebabf5f9374a62abfe0772 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 07:14:19 +0000 Subject: Fix two pre-existing compile errors caught by CI - Enable buildConfig generation explicitly (AGP 8+ disabled it by default); fixes Unresolved reference 'BuildConfig' in MainActivity - Add required onCancellation lambda to cont.resume() in TrackStorage; fixes 'No value passed for parameter onCancellation' with coroutines 1.10.2 https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/build.gradle | 1 + android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle index 554b513..472d678 100644 --- a/android-app/app/build.gradle +++ b/android-app/app/build.gradle @@ -39,6 +39,7 @@ android { buildFeatures { viewBinding true + buildConfig true } testOptions { 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 59d7e53..146d10f 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 @@ -283,7 +283,7 @@ class TrackStorage(private val context: Context) { null ) { path, uri -> NavLogger.i("track", "triggerNavDirRescan: done path=$path uri=$uri") - if (cont.isActive) cont.resume(Unit) + if (cont.isActive) cont.resume(Unit) {} } } } ?: NavLogger.e("track", "triggerNavDirRescan: timed out") -- cgit v1.2.3 From d6a2e71c155f62a03e1ab466198a256c96c13b3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:38:33 +0000 Subject: Fix GPX file opening, share sheet visibility, and add SAF track storage Intent filter fixes: - Remove pathSuffix=".gpx" + scheme restriction from the application/octet-stream ACTION_VIEW filter; pathSuffix matches the URI path, not the filename, so it never fired against file manager content URIs (opaque paths like /provider/uuid). The existing display-name check in handleIncomingGpx() is the correct gate. - Add ACTION_SEND filter for application/octet-stream; Nav was invisible in share sheets because most file managers send this type for .gpx files. SAF-based track storage (API 29+): - Replace fragile SharedPrefs URI cache + OWNER_PACKAGE_NAME MediaStore query with Storage Access Framework as the primary write/read path. The user grants access to their tracks folder once via ACTION_OPEN_DOCUMENT_TREE; DocumentFile.listFiles() finds all GPX files without relying on MediaStore ownership (which Android clears on uninstall). After reinstall, a one-tap "Restore track access" button in Saved Tracks re-authorizes the same folder and all tracks immediately reappear. - MediaStore path (existing) is kept as fallback for tracks saved before this change - Add SafState enum (CONFIGURED / PERMISSION_LOST / NOT_CONFIGURED) in TrackStorage - TrackRepository exposes safState() and initSafDirectory() thin wrappers - MainViewModel exposes safState StateFlow and onSafDirectorySelected() - SavedTracksFragment observes safState and shows contextual setup / re-auth button - MainActivity registers safPickerLauncher and exposes launchSafPicker() - Add androidx.documentfile:documentfile:1.0.1 dependency https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/build.gradle | 1 + android-app/app/src/main/AndroidManifest.xml | 20 +-- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 20 ++- .../org/terst/nav/track/SavedTracksFragment.kt | 41 +++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 7 + .../kotlin/org/terst/nav/track/TrackStorage.kt | 150 ++++++++++++++------- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 11 ++ .../src/main/res/layout/fragment_saved_tracks.xml | 29 +++- 8 files changed, 210 insertions(+), 69 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 @@ - + - + - + @@ -90,6 +87,11 @@ + + + + + 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..8e4a2e1 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 @@ -540,12 +541,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 { 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 b2181c9..bef9df3 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,11 @@ class SavedTracksFragment : Fragment() { (requireActivity() as MainActivity).hideOverlays() } - val rv = view.findViewById(R.id.rv_saved_tracks) - val empty = view.findViewById(R.id.tv_empty) + val rv = view.findViewById(R.id.rv_saved_tracks) + val layoutEmpty = view.findViewById(R.id.layout_empty) + val tvEmpty = view.findViewById(R.id.tv_empty) + val btnSetup = view.findViewById(R.id.btn_setup_storage) + rv.layoutManager = LinearLayoutManager(requireContext()) rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) @@ -49,9 +54,35 @@ class SavedTracksFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewModel.savedTracks.collect { tracks -> - adapter.submitList(tracks) - empty.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, 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> { - 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> { + 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> { val tracks = mutableListOf>() - // 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() 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.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.asStateFlow() 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 @@ - + android:padding="32dp" + android:visibility="gone"> + + + + + + Date: Wed, 20 May 2026 09:29:48 +0000 Subject: Fix blank track list and Recenter button bleed-through in Saved Tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SavedTracksFragment was missing adapter.submitList(tracks) after the collect block rewrite — tracks loaded fine (16 found via SAF) but the RecyclerView adapter never received them, leaving the list visually blank - showOverlay() now also hides fabRecenter; it was not in the hide list so the map's Recenter button remained visible on top of the fragment overlay https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt | 3 +++ .../app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt | 1 + 2 files changed, 4 insertions(+) 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 8e4a2e1..a4453be 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 @@ -429,6 +429,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { 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() @@ -438,6 +439,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fragmentContainer.visibility = View.GONE fabRecordTrack.visibility = View.VISIBLE fabLayers.visibility = View.VISIBLE + // fabRecenter visibility is managed by the isFollowing observer; just restore alpha + fabRecenter.alpha = 1f // Re-apply cached conditions in case they arrived while the overlay was covering the sheet lastConditions?.let { applyConditions(it) } } 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 bef9df3..d8b9858 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 @@ -54,6 +54,7 @@ class SavedTracksFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewModel.savedTracks.collect { tracks -> + adapter.submitList(tracks) val empty = tracks.isEmpty() layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE rv.visibility = if (empty) View.GONE else View.VISIBLE -- cgit v1.2.3 From 925790fee2d3c092cb7ca20797c7b57eeff40fea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 09:33:31 +0000 Subject: Move Saved Tracks to Log tab; restore conditions sheet on back - hideOverlays() now resets bottomSheetBehavior to COLLAPSED/not-hideable, so the conditions sheet reappears correctly when returning from any overlay (previously it stayed hidden after navigating away via the track browser) - Remove btn_saved_tracks from the conditions sheet (layout + click handler) - Add btn_saved_tracks to the Log screen layout and wire it in VoiceLogFragment via MainActivity.showSavedTracks(); tracks live with the log, not with weather https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../app/src/main/kotlin/org/terst/nav/MainActivity.kt | 16 ++++++---------- .../kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 6 ++++++ .../app/src/main/res/layout/fragment_voice_log.xml | 9 +++++++++ .../app/src/main/res/layout/layout_instruments_sheet.xml | 14 -------------- 4 files changed, 21 insertions(+), 24 deletions(-) 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 a4453be..7ce417d 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 @@ -303,14 +303,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } - // "Saved Tracks" button in the instrument sheet - findViewById(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]. */ @@ -435,13 +427,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { .commit() } + fun showSavedTracks() { + showOverlay(SavedTracksFragment()) + } + internal fun hideOverlays() { fragmentContainer.visibility = View.GONE fabRecordTrack.visibility = View.VISIBLE fabLayers.visibility = View.VISIBLE - // fabRecenter visibility is managed by the isFollowing observer; just restore alpha fabRecenter.alpha = 1f - // Re-apply cached conditions in case they arrived while the overlay was covering the sheet + bottomSheetBehavior.isHideable = false + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED lastConditions?.let { applyConditions(it) } } 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 ab30d96..105057c 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 @@ -23,9 +23,11 @@ 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.SavedTracksFragment import java.io.File import java.io.FileOutputStream import java.util.Locale @@ -124,6 +126,10 @@ class VoiceLogFragment : Fragment() { .commit() } + view.findViewById(R.id.btn_saved_tracks).setOnClickListener { + (requireActivity() as MainActivity).showSavedTracks() + } + viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { state -> renderState(state) } } 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..16c3574 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 @@ -153,4 +153,13 @@ android:layout_height="60dp" android:text="GENERATE TRIP REPORT" /> + + 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..31c445e 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 @@ -339,19 +339,5 @@ - - -- cgit v1.2.3 From 6dc2b16da127dd0a68907c037e8219eaacc8806c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 09:40:20 +0000 Subject: Fix gap between wave animation and ocean conditions panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the spurious constraintBottom_toBottomOf="parent" from forecast_row: ConstraintLayout was vertically centering it between the wave and the sheet bottom (default bias 0.5) instead of placing it flush below the wave. Also aligned light-mode wave_sea_bottom (#074B68 → #0D2137) with the forecast_row background so the wave tail blends seamlessly in light theme. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/res/layout/layout_instruments_sheet.xml | 3 +-- android-app/app/src/main/res/values/colors.xml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 31c445e..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"> #2B8FC4 #87C8DF #0D7A9A - #074B68 + #0D2137 #50FFFFFF #80FFFFFF -- cgit v1.2.3 From cdb3fa7c6167e108f46b4d411655f78a22e3bebe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 09:52:18 +0000 Subject: Fix Recenter bleed-through; drop noisy speed events; track detail as bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recenter button was reappearing over overlays because the isFollowing flow kept calling fadeIn(fabRecenter) even when an overlay was visible. Added overlayShowing flag — the flow skips FAB updates while an overlay is shown. hideOverlays() now correctly restores fabRecenter visibility based on the actual following state instead of just resetting alpha. Speed up/Slow down events fired on every 1.5 kt GPS jitter, flooding the log with noise. Removed the detection loop entirely — no reliable motor on/off signal is available from GPS alone. TrackDetailSheet converted from a full-screen Fragment to a BottomSheetDialogFragment: opens at half-screen height so the track route is visible on the map behind it, draggable to full height, 20% background dim. SavedTracksFragment now calls hideOverlays() then sheet.show() instead of fragment-replacing the overlay container. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 19 ++++++++---- .../org/terst/nav/track/SavedTracksFragment.kt | 7 ++--- .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 36 +++++++++------------- .../main/res/layout/layout_track_detail_sheet.xml | 7 +++++ 4 files changed, 36 insertions(+), 33 deletions(-) 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 7ce417d..caabe40 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 @@ -114,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() @@ -418,6 +419,7 @@ 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 @@ -432,9 +434,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } internal fun hideOverlays() { + overlayShowing = false fragmentContainer.visibility = View.GONE fabRecordTrack.visibility = View.VISIBLE fabLayers.visibility = View.VISIBLE + 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 @@ -592,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) + } } } } 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 d8b9858..f992e1e 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 @@ -44,11 +44,8 @@ class SavedTracksFragment : Fragment() { val adapter = SavedTrackAdapter { track -> viewModel.selectTrack(track) - parentFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) - .addToBackStack(null) - .commit() + (requireActivity() as MainActivity).hideOverlays() + TrackDetailSheet.newInstance().show(parentFragmentManager, "track_detail") } rv.adapter = adapter diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt index c1764a8..627ce89 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt @@ -5,11 +5,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.terst.nav.R import org.terst.nav.ui.MainViewModel import java.time.Instant @@ -28,7 +30,7 @@ data class LogEvent( val detail: String ) -class TrackDetailSheet : Fragment() { +class TrackDetailSheet : BottomSheetDialogFragment() { private val viewModel: MainViewModel by activityViewModels() @@ -41,13 +43,11 @@ class TrackDetailSheet : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val track = viewModel.selectedTrack.value ?: run { - parentFragmentManager.popBackStack() + dismiss() return } - view.findViewById(R.id.btn_back).setOnClickListener { - parentFragmentManager.popBackStack() - } + view.findViewById(R.id.btn_back).setOnClickListener { dismiss() } val titleFmt = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm", Locale.getDefault()) .withZone(ZoneId.systemDefault()) @@ -70,6 +70,14 @@ class TrackDetailSheet : Fragment() { rv.adapter = LogEventAdapter(events) { event -> viewModel.panToTrackPoint(event.lat, event.lon) } + + (dialog as? BottomSheetDialog)?.behavior?.apply { + isFitToContents = false + halfExpandedRatio = 0.5f + skipCollapsed = true + state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + dialog?.window?.setDimAmount(0.2f) } override fun onDestroyView() { @@ -96,22 +104,6 @@ class TrackDetailSheet : Fragment() { "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) } - for (i in 1 until points.size - 1) { - val prev = points[i - 1].sogKnots - val curr = points[i].sogKnots - val next = points[i + 1].sogKnots - val rise = curr - prev - val fall = curr - next - val p = points[i] - if (rise > 1.5 && rise > fall) { - events += LogEvent(p.lat, p.lon, p.timestampMs, "↑", - "Speed up", "%.1f → %.1f kt".format(prev, curr)) - } else if (fall > 1.5 && fall > rise) { - events += LogEvent(p.lat, p.lon, p.timestampMs, "↓", - "Slow down", "%.1f → %.1f kt".format(curr, next)) - } - } - points.lastOrNull()?.let { p -> events += LogEvent(p.lat, p.lon, p.timestampMs, "🏁", "End", formatPointDetail(p)) } diff --git a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml index 6a2ba50..97b1da8 100644 --- a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml +++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml @@ -6,6 +6,13 @@ android:orientation="vertical" android:background="?attr/colorSurface"> + + + Date: Wed, 20 May 2026 09:59:26 +0000 Subject: Restyle past tracks: solid dark blue, dim/focus layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All past tracks render as solid dark navy (#0B3050) at 45% opacity, 2.5px — no dashes. A second focus layer (#2B8FC4, full opacity, 4px) paints on top when a track is selected, making it pop without changing the dim layer's data. Active recording stays red. The dim layer always contains every past track; the focus layer gets the selected track's points independently via setFocusedTrackPoints(). Cleared on dismiss so all tracks return to equal dim opacity. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 3 +- .../src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 71 +++++++++++++++------- 2 files changed, 50 insertions(+), 24 deletions(-) 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 caabe40..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 @@ -818,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/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 32b3643..b8ce460 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,6 +309,34 @@ 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, pastTracks: List>) { + 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("#0B3050"), + PropertyFactory.lineOpacity(0.45f), + 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.lineJoin("round") + ) + }) + } + if (trackActiveSource == null) { trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID) style.addSource(trackActiveSource!!) @@ -313,23 +344,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) { setProperties( PropertyFactory.lineColor("#E53935"), 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 { - setProperties( - PropertyFactory.lineColor("#E53935"), - PropertyFactory.lineWidth(3f), - PropertyFactory.lineDasharray(arrayOf(1f, 2f)), - PropertyFactory.lineCap("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) } @@ -337,15 +361,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) { } 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?) { + val geojson = if (points != null && points.size >= 2) { + Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })) } else { - trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) + FeatureCollection.fromFeatures(emptyList()) } + trackPastFocusSource?.setGeoJson(geojson) } /** -- cgit v1.2.3 From 3793c919f14d8ae8d8814fde37786418a3dfdae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 10:06:14 +0000 Subject: Inline saved tracks on Log tab; collapse log entry when not recording Removed the 'Saved Tracks' button that opened a separate overlay. Tracks now appear directly on the Log screen in a RecyclerView below the Generate Trip Report button. Tapping a track hides the overlay and opens the existing TrackDetailSheet bottom sheet over the map. The log entry section (text field, mic, camera, save/clear) is hidden with visibility=gone when not recording and shown when a recording is in progress. The storage-setup prompt is now surfaced here too. SavedTrackAdapter and DATE_FMT widened to internal so VoiceLogFragment can reuse them directly. SavedTracksFragment kept for the GPX import flow. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../org/terst/nav/track/SavedTracksFragment.kt | 4 +- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 125 +++++--- .../app/src/main/res/layout/fragment_voice_log.xml | 350 ++++++++++++--------- 3 files changed, 290 insertions(+), 189 deletions(-) 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 f992e1e..44055ce 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 @@ -86,11 +86,11 @@ class SavedTracksFragment : Fragment() { } } -private val DATE_FMT: DateTimeFormatter = +internal val DATE_FMT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE dd MMM yyyy · HH:mm", Locale.getDefault()) .withZone(ZoneId.systemDefault()) -class SavedTrackAdapter( +internal class SavedTrackAdapter( private val onSelect: (SavedTrack) -> Unit ) : RecyclerView.Adapter() { 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 105057c..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,7 +19,11 @@ 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 @@ -27,17 +32,22 @@ 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.SavedTracksFragment +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 @@ -50,13 +60,9 @@ class VoiceLogFragment : Fragment() { private lateinit var btnSave: MaterialButton private lateinit var btnClear: MaterialButton private lateinit var tvSavedConfirmation: TextView - private lateinit var btnGenerateReport: MaterialButton - /** Path or URI string for the currently attached photo, null if none. */ private var pendingPhotoPath: String? = null - // ── Camera (TakePicturePreview returns a thumbnail bitmap — no FileProvider needed) ── - private val cameraLauncher = registerForActivityResult( ActivityResultContracts.TakePicturePreview() ) { bitmap: Bitmap? -> @@ -71,18 +77,12 @@ class VoiceLogFragment : Fragment() { } } - // ── Gallery (GetContent — no storage permission needed; URI valid for this session) ── - private val galleryLauncher = registerForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> - if (uri != null) { - setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) } - } + if (uri != null) setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) } } - // ── Lifecycle ───────────────────────────────────────────────────────────── - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -92,18 +92,18 @@ class VoiceLogFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - etNote = view.findViewById(R.id.et_note) - fabMic = view.findViewById(R.id.fab_mic) - btnCamera = view.findViewById(R.id.btn_camera) - btnGallery = view.findViewById(R.id.btn_gallery) - tvStatus = view.findViewById(R.id.tv_status) - framePhoto = view.findViewById(R.id.frame_photo) - ivPhoto = view.findViewById(R.id.iv_photo) - btnRemovePhoto = view.findViewById(R.id.btn_remove_photo) - btnSave = view.findViewById(R.id.btn_save) - btnClear = view.findViewById(R.id.btn_clear) + 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) + btnGallery = view.findViewById(R.id.btn_gallery) + tvStatus = view.findViewById(R.id.tv_status) + framePhoto = view.findViewById(R.id.frame_photo) + ivPhoto = view.findViewById(R.id.iv_photo) + btnRemovePhoto = view.findViewById(R.id.btn_remove_photo) + 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() @@ -111,27 +111,75 @@ class VoiceLogFragment : Fragment() { btnCamera.setOnClickListener { cameraLauncher.launch(null) } btnGallery.setOnClickListener { galleryLauncher.launch("image/*") } btnRemovePhoto.setOnClickListener { clearPhoto() } - btnSave.setOnClickListener { - val text = etNote.text?.toString()?.trim() ?: "" - viewModel.save(text, pendingPhotoPath) + logViewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath) } - btnClear.setOnClickListener { clearEntry() } - btnGenerateReport.setOnClickListener { + view.findViewById(R.id.btn_generate_report).setOnClickListener { parentFragmentManager.beginTransaction() .replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment()) .addToBackStack(null) .commit() } - view.findViewById(R.id.btn_saved_tracks).setOnClickListener { - (requireActivity() as MainActivity).showSavedTracks() + // Show log-entry section only while recording + viewLifecycleOwner.lifecycleScope.launch { + mainViewModel.isRecording.collect { recording -> + layoutLogEntry.visibility = if (recording) View.VISIBLE else View.GONE + } + } + + // Inline track list + val rv = view.findViewById(R.id.rv_saved_tracks) + val layoutEmpty = view.findViewById(R.id.layout_empty) + val tvEmpty = view.findViewById(R.id.tv_empty) + val btnSetup = view.findViewById(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 { - viewModel.state.collect { state -> renderState(state) } + logViewModel.state.collect { state -> renderState(state) } } } @@ -145,16 +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") - } - override fun onError(error: Int) { - viewModel.onRecognitionError("Recognition error: $error") + if (text.isNotBlank()) logViewModel.onSpeechRecognized(text) + else logViewModel.onRecognitionError("Could not understand speech") } + override fun onError(error: Int) { logViewModel.onRecognitionError("Recognition error: $error") } override fun onBeginningOfSpeech() {} override fun onBufferReceived(b: ByteArray?) {} override fun onEndOfSpeech() {} @@ -197,7 +243,7 @@ class VoiceLogFragment : Fragment() { private fun clearEntry() { etNote.setText("") clearPhoto() - viewModel.retry() + logViewModel.retry() tvSavedConfirmation.text = "" tvStatus.text = "" } @@ -215,7 +261,6 @@ class VoiceLogFragment : Fragment() { fabMic.isEnabled = false } is VoiceLogState.Result -> { - // Fill the text field; user can edit before saving etNote.setText(state.recognized) etNote.setSelection(state.recognized.length) tvStatus.text = "" 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 16c3574..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,165 +1,221 @@ - + android:layout_height="match_parent"> - - - - - - - - - - + android:orientation="vertical" + android:padding="20dp"> - - - + - + android:orientation="vertical" + android:visibility="gone"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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" /> + - - - - - - - - - - - - - - - - + + + - - + + + + + + + + + android:nestedScrollingEnabled="false" + android:clipToPadding="false" + android:paddingBottom="80dp" /> - - - - - - - - - - + -- cgit v1.2.3 From a45ca70410c3a6b15051f26ef4325be3de8d6c37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 16:12:41 +0000 Subject: Fix setGeoJson overload ambiguity in setFocusedTrackPoints Kotlin inferred Feature|FeatureCollection as their common supertype, which didn't match any single setGeoJson overload. Split into two explicit branches so each call site has an unambiguous type. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 b8ce460..5449db2 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 @@ -365,12 +365,13 @@ class MapHandler(private val maplibreMap: MapLibreMap) { /** Highlights [points] in the focus layer (full-opacity bright blue over the dim layer). */ fun setFocusedTrackPoints(points: List?) { - val geojson = if (points != null && points.size >= 2) { - Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })) + if (points != null && points.size >= 2) { + trackPastFocusSource?.setGeoJson( + Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })) + ) } else { - FeatureCollection.fromFeatures(emptyList()) + trackPastFocusSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList())) } - trackPastFocusSource?.setGeoJson(geojson) } /** -- cgit v1.2.3 From fbb6afd05383cb1a72597ed3a8e853d029645f48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 16:32:10 +0000 Subject: Halve past track opacity (0.45 → 0.22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5449db2..0c31024 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 @@ -315,7 +315,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) { style.addLayer(LineLayer(TRACK_PAST_DIM_LAYER_ID, TRACK_PAST_DIM_SOURCE_ID).apply { setProperties( PropertyFactory.lineColor("#0B3050"), - PropertyFactory.lineOpacity(0.45f), + PropertyFactory.lineOpacity(0.22f), PropertyFactory.lineWidth(2.5f), PropertyFactory.lineCap("round"), PropertyFactory.lineJoin("round") -- cgit v1.2.3 From 4b0a4c76d94ace1593d66f6fd485b88b3b6c4994 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 16:36:35 +0000 Subject: Fix track detail sheet expansion; fix tack detection sensitivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sheet: set isDraggable=false so scrolling the event list doesn't push the sheet up over the map. Top 50% stays map, bottom 50% stays list. Tack detector: replaced 5-index skip (~5 s at 1 Hz) with a 45-second timestamp gap. Also tightened STABILITY_MAX from 30° to 20° to reduce false positives from GPS heading noise. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../src/main/kotlin/org/terst/nav/track/TackDetector.kt | 16 ++++++++-------- .../main/kotlin/org/terst/nav/track/TrackDetailSheet.kt | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt index 6616acf..3c3385c 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -3,20 +3,20 @@ package org.terst.nav.track import kotlin.math.abs object TackDetector { - private const val HALF_WIN = 2 - private const val MIN_DELTA = 60.0 - private const val MAX_DELTA = 140.0 - private const val SKIP_AFTER = 5 - private const val STABILITY_MAX = 30.0 + private const val HALF_WIN = 2 + private const val MIN_DELTA = 60.0 + private const val MAX_DELTA = 140.0 + private const val STABILITY_MAX = 20.0 + private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks fun detectTacks(points: List): List { if (points.size < 2 * HALF_WIN + 1) return emptyList() val results = mutableListOf() - var nextCandidate = 0 + var lastTackMs = Long.MIN_VALUE for (i in HALF_WIN until points.size - HALF_WIN) { - if (i < nextCandidate) continue + if (points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg)) val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg)) @@ -28,7 +28,7 @@ object TackDetector { if (delta in MIN_DELTA..MAX_DELTA) { results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) - nextCandidate = i + SKIP_AFTER + lastTackMs = points[i].timestampMs } } return results diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt index 627ce89..98205fd 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt @@ -75,6 +75,7 @@ class TrackDetailSheet : BottomSheetDialogFragment() { isFitToContents = false halfExpandedRatio = 0.5f skipCollapsed = true + isDraggable = false state = BottomSheetBehavior.STATE_HALF_EXPANDED } dialog?.window?.setDimAmount(0.2f) -- cgit v1.2.3