diff options
Diffstat (limited to 'android-app/app/src/main')
5 files changed, 195 insertions, 113 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt index 44055ce..67f32f6 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt @@ -34,18 +34,20 @@ class SavedTracksFragment : Fragment() { (requireActivity() as MainActivity).hideOverlays() } - val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks) - val layoutEmpty = view.findViewById<View>(R.id.layout_empty) - val tvEmpty = view.findViewById<TextView>(R.id.tv_empty) - val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage) + val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks) + val layoutEmpty = view.findViewById<View>(R.id.layout_empty) + val tvEmpty = view.findViewById<TextView>(R.id.tv_empty) + val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage) rv.layoutManager = LinearLayoutManager(requireContext()) rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) val adapter = SavedTrackAdapter { track -> viewModel.selectTrack(track) - (requireActivity() as MainActivity).hideOverlays() - TrackDetailSheet.newInstance().show(parentFragmentManager, "track_detail") + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) + .addToBackStack("track_detail") + .commit() } rv.adapter = 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 3c3385c..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,11 +3,12 @@ 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 STABILITY_MAX = 20.0 - private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks + 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<TrackPoint>): List<TackEvent> { if (points.size < 2 * HALF_WIN + 1) return emptyList() @@ -16,6 +17,7 @@ object TackDetector { var lastTackMs = Long.MIN_VALUE for (i in HALF_WIN until points.size - HALF_WIN) { + 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)) 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 98205fd..cedf4e1 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,13 +5,25 @@ 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.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 import org.terst.nav.R import org.terst.nav.ui.MainViewModel import java.time.Instant @@ -30,9 +42,11 @@ data class LogEvent( val detail: String ) -class TrackDetailSheet : BottomSheetDialogFragment() { +class TrackDetailSheet : Fragment() { private val viewModel: MainViewModel by activityViewModels() + private lateinit var trackMapView: MapView + private var trackMap: MapLibreMap? = null companion object { fun newInstance() = TrackDetailSheet() @@ -43,11 +57,13 @@ class TrackDetailSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val track = viewModel.selectedTrack.value ?: run { - dismiss() + parentFragmentManager.popBackStack() return } - view.findViewById<View>(R.id.btn_back).setOnClickListener { dismiss() } + view.findViewById<View>(R.id.btn_back).setOnClickListener { + parentFragmentManager.popBackStack() + } val titleFmt = DateTimeFormatter.ofPattern("dd MMM yyyy · HH:mm", Locale.getDefault()) .withZone(ZoneId.systemDefault()) @@ -68,20 +84,72 @@ class TrackDetailSheet : BottomSheetDialogFragment() { 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) } - (dialog as? BottomSheetDialog)?.behavior?.apply { - isFitToContents = false - halfExpandedRatio = 0.5f - skipCollapsed = true - isDraggable = false - state = BottomSheetBehavior.STATE_HALF_EXPANDED + 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) + } } - dialog?.window?.setDimAmount(0.2f) + } + + private fun drawTrack(style: Style, points: List<TrackPoint>) { + 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<TackEvent>) { + 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<TrackPoint>) { + 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) } 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/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 0c31024..7614a49 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -315,7 +315,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) { style.addLayer(LineLayer(TRACK_PAST_DIM_LAYER_ID, TRACK_PAST_DIM_SOURCE_ID).apply { setProperties( PropertyFactory.lineColor("#0B3050"), - PropertyFactory.lineOpacity(0.22f), + PropertyFactory.lineOpacity(0.12f), PropertyFactory.lineWidth(2.5f), PropertyFactory.lineCap("round"), PropertyFactory.lineJoin("round") 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 97b1da8..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,107 +6,117 @@ android:orientation="vertical" android:background="?attr/colorSurface"> - <!-- Drag handle --> - <View - android:layout_width="36dp" - android:layout_height="40dp" - android:layout_gravity="center_horizontal" - android:background="@drawable/drag_handle_bg" /> - - <!-- Header --> + <!-- Track map — top half --> + <org.maplibre.android.maps.MapView + android:id="@+id/track_map_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + + <!-- Detail panel — bottom half --> <LinearLayout android:layout_width="match_parent" - android:layout_height="56dp" - android:orientation="horizontal" - android:gravity="center_vertical" - android:paddingStart="8dp" - android:paddingEnd="16dp" - android:elevation="4dp" + android:layout_height="0dp" + android:layout_weight="1" + android:orientation="vertical" android:background="?attr/colorSurface"> - <ImageButton - android:id="@+id/btn_back" - android:layout_width="48dp" - android:layout_height="48dp" - android:src="@drawable/ic_back" - android:background="?attr/selectableItemBackgroundBorderless" - android:contentDescription="Back" /> + <!-- Header --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="56dp" + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingStart="8dp" + android:paddingEnd="16dp" + android:elevation="4dp" + android:background="?attr/colorSurface"> - <TextView - android:id="@+id/detail_title" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:textSize="17sp" - android:textStyle="bold" - android:layout_marginStart="4dp" /> + <ImageButton + android:id="@+id/btn_back" + android:layout_width="48dp" + android:layout_height="48dp" + android:src="@drawable/ic_back" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="Back" /> - </LinearLayout> + <TextView + android:id="@+id/detail_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:textSize="17sp" + android:textStyle="bold" + android:layout_marginStart="4dp" /> - <!-- Stats row: distance · duration · max · avg --> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:paddingHorizontal="12dp" - android:paddingTop="12dp" - android:paddingBottom="12dp"> - - <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:orientation="vertical" android:gravity="center"> - <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content" - android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> - <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> </LinearLayout> - <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:orientation="vertical" android:gravity="center"> - <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content" - android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> - <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> - </LinearLayout> + <!-- Stats row: distance · duration · max · avg --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingHorizontal="12dp" + android:paddingTop="8dp" + android:paddingBottom="8dp"> - <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:orientation="vertical" android:gravity="center"> - <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content" - android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> - <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> - </LinearLayout> + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> + + <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_weight="1" android:orientation="vertical" android:gravity="center"> + <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content" + android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> + <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> + </LinearLayout> - <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_weight="1" android:orientation="vertical" android:gravity="center"> - <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content" - android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" /> - <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" /> </LinearLayout> - </LinearLayout> + <View android:layout_width="match_parent" android:layout_height="1dp" + android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" /> - <View android:layout_width="match_parent" android:layout_height="1dp" - android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" /> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="20dp" + android:paddingTop="8dp" + android:paddingBottom="4dp" + android:text="Event Log" + android:textSize="13sp" + android:textAllCaps="true" + android:letterSpacing="0.08" + android:textColor="?attr/colorOnSurfaceVariant" /> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="20dp" - android:paddingTop="12dp" - android:paddingBottom="4dp" - android:text="Event Log" - android:textSize="13sp" - android:textAllCaps="true" - android:letterSpacing="0.08" - android:textColor="?attr/colorOnSurfaceVariant" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/rv_log_entries" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" - android:clipToPadding="false" - android:paddingBottom="24dp" /> + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/rv_log_entries" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:clipToPadding="false" + android:paddingBottom="24dp" /> + + </LinearLayout> </LinearLayout> |
