From 48ad5dca0452689de86b7edf0b6638fe7c5c7025 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:43:25 +0000 Subject: Fix tack detection and convert track detail to embedded-map fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TackDetector: revert STABILITY_MAX to 30° (was tightened too far to 20°), add 120s start-skip to filter GPS cold-start false positives, keep 45s minimum gap between tacks. TrackDetailSheet: convert from BottomSheetDialogFragment to a regular Fragment with an embedded MapLibreMap in the top half. This fixes the map centering issue (the embedded map is always sized to its own frame), eliminates accidental dismissal when dragging the map, and makes the view self-contained — back navigation restores the track list via the fragment back stack. Tack markers rendered on the embedded map; tapping a log event pans the embedded map directly. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../main/res/layout/layout_track_detail_sheet.xml | 179 ++++++++++++--------- 1 file changed, 100 insertions(+), 79 deletions(-) (limited to 'android-app/app/src/main/res/layout') 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..fafd75a 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,100 +6,121 @@ android:orientation="vertical" android:background="?attr/colorSurface"> +<<<<<<< Updated upstream +======= + + + + +>>>>>>> Stashed changes - + + - + - + - - - - - - - - - - + + - - - - + + + + + + + + + + + + + + + + + + + - - - - + - + - - - + + + -- cgit v1.2.3 From 7239d5ecb8c5fb80e68ddac72e88d1ef2116fcd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:46:27 +0000 Subject: Fix XML conflict markers left in layout_track_detail_sheet.xml https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/res/layout/layout_track_detail_sheet.xml | 4 ---- 1 file changed, 4 deletions(-) (limited to 'android-app/app/src/main/res/layout') 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 fafd75a..993edf8 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,9 +6,6 @@ android:orientation="vertical" android:background="?attr/colorSurface"> -<<<<<<< Updated upstream - -======= ->>>>>>> Stashed changes Date: Mon, 25 May 2026 18:12:11 +0000 Subject: Persist log entries and photos; stamp with GPS location - LogbookStorage: Moshi-backed JSON persistence to getExternalFilesDir, photos saved to Pictures/Nav/ instead of cache - LogbookRepository: file-backed replacement for in-memory store, loads saved entries on init so they survive app restarts - VoiceLogViewModel: expose entries StateFlow, accept lat/lon in save() - VoiceLogFragment: switch camera to TakePicture(Uri) via FileProvider for full-res photos; gallery copies content URI to persistent storage; passes current GPS position to save(); shows scrollable entries list - MainViewModel: expose currentPosition StateFlow updated on each GPS fix - AndroidManifest: add FileProvider for Pictures/Nav/ directory - TripReportViewModel: updated to use LogbookRepository https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/AndroidManifest.xml | 9 ++ .../main/kotlin/org/terst/nav/NavApplication.kt | 4 +- .../org/terst/nav/logbook/LogbookRepository.kt | 18 +++ .../kotlin/org/terst/nav/logbook/LogbookStorage.kt | 54 ++++++++ .../org/terst/nav/logbook/VoiceLogViewModel.kt | 15 ++- .../terst/nav/tripreport/TripReportViewModel.kt | 4 +- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 4 + .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 148 +++++++++++++++++++-- .../app/src/main/res/layout/fragment_voice_log.xml | 28 ++++ .../app/src/main/res/layout/item_logbook_entry.xml | 50 +++++++ android-app/app/src/main/res/xml/file_paths.xml | 4 + 11 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt create mode 100644 android-app/app/src/main/res/layout/item_logbook_entry.xml create mode 100644 android-app/app/src/main/res/xml/file_paths.xml (limited to 'android-app/app/src/main/res/layout') diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 23817bb..1701e17 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -23,6 +23,15 @@ + + + ().apply { addAll(storage.loadAll()) } + private var nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L + + val photoDir get() = storage.photoDir + + fun save(entry: LogEntry): LogEntry { + val saved = entry.copy(id = nextId++) + entries.add(saved) + storage.saveAll(entries) + return saved + } + + fun getAll(): List = entries.toList() +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt new file mode 100644 index 0000000..35349f0 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt @@ -0,0 +1,54 @@ +package org.terst.nav.logbook + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Environment +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.terst.nav.ui.NavLogger +import java.io.File +import java.io.FileOutputStream + +class LogbookStorage(private val context: Context) { + + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val adapter = moshi.adapter(LogEntryList::class.java) + + private val storageFile: File + get() = File(context.getExternalFilesDir(null), "nav_logbook.json") + + val photoDir: File + get() = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "Nav") + .also { it.mkdirs() } + + fun loadAll(): List = runCatching { + if (!storageFile.exists()) return emptyList() + adapter.fromJson(storageFile.readText())?.entries ?: emptyList() + }.getOrElse { e -> + NavLogger.e("logbook", "loadAll failed: ${e.message}") + emptyList() + } + + fun saveAll(entries: List): Boolean = runCatching { + storageFile.parentFile?.mkdirs() + storageFile.writeText(adapter.toJson(LogEntryList(entries))) + true + }.getOrElse { false } + + fun saveBitmap(bitmap: Bitmap): String? = runCatching { + val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg") + FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + dest.absolutePath + }.getOrElse { null } + + fun copyFromUri(uri: Uri): String? = runCatching { + val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + dest.absolutePath + }.getOrElse { null } +} + +data class LogEntryList(val entries: List) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt index 0a68ba8..67e1062 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt @@ -3,11 +3,14 @@ package org.terst.nav.logbook import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { +class VoiceLogViewModel(private val repository: LogbookRepository) { private val _state = MutableStateFlow(VoiceLogState.Idle) val state: StateFlow = _state + private val _entries = MutableStateFlow>(repository.getAll().reversed()) + val entries: StateFlow> = _entries + fun onListeningStarted() { _state.value = VoiceLogState.Listening } @@ -20,20 +23,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { _state.value = VoiceLogState.Error(message) } - /** - * Save an entry with the given text and optional photo path. - * Called directly by the fragment from the Save button so the user - * can have edited the recognized text in the EditText beforehand. - */ - fun save(text: String, photoPath: String? = null) { + fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null) { if (text.isBlank() && photoPath == null) return val entry = LogEntry( timestampMs = System.currentTimeMillis(), text = text.ifBlank { "(photo only)" }, entryType = EntryType.GENERAL, + lat = lat, + lon = lon, photoPath = photoPath ) val saved = repository.save(entry) + _entries.value = repository.getAll().reversed() _state.value = VoiceLogState.Saved(saved) } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt index e474cd2..45cbed5 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.terst.nav.logbook.InMemoryLogbookRepository +import org.terst.nav.logbook.LogbookRepository import org.terst.nav.track.TrackRepository sealed class TripReportState { @@ -18,7 +18,7 @@ sealed class TripReportState { class TripReportViewModel( private val trackRepository: TrackRepository, - private val logbookRepository: InMemoryLogbookRepository, + private val logbookRepository: LogbookRepository, private val generator: TripReportGenerator = TripReportGenerator() ) : ViewModel() { 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 01cec43..49ce0f5 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 @@ -92,6 +92,9 @@ class MainViewModel( private var lastSog: Double = 0.0 private var lastCog: Double = 0.0 + private val _currentPosition = MutableStateFlow?>(null) + val currentPosition: StateFlow?> = _currentPosition.asStateFlow() + private val aisRepository = AisRepository() private val trackRepository = NavApplication.trackRepository @@ -221,6 +224,7 @@ class MainViewModel( fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg + _currentPosition.value = lat to lon val conditions = _marineConditions.value val forecast = _forecast.value.firstOrNull() val point = TrackPoint( 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 fb1001a..31d9715 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 @@ -3,7 +3,7 @@ package org.terst.nav.ui.voicelog import android.Manifest import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Bundle @@ -18,6 +18,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope @@ -27,9 +28,13 @@ 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.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.terst.nav.MainActivity +import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.logbook.LogEntry import org.terst.nav.logbook.VoiceLogState import org.terst.nav.logbook.VoiceLogViewModel import org.terst.nav.track.SafState @@ -38,12 +43,15 @@ import org.terst.nav.track.TrackDetailSheet import org.terst.nav.ui.MainViewModel import java.io.File import java.io.FileOutputStream +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale class VoiceLogFragment : Fragment() { private val logViewModel by lazy { - VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository) + VoiceLogViewModel(repository = NavApplication.logbookRepository) } private val mainViewModel: MainViewModel by activityViewModels() @@ -62,25 +70,47 @@ class VoiceLogFragment : Fragment() { private lateinit var tvSavedConfirmation: TextView private var pendingPhotoPath: String? = null + private var pendingCameraUri: Uri? = null private val cameraLauncher = registerForActivityResult( - ActivityResultContracts.TakePicturePreview() - ) { bitmap: Bitmap? -> - if (bitmap != null) { - val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg") - try { - FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) } - setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) } - } catch (_: Exception) { - tvStatus.text = "Couldn't save photo" + ActivityResultContracts.TakePicture() + ) { success: Boolean -> + if (success) { + val path = pendingPhotoPath ?: return@registerForActivityResult + setPhoto(path) { + val bm = BitmapFactory.decodeFile(path) + if (bm != null) ivPhoto.setImageBitmap(bm) + else ivPhoto.setImageURI(pendingCameraUri) } + } else { + pendingPhotoPath?.let { File(it).delete() } + pendingPhotoPath = null + pendingCameraUri = null } } private val galleryLauncher = registerForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> - if (uri != null) setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) } + if (uri != null) { + lifecycleScope.launch { + val path = withContext(Dispatchers.IO) { + runCatching { + val dir = NavApplication.logbookRepository.photoDir + val dest = File(dir, "log_${System.currentTimeMillis()}.jpg") + requireContext().contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + dest.absolutePath + }.getOrNull() + } + if (path != null) { + setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) } + } else { + tvStatus.text = "Couldn't copy photo" + } + } + } } override fun onCreateView( @@ -108,11 +138,28 @@ class VoiceLogFragment : Fragment() { setupSpeechRecognizer() fabMic.setOnClickListener { startListening() } - btnCamera.setOnClickListener { cameraLauncher.launch(null) } + + btnCamera.setOnClickListener { + val dir = NavApplication.logbookRepository.photoDir + val file = File(dir, "log_${System.currentTimeMillis()}.jpg") + pendingPhotoPath = file.absolutePath + pendingCameraUri = FileProvider.getUriForFile( + requireContext(), "${requireContext().packageName}.fileprovider", file + ) + cameraLauncher.launch(pendingCameraUri!!) + } + btnGallery.setOnClickListener { galleryLauncher.launch("image/*") } btnRemovePhoto.setOnClickListener { clearPhoto() } + btnSave.setOnClickListener { - logViewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath) + val pos = mainViewModel.currentPosition.value + logViewModel.save( + text = etNote.text?.toString()?.trim() ?: "", + photoPath = pendingPhotoPath, + lat = pos?.first, + lon = pos?.second + ) } btnClear.setOnClickListener { clearEntry() } @@ -130,6 +177,23 @@ class VoiceLogFragment : Fragment() { } } + // Log entries list + val tvNoEntries = view.findViewById(R.id.tv_no_log_entries) + val rvLogEntries = view.findViewById(R.id.rv_log_entries) + rvLogEntries.layoutManager = LinearLayoutManager(requireContext()) + rvLogEntries.isNestedScrollingEnabled = false + rvLogEntries.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + val logAdapter = LogbookEntryAdapter() + rvLogEntries.adapter = logAdapter + + viewLifecycleOwner.lifecycleScope.launch { + logViewModel.entries.collect { entries -> + logAdapter.submitList(entries) + tvNoEntries.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE + rvLogEntries.visibility = if (entries.isEmpty()) View.GONE else View.VISIBLE + } + } + // Inline track list val rv = view.findViewById(R.id.rv_saved_tracks) val layoutEmpty = view.findViewById(R.id.layout_empty) @@ -235,6 +299,7 @@ class VoiceLogFragment : Fragment() { private fun clearPhoto() { pendingPhotoPath = null + pendingCameraUri = null ivPhoto.setImageDrawable(null) framePhoto.visibility = View.GONE } @@ -302,3 +367,58 @@ class VoiceLogFragment : Fragment() { private const val RC_AUDIO = 1001 } } + +// ── Logbook entries adapter ──────────────────────────────────────────────────── + +private val TIME_FMT: DateTimeFormatter = + DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + +private class LogbookEntryAdapter : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitList(list: List) { + items = list + notifyDataSetChanged() + } + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val ivPhoto = view.findViewById(R.id.iv_logbook_photo) + val tvTime = view.findViewById(R.id.tv_logbook_timestamp) + val tvText = view.findViewById(R.id.tv_logbook_text) + val tvLocation = view.findViewById(R.id.tv_logbook_location) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_logbook_entry, parent, false)) + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val e = items[position] + holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs)) + holder.tvText.text = e.text + + if (e.lat != null && e.lon != null) { + holder.tvLocation.text = "%.4f°, %.4f°".format(e.lat, e.lon) + holder.tvLocation.visibility = View.VISIBLE + } else { + holder.tvLocation.visibility = View.GONE + } + + val photoPath = e.photoPath + if (photoPath != null && File(photoPath).exists()) { + val bm = BitmapFactory.decodeFile(photoPath) + if (bm != null) { + holder.ivPhoto.setImageBitmap(bm) + holder.ivPhoto.visibility = View.VISIBLE + } else { + holder.ivPhoto.visibility = View.GONE + } + } else { + holder.ivPhoto.setImageDrawable(null) + holder.ivPhoto.visibility = View.GONE + } + } +} 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 389add1..4a2ff14 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 @@ -160,6 +160,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c8f824e --- /dev/null +++ b/android-app/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + -- cgit v1.2.3