diff options
| author | Claude <noreply@anthropic.com> | 2026-05-20 21:20:27 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-20 21:20:27 +0000 |
| commit | 893bb60a4135e5a73a9ccc8a6d49a1953bdf0ef2 (patch) | |
| tree | 482a355bb4b176ec2c23d72c3566d5f31573ee03 /android-app/app | |
| parent | b3e8370e278cd9c4f822453a0a7bae05dd0ca729 (diff) | |
| parent | 4b0a4c76d94ace1593d66f6fd485b88b3b6c4994 (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')
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> |
