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 --- .../src/main/res/layout/fragment_saved_tracks.xml | 29 +++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) (limited to 'android-app/app/src/main/res') 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: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(-) (limited to 'android-app/app/src/main/res') 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(-) (limited to 'android-app/app/src/main/res') 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(-) (limited to 'android-app/app/src/main/res') 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 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(-) (limited to 'android-app/app/src/main/res') 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 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 --- .../org/terst/nav/track/SavedTracksFragment.kt | 46 ++++-- .../kotlin/org/terst/nav/track/TackDetector.kt | 18 ++- .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 85 +++++++++- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 127 ++++++++++----- .../main/res/layout/layout_track_detail_sheet.xml | 179 ++++++++++++--------- 5 files changed, 321 insertions(+), 134 deletions(-) (limited to 'android-app/app/src/main/res') 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..b4a0a91 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,17 +34,19 @@ 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)) val adapter = SavedTrackAdapter { track -> viewModel.selectTrack(track) - parentFragmentManager - .beginTransaction() + parentFragmentManager.beginTransaction() .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) - .addToBackStack(null) + .addToBackStack("track_detail") .commit() } rv.adapter = adapter @@ -50,18 +54,42 @@ 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 + } + } + + 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() } } } } -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/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt index 6616acf..9668fd1 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,22 @@ 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 = 30.0 + private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks + private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise) 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 - points.first().timestampMs < START_SKIP_MS) 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 +30,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 c1764a8..158e513 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 @@ -10,6 +10,23 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +<<<<<<< Updated upstream +======= +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString +import org.maplibre.geojson.Point +>>>>>>> Stashed changes import org.terst.nav.R import org.terst.nav.ui.MainViewModel import java.time.Instant @@ -31,6 +48,8 @@ data class LogEvent( class TrackDetailSheet : Fragment() { private val viewModel: MainViewModel by activityViewModels() + private lateinit var trackMapView: MapView + private var trackMap: MapLibreMap? = null companion object { fun newInstance() = TrackDetailSheet() @@ -68,11 +87,75 @@ class TrackDetailSheet : Fragment() { rv.layoutManager = LinearLayoutManager(requireContext()) rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) rv.adapter = LogEventAdapter(events) { event -> - viewModel.panToTrackPoint(event.lat, event.lon) + trackMap?.easeCamera(CameraUpdateFactory.newLatLng(LatLng(event.lat, event.lon)), 300) } +<<<<<<< Updated upstream +======= + + trackMapView = view.findViewById(R.id.track_map_view) + trackMapView.onCreate(savedInstanceState) + trackMapView.getMapAsync { map -> + trackMap = map + map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/bright")) { style -> + drawTrack(style, track.points) + drawTacks(style, track.tacks) + zoomToTrack(map, track.points) + } + } + } + + private fun drawTrack(style: Style, points: List) { + if (points.size < 2) return + val source = GeoJsonSource("track-detail-source", + Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) }))) + style.addSource(source) + style.addLayer(LineLayer("track-detail-layer", "track-detail-source").apply { + setProperties( + PropertyFactory.lineColor("#2B8FC4"), + PropertyFactory.lineWidth(4f), + PropertyFactory.lineCap("round"), + PropertyFactory.lineJoin("round") + ) + }) + } + + private fun drawTacks(style: Style, tacks: List) { + if (tacks.isEmpty()) return + val features = tacks.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) } + val source = GeoJsonSource("tack-detail-source", FeatureCollection.fromFeatures(features)) + style.addSource(source) + style.addLayer(CircleLayer("tack-detail-layer", "tack-detail-source").apply { + setProperties( + PropertyFactory.circleColor("#FDD835"), + PropertyFactory.circleRadius(7f), + PropertyFactory.circleStrokeColor("#F57F17"), + PropertyFactory.circleStrokeWidth(2f) + ) + }) + } + + private fun zoomToTrack(map: MapLibreMap, points: List) { + if (points.size < 2) return + val bounds = LatLngBounds.Builder().apply { + points.forEach { include(LatLng(it.lat, it.lon)) } + }.build() + map.easeCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80), 600) + } + + override fun onStart() { super.onStart(); if (::trackMapView.isInitialized) trackMapView.onStart() } + override fun onResume() { super.onResume(); if (::trackMapView.isInitialized) trackMapView.onResume() } + override fun onPause() { super.onPause(); if (::trackMapView.isInitialized) trackMapView.onPause() } + override fun onStop() { super.onStop(); if (::trackMapView.isInitialized) trackMapView.onStop() } + override fun onLowMemory() { super.onLowMemory(); if (::trackMapView.isInitialized) trackMapView.onLowMemory() } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (::trackMapView.isInitialized) trackMapView.onSaveInstanceState(outState) +>>>>>>> Stashed changes } override fun onDestroyView() { + if (::trackMapView.isInitialized) trackMapView.onDestroy() super.onDestroyView() viewModel.clearSelectedTrack() } 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..116a90c 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,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? -> @@ -69,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?, @@ -90,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() @@ -109,23 +111,77 @@ 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() } + // 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(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) + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) + .addToBackStack("track_detail") + .commit() + } + 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) } } } @@ -139,16 +195,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() {} @@ -191,7 +245,7 @@ class VoiceLogFragment : Fragment() { private fun clearEntry() { etNote.setText("") clearPhoto() - viewModel.retry() + logViewModel.retry() tvSavedConfirmation.text = "" tvStatus.text = "" } @@ -209,7 +263,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/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') 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') 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 From 70c1698ebc9e9661724e5326ae1c93f2b74c2b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 02:06:46 +0000 Subject: Fix LocationService current TODO; show log entries in track detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocationService: - Add _currentSpeedKt/_currentDirectionDeg to companion state - Add setCurrentConditions() companion function - snapshot() now returns real current data instead of null - MainActivity.applyConditions() calls setCurrentConditions() on each marine conditions update TrackDetailSheet: - buildLogEvents() filters NavApplication.logbookRepository entries by track time window (startMs..endMs); entries without GPS snap to nearest track point by timestamp - Note/photo events added to the event log list with 📝/📷 icons - drawNotes() adds a green CircleLayer for note/photo markers on the map - LogEvent extended with photoPath field - LogEventAdapter shows a downsampled photo thumbnail when present item_log_entry.xml: add iv_log_thumb ImageView (hidden by default) https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../main/kotlin/org/terst/nav/LocationService.kt | 12 ++++- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 1 + .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 62 +++++++++++++++++++++- .../app/src/main/res/layout/item_log_entry.xml | 12 +++++ 4 files changed, 84 insertions(+), 3 deletions(-) (limited to 'android-app/app/src/main/res') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index 015e334..9440d49 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -376,8 +376,8 @@ class LocationService : Service() { return EnvironmentalSnapshot( windSpeedKt = trueWind?.speedKt, windDirectionDeg = trueWind?.directionDeg, - currentSpeedKt = null, // TODO: Pull from latest forecast - currentDirectionDeg = null + currentSpeedKt = _currentSpeedKt.value, + currentDirectionDeg = _currentDirectionDeg.value ) } @@ -431,6 +431,14 @@ class LocationService : Service() { private val _nmeaBoatSpeedDataFlow = MutableSharedFlow(replay = 1) val nmeaBoatSpeedData: SharedFlow get() = _nmeaBoatSpeedDataFlow + private val _currentSpeedKt = MutableStateFlow(null) + private val _currentDirectionDeg = MutableStateFlow(null) + + fun setCurrentConditions(speedKt: Double?, directionDeg: Double?) { + _currentSpeedKt.value = speedKt + _currentDirectionDeg.value = directionDeg + } + private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) val currentPowerMode: StateFlow get() = _currentPowerMode 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 ddb9c56..e4bc9b5 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 @@ -333,6 +333,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun applyConditions(c: MarineConditions) { + LocationService.setCurrentConditions(c.currentSpeedKt, c.currentDirDeg) instrumentHandler?.updateConditions( twsKt = c.windSpeedKt, twsBearingDeg = c.windDirDeg?.toFloat(), 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 cedf4e1..25e7b15 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 @@ -1,9 +1,11 @@ package org.terst.nav.track +import android.graphics.BitmapFactory import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -24,8 +26,10 @@ import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point +import org.terst.nav.NavApplication import org.terst.nav.R import org.terst.nav.ui.MainViewModel +import java.io.File import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -39,7 +43,8 @@ data class LogEvent( val timestampMs: Long, val icon: String, val label: String, - val detail: String + val detail: String, + val photoPath: String? = null ) class TrackDetailSheet : Fragment() { @@ -94,6 +99,8 @@ class TrackDetailSheet : Fragment() { map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/bright")) { style -> drawTrack(style, track.points) drawTacks(style, track.tacks) + val notePoints = events.filter { it.photoPath != null || it.icon == "📝" } + drawNotes(style, notePoints) zoomToTrack(map, track.points) } } @@ -129,6 +136,21 @@ class TrackDetailSheet : Fragment() { }) } + private fun drawNotes(style: Style, notes: List) { + if (notes.isEmpty()) return + val features = notes.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) } + val source = GeoJsonSource("notes-detail-source", FeatureCollection.fromFeatures(features)) + style.addSource(source) + style.addLayer(CircleLayer("notes-detail-layer", "notes-detail-source").apply { + setProperties( + PropertyFactory.circleColor("#4CAF50"), + PropertyFactory.circleRadius(7f), + PropertyFactory.circleStrokeColor("#1B5E20"), + PropertyFactory.circleStrokeWidth(2f) + ) + }) + } + private fun zoomToTrack(map: MapLibreMap, points: List) { if (points.size < 2) return val bounds = LatLngBounds.Builder().apply { @@ -173,6 +195,17 @@ class TrackDetailSheet : Fragment() { "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) } + // Log entries saved during this track's time window + val logEntries = NavApplication.logbookRepository.getAll() + .filter { it.timestampMs in track.startMs..track.endMs } + for (entry in logEntries) { + val lat = entry.lat ?: nearestTrackPoint(points, entry.timestampMs)?.lat ?: continue + val lon = entry.lon ?: nearestTrackPoint(points, entry.timestampMs)?.lon ?: continue + val icon = if (entry.photoPath != null) "📷" else "📝" + val detail = entry.text.take(80) + events += LogEvent(lat, lon, entry.timestampMs, icon, "Note", detail, entry.photoPath) + } + points.lastOrNull()?.let { p -> events += LogEvent(p.lat, p.lon, p.timestampMs, "🏁", "End", formatPointDetail(p)) } @@ -180,6 +213,9 @@ class TrackDetailSheet : Fragment() { return events.sortedBy { it.timestampMs } } + private fun nearestTrackPoint(points: List, timestampMs: Long): TrackPoint? = + points.minByOrNull { abs(it.timestampMs - timestampMs) } + private fun formatPointDetail(p: TrackPoint): String { val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg)) p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) } @@ -202,6 +238,7 @@ private class LogEventAdapter( val tvTime = view.findViewById(R.id.tv_log_time) val tvLabel = view.findViewById(R.id.tv_log_label) val tvDetail = view.findViewById(R.id.tv_log_detail) + val ivThumb = view.findViewById(R.id.iv_log_thumb) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = @@ -216,5 +253,28 @@ private class LogEventAdapter( holder.tvLabel.text = e.label holder.tvDetail.text = e.detail holder.itemView.setOnClickListener { onTap(e) } + + val path = e.photoPath + if (path != null && File(path).exists()) { + val bm = decodeThumbnail(path, 160, 120) + if (bm != null) { + holder.ivThumb.setImageBitmap(bm) + holder.ivThumb.visibility = View.VISIBLE + } else { + holder.ivThumb.setImageDrawable(null) + holder.ivThumb.visibility = View.GONE + } + } else { + holder.ivThumb.setImageDrawable(null) + holder.ivThumb.visibility = View.GONE + } } } + +private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, opts) + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = scale }) +} diff --git a/android-app/app/src/main/res/layout/item_log_entry.xml b/android-app/app/src/main/res/layout/item_log_entry.xml index fc53ef1..e23c7c2 100644 --- a/android-app/app/src/main/res/layout/item_log_entry.xml +++ b/android-app/app/src/main/res/layout/item_log_entry.xml @@ -49,6 +49,18 @@ app:layout_constraintTop_toBottomOf="@id/tv_log_label" app:layout_constraintStart_toEndOf="@id/tv_log_icon" app:layout_constraintEnd_toEndOf="parent" + android:layout_marginStart="8dp" /> + + -- cgit v1.2.3 From e34df09b96f4ded3c25c5a14a0332a874205b64a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:13:48 +0000 Subject: Add notes to finished tracks, single Nav voice, fix speed coloring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrackDetailSheet: + Add note button opens a dialog with text, camera, and gallery. Entry is saved with timestampMs=track.endMs so it lands inside the track's time window and appears automatically in the event log. logEventAdapter.update() refreshes the list after save. - TripReportGenerator: removed NarrativeStyle enum and 4-style branch. generateNarrative() now emits a concise PASSAGE header (date, time range, duration), stats line (nm / avg kt / max kt), optional conditions (seas, temp), and a timestamped deck log. Fragment and layout updated to remove the ChipGroup style picker. - TrackColors: replaced Expression.get("color") — which silently fails for lineColor on LineLayer in MapLibre Android 11.x — with Expression.step(Expression.get("speed"), ...) that maps the numeric "speed" property directly to color literals. "color" string property removed from speedSegments() features. https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .agent/worklog.md | 14 +- .../main/kotlin/org/terst/nav/track/TrackColors.kt | 26 ++-- .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 143 ++++++++++++++++++++- .../org/terst/nav/tripreport/TripReportFragment.kt | 20 +-- .../terst/nav/tripreport/TripReportGenerator.kt | 114 ++++++++-------- .../terst/nav/tripreport/TripReportViewModel.kt | 12 +- .../src/main/res/layout/dialog_add_track_note.xml | 69 ++++++++++ .../src/main/res/layout/fragment_trip_report.xml | 62 +-------- .../main/res/layout/layout_track_detail_sheet.xml | 35 +++-- .../nav/tripreport/TripReportGeneratorTest.kt | 32 +++-- 10 files changed, 349 insertions(+), 178 deletions(-) create mode 100644 android-app/app/src/main/res/layout/dialog_add_track_note.xml (limited to 'android-app/app/src/main/res') diff --git a/.agent/worklog.md b/.agent/worklog.md index cfae053..a31dbd0 100644 --- a/.agent/worklog.md +++ b/.agent/worklog.md @@ -2,15 +2,19 @@ ## Backlog (fix later) -- **Add notes/photos to a finished track** — UI to attach logbook entries to a past track by timestamp/geo proximity. Must flow into the trip report (currently only live-session entries are scoped). +- **Tack detection is buggy, especially at low speed** — False positives at low SOG (GPS heading noise looks like tacks). Need minimum-SOG gate (e.g. skip detection when SOG < 1.5 kt) and review the stability window / MIN_DELTA thresholds against real tracks. -- **Trip report voice** — The current narrative styles (Professional / Adventurous / Journal / Pirate) are generic and not right for Nav. Nav is an opinionated sailing app; the report should have a single voice that reflects that — concise, knowledgeable, seamanlike. Rip out the style picker and write one good template. +## Current Task Goal +Logbook + Trip Report Improvements — COMPLETE (2026-05-26) -- **Tack detection is buggy, especially at low speed** — False positives at low SOG (GPS heading noise looks like tacks). Need minimum-SOG gate (e.g. skip detection when SOG < 1.5 kt) and review the stability window / MIN_DELTA thresholds against real tracks. +Delivered: +- **Add notes/photos to finished track** — `+ Add note` button in `TrackDetailSheet` opens a dialog with text entry, camera, and gallery. Notes are saved with `timestampMs = track.endMs` so they fall within the track window and appear in `buildLogEvents()` automatically. The map is re-built via `logEventAdapter.update()` after save. +- **Trip report single Nav voice** — Removed `NarrativeStyle` enum and the 4-style ChipGroup. `generateNarrative(summary)` now emits a concise `PASSAGE` header with date/time range, stats, conditions, and a timestamped deck log. `fragment_trip_report.xml` no longer has the style picker. +- **Speed coloring fixed** — Replaced the broken `Expression.get("color")` (reads a string feature property, silently fails for `lineColor` on `LineLayer` in MapLibre Android 11.x) with `Expression.step(Expression.get("speed"), ...)` which maps the numeric `"speed"` property directly to color literals. `speedSegments()` no longer writes the unused `"color"` string property. -- **Speed coloring on the map doesn't work** — `Expression.get("color")` on a FeatureCollection of 2-point LineStrings fails silently in MapLibre Android 11.x. The focused-track layer in MapHandler still uses this broken approach. Needs investigation: either find a working data-driven expression syntax for this MapLibre version, or pre-segment the GeoJSON differently. +--- -## Current Task Goal +## Previous Task Goal Instrument Sheet Visual Redesign — IN PROGRESS (2026-04-06) **Plan:** `docs/superpowers/plans/2026-04-04-instrument-sheet-visual-redesign.md` diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt index 292240a..5e5e85d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt @@ -5,17 +5,9 @@ import org.maplibre.geojson.Feature import org.maplibre.geojson.LineString import org.maplibre.geojson.Point -private fun speedToHex(speedKt: Double): String = when { - speedKt >= 13 -> "#F44336" // red - speedKt >= 10 -> "#FFC107" // amber - speedKt >= 7 -> "#4CAF50" // green - speedKt >= 4 -> "#00BCD4" // cyan - else -> "#2196F3" // blue -} - /** * Breaks [points] into consecutive 2-point LineString Features. - * Each feature carries "speed" (knots) and "color" (CSS hex) properties. + * Each feature carries a numeric "speed" property (knots) for data-driven coloring. */ fun speedSegments(points: List): List = (0 until points.size - 1).map { i -> @@ -27,12 +19,20 @@ fun speedSegments(points: List): List = )) ).apply { addNumberProperty("speed", sog) - addStringProperty("color", speedToHex(sog)) } } /** - * Returns the lineColor expression for speed-colored segments. - * Reads the pre-computed "color" string property from each feature. + * Returns a step expression mapping the "speed" numeric property to a color. + * Uses Expression.step() which reads the numeric property directly, avoiding + * the silent failure of Expression.get("color") for string properties on LineLayer + * in MapLibre Android 11.x. */ -fun speedColorExpression(): Expression = Expression.get("color") +fun speedColorExpression(): Expression = Expression.step( + Expression.get("speed"), + Expression.literal("#2196F3"), // < 4 kt: blue + Expression.stop(4, Expression.literal("#00BCD4")), // 4–7 kt: cyan + Expression.stop(7, Expression.literal("#4CAF50")), // 7–10 kt: green + Expression.stop(10, Expression.literal("#FFC107")), // 10–13 kt: amber + Expression.stop(13, Expression.literal("#F44336")) // 13+ kt: red +) 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 25e7b15..1d92e18 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 @@ -1,17 +1,31 @@ package org.terst.nav.track +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent import android.graphics.BitmapFactory +import android.net.Uri import android.os.Bundle +import android.provider.MediaStore import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds @@ -28,8 +42,11 @@ import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.logbook.EntryType +import org.terst.nav.logbook.LogEntry 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 @@ -53,6 +70,52 @@ class TrackDetailSheet : Fragment() { private lateinit var trackMapView: MapView private var trackMap: MapLibreMap? = null + // Note-adding state + private var pendingNotePhotoPath: String? = null + private var noteDialogPhotoFrame: FrameLayout? = null + private var noteDialogPhotoView: ImageView? = null + private var logEventAdapter: LogEventAdapter? = null + private var currentTrack: SavedTrack? = null + + private val noteCameraLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val path = pendingNotePhotoPath ?: return@registerForActivityResult + val bm = BitmapFactory.decodeFile(path) + if (bm != null) noteDialogPhotoView?.setImageBitmap(bm) + else noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path))) + noteDialogPhotoFrame?.visibility = View.VISIBLE + } else { + pendingNotePhotoPath?.let { File(it).delete() } + pendingNotePhotoPath = null + } + } + + private val noteGalleryLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: 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 { inp -> + FileOutputStream(dest).use { out -> inp.copyTo(out) } + } + dest.absolutePath + }.getOrNull() + } + if (path != null) { + pendingNotePhotoPath = path + noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path))) + noteDialogPhotoFrame?.visibility = View.VISIBLE + } + } + } + } + companion object { fun newInstance() = TrackDetailSheet() } @@ -65,6 +128,7 @@ class TrackDetailSheet : Fragment() { parentFragmentManager.popBackStack() return } + currentTrack = track view.findViewById(R.id.btn_back).setOnClickListener { parentFragmentManager.popBackStack() @@ -88,9 +152,14 @@ class TrackDetailSheet : Fragment() { val rv = view.findViewById(R.id.rv_log_entries) rv.layoutManager = LinearLayoutManager(requireContext()) rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) - rv.adapter = LogEventAdapter(events) { event -> + logEventAdapter = LogEventAdapter(events) { event -> trackMap?.easeCamera(CameraUpdateFactory.newLatLng(LatLng(event.lat, event.lon)), 300) } + rv.adapter = logEventAdapter + + view.findViewById(R.id.btn_add_note).setOnClickListener { + showAddNoteDialog(track) + } trackMapView = view.findViewById(R.id.track_map_view) trackMapView.onCreate(savedInstanceState) @@ -106,6 +175,67 @@ class TrackDetailSheet : Fragment() { } } + private fun showAddNoteDialog(track: SavedTrack) { + val dialogView = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_add_track_note, null) + val etNote = dialogView.findViewById(R.id.et_note_text) + noteDialogPhotoFrame = dialogView.findViewById(R.id.frame_note_photo) + noteDialogPhotoView = dialogView.findViewById(R.id.iv_note_photo) + pendingNotePhotoPath = null + noteDialogPhotoFrame?.visibility = View.GONE + + dialogView.findViewById