diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 15:38:31 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 15:38:31 +0000 |
| commit | 59d31d8d6198d5a8c2c4ba17cf9ad1b42a7e2018 (patch) | |
| tree | 5825ecbcb8832b051f89ff97afd466d2c3950fba /android-app/app/src/main/kotlin/org | |
| parent | f9b8801eb52c48986eb0123e8758f7ab78736dec (diff) | |
feat(tracks): show summary sheet on track stop; 2-min minimum
TrackSummarySheet: bottom sheet showing distance (nm), duration,
max/avg speed, avg wind and waves (when available, waves in ft).
Only shown for tracks ≥ 2 minutes — shorter tracks are discarded silently.
MainViewModel: exposes trackSummary SharedFlow (replay=0) and trackStartMs.
MainActivity: observes flow, shows sheet after stopTrack completes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
4 files changed, 126 insertions, 6 deletions
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 022b748..d64ce8d 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 @@ -109,6 +109,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track" } } + lifecycleScope.launch { + viewModel.trackSummary.collect { summary -> + org.terst.nav.track.TrackSummarySheet + .from(summary, viewModel.trackStartMs) + .show(supportFragmentManager, "track_summary") + } + } } private fun setupBottomSheet() { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index ed32497..228a484 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -12,7 +12,10 @@ class TrackRepository(context: Context) { private set private val activePoints = mutableListOf<TrackPoint>() - private var trackStartMs = 0L + + /** Epoch-ms when the current (or last) track started. Exposed for the summary sheet title. */ + var trackStartMs: Long = 0L + private set // Loaded lazily from Documents/Nav/ on first access private val _pastTracks = mutableListOf<List<TrackPoint>>() @@ -26,18 +29,22 @@ class TrackRepository(context: Context) { /** * Stops the active track, computes a [TrackSummary], persists the track - * to shared storage, and returns the summary. Returns null if no points - * were recorded. + * to shared storage, and returns the summary. Returns null if the track + * was too short (< 2 minutes) or had no points. */ + suspend fun stopTrack(): TrackSummary? = withContext(Dispatchers.IO) { if (!isRecording) return@withContext null isRecording = false val points = activePoints.toList() activePoints.clear() - if (points.isEmpty()) return@withContext null + if (points.size < 2) return@withContext null val summary = summarise(points) - _pastTracks.add(0, points) // prepend so most recent is first + // Discard tracks shorter than 2 minutes — likely accidental taps + if (summary.durationMs < 2 * 60_000L) return@withContext null + + _pastTracks.add(0, points) storage.saveTrack(points, trackStartMs) summary } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt new file mode 100644 index 0000000..8d9d7c7 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt @@ -0,0 +1,95 @@ +package org.terst.nav.track + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.terst.nav.R +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class TrackSummarySheet : BottomSheetDialogFragment() { + + companion object { + private const val ARG_DISTANCE = "distance_nm" + private const val ARG_DURATION = "duration_ms" + private const val ARG_MAX_SOG = "max_sog" + private const val ARG_AVG_SOG = "avg_sog" + private const val ARG_AVG_WIND = "avg_wind" // -1 if absent + private const val ARG_AVG_WAVE = "avg_wave_m" // -1 if absent + private const val ARG_START_MS = "start_ms" + + fun from(summary: TrackSummary, startMs: Long) = TrackSummarySheet().apply { + arguments = Bundle().apply { + putDouble(ARG_DISTANCE, summary.distanceNm) + putLong(ARG_DURATION, summary.durationMs) + putDouble(ARG_MAX_SOG, summary.maxSogKt) + putDouble(ARG_AVG_SOG, summary.avgSogKt) + putDouble(ARG_AVG_WIND, summary.avgWindKt ?: -1.0) + putDouble(ARG_AVG_WAVE, summary.avgWaveHeightM ?: -1.0) + putLong(ARG_START_MS, startMs) + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.layout_track_summary_sheet, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val args = requireArguments() + val distanceNm = args.getDouble(ARG_DISTANCE) + val durationMs = args.getLong(ARG_DURATION) + val maxSog = args.getDouble(ARG_MAX_SOG) + val avgSog = args.getDouble(ARG_AVG_SOG) + val avgWind = args.getDouble(ARG_AVG_WIND).takeIf { it >= 0 } + val avgWaveM = args.getDouble(ARG_AVG_WAVE).takeIf { it >= 0 } + val startMs = args.getLong(ARG_START_MS) + + val titleFmt = DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + view.findViewById<TextView>(R.id.summary_title).text = + "Track · ${titleFmt.format(Instant.ofEpochMilli(startMs))}" + + view.findViewById<TextView>(R.id.summary_distance).text = + "%.1f".format(Locale.getDefault(), distanceNm) + + view.findViewById<TextView>(R.id.summary_duration).text = formatDuration(durationMs) + + view.findViewById<TextView>(R.id.summary_max_sog).text = + "%.1f".format(Locale.getDefault(), maxSog) + + view.findViewById<TextView>(R.id.summary_avg_sog).text = + "%.1f".format(Locale.getDefault(), avgSog) + + val conditionsRow = view.findViewById<LinearLayout>(R.id.summary_conditions_row) + val windCell = view.findViewById<LinearLayout>(R.id.summary_wind_cell) + val waveCell = view.findViewById<LinearLayout>(R.id.summary_wave_cell) + + if (avgWind != null) { + windCell.visibility = View.VISIBLE + view.findViewById<TextView>(R.id.summary_avg_wind).text = + "%.0f".format(Locale.getDefault(), avgWind) + } + if (avgWaveM != null) { + waveCell.visibility = View.VISIBLE + val waveFt = avgWaveM * 3.28084 + view.findViewById<TextView>(R.id.summary_avg_wave).text = + "%.1f".format(Locale.getDefault(), waveFt) + } + if (avgWind != null || avgWaveM != null) { + conditionsRow.visibility = View.VISIBLE + } + } + + private fun formatDuration(ms: Long): String { + val totalMinutes = ms / 60_000 + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" + } +} 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 c1707ab..5797138 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 @@ -15,10 +15,14 @@ import org.terst.nav.data.repository.WeatherRepository import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.terst.nav.track.TrackSummary import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory @@ -60,18 +64,25 @@ class MainViewModel( private val _pastTracks = MutableStateFlow<List<List<TrackPoint>>>(emptyList()) val pastTracks: StateFlow<List<List<TrackPoint>>> = _pastTracks.asStateFlow() + private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0) + val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow() + fun startTrack() { trackRepository.startTrack() _trackPoints.value = emptyList() _isRecording.value = true } + /** Epoch-ms when the current track started — for the summary sheet title. */ + val trackStartMs: Long get() = trackRepository.trackStartMs + fun stopTrack() { viewModelScope.launch { - trackRepository.stopTrack() + val summary = trackRepository.stopTrack() _pastTracks.value = trackRepository.getPastTracks() _trackPoints.value = emptyList() _isRecording.value = false + summary?.let { _trackSummary.emit(it) } } } |
