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') 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