diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 07:13:53 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 07:13:53 +0000 |
| commit | 5c3bcee9a574780476ac4a4888b51b76f186c032 (patch) | |
| tree | d983e88e63dd26a099143a5e71e6f356c7faeec5 /android-app/app/src | |
| parent | d35410c707900075bf4207bab518ead44d43c6e7 (diff) | |
| parent | 8c1d1605f529e32876ba4cb325a019a311c5011d (diff) | |
Merge claude/optimize-nav-location-tracking-Vac5Z into main
- Add notes/photos to finished tracks (TrackDetailSheet)
- Single Nav voice trip report (remove NarrativeStyle, 4-style picker)
- Fix speed coloring on MapLibre 11.x (Expression.step on numeric property)
- Redesign tack detection: time-based windows + circularMAD stability
- Update worklog
https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
Diffstat (limited to 'android-app/app/src')
10 files changed, 444 insertions, 197 deletions
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 b0d256b..763e693 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 @@ -1,50 +1,130 @@ package org.terst.nav.track import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +/** + * Detects tacks and jibes in a recorded GPS track. + * + * A tack or jibe has three phases: + * [── settled leg A (≥30 s) ──][─ guard (15 s) ─][apex][─ guard (15 s) ─][── settled leg B (≥30 s) ──] + * + * For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone), + * require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX, + * accept if heading delta in [MIN_DELTA, MAX_DELTA]. + * + * De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group. + * Refine position: within the maneuver zone, find the point with the greatest instantaneous + * heading rate of change as the map pin. + * + * No SOG gate: circularMAD stability check handles noise at any speed. + * A boat at anchor has effectively random COG (MAD ≈ 90°) and is always rejected. + * A real settled tack leg has MAD typically 5–15° and passes. + * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom). + * Race mode (shorter windows, wider MAD) is a future backlog item. + */ 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 = 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) + private const val T_SETTLE = 30_000L // ms — stable heading window required before/after + private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side) + private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows + private const val MIN_DELTA = 60.0 // ° — minimum heading change to count as tack/jibe + private const val MAX_DELTA = 160.0 // ° — maximum heading change (beyond = U-turn, not a tack) + private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events + private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise) + private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window + + private data class Candidate( + val index: Int, + val timestampMs: Long, + val delta: Double, + val cogBefore: Double, + val cogAfter: Double + ) fun detectTacks(points: List<TrackPoint>): List<TackEvent> { - if (points.size < 2 * HALF_WIN + 1) return emptyList() + if (points.size < MIN_PTS) return emptyList() + val t0 = points.first().timestampMs - val results = mutableListOf<TackEvent>() - var lastTackMs: Long? = null + val raw = mutableListOf<Candidate>() + + for (i in points.indices) { + val t = points[i].timestampMs + if (t - t0 < START_SKIP_MS) continue + + val beforeEnd = t - T_MANEUVER / 2 + val beforeStart = beforeEnd - T_SETTLE + val before = points.filter { it.timestampMs in beforeStart until beforeEnd } + if (before.size < MIN_PTS) continue - for (i in HALF_WIN until points.size - HALF_WIN) { - if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue - if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue + val afterStart = t + T_MANEUVER / 2 + val afterEnd = afterStart + T_SETTLE + val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd } + if (after.size < MIN_PTS) 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)) - if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue + val cogBefore = circularMean(before.map { it.cogDeg }) + val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore) + if (spreadBefore > STAB_MAX) continue + + val cogAfter = circularMean(after.map { it.cogDeg }) + val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter) + if (spreadAfter > STAB_MAX) continue - val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg) - val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg) val delta = abs(angleDiff(cogBefore, cogAfter)) + if (delta < MIN_DELTA || delta > MAX_DELTA) continue + + raw += Candidate(i, t, delta, cogBefore, cogAfter) + } + if (raw.isEmpty()) return emptyList() - if (delta in MIN_DELTA..MAX_DELTA) { - results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) - lastTackMs = points[i].timestampMs + // De-duplicate: if consecutive raw candidates are within MIN_GAP_MS of each other, they + // belong to the same maneuver. Keep the max-delta candidate per group. + // Compare against the PREVIOUS candidate (adjacent comparison) so a long stream of + // close candidates from one maneuver stays in a single group regardless of total span. + val results = mutableListOf<TackEvent>() + var best: Candidate? = null + var prevMs = Long.MIN_VALUE / 2 + + for (c in raw) { + if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) { + results += buildTackEvent(points, best!!) + best = c + } else { + if (best == null || c.delta > best!!.delta) best = c } + prevMs = c.timestampMs } + best?.let { results += buildTackEvent(points, it) } return results } + private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent { + // Refine map pin: find max instantaneous heading rate within maneuver zone + val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2) + var bestIdx = c.index + var bestRate = 0.0 + for (i in 1 until points.size) { + if (points[i].timestampMs !in maneuvRange) continue + val rate = abs(angleDiff(points[i - 1].cogDeg, points[i].cogDeg)) + if (rate > bestRate) { bestRate = rate; bestIdx = i } + } + return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter) + } + internal fun angleDiff(from: Double, to: Double): Double { var diff = to - from - while (diff > 180) diff -= 360 + while (diff > 180) diff -= 360 while (diff < -180) diff += 360 return diff } - private fun circularMean(a: Double, b: Double): Double { - val half = angleDiff(a, b) / 2.0 - return ((a + half) % 360.0 + 360.0) % 360.0 + private fun circularMean(angles: List<Double>): Double { + val sinSum = angles.sumOf { sin(Math.toRadians(it)) } + val cosSum = angles.sumOf { cos(Math.toRadians(it)) } + return ((Math.toDegrees(atan2(sinSum, cosSum)) % 360.0) + 360.0) % 360.0 } + + private fun circularMAD(angles: List<Double>, mean: Double): Double = + angles.map { abs(angleDiff(it, mean)) }.average() } 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<TrackPoint>): List<Feature> = (0 until points.size - 1).map { i -> @@ -27,12 +19,20 @@ fun speedSegments(points: List<TrackPoint>): List<Feature> = )) ).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<View>(R.id.btn_back).setOnClickListener { parentFragmentManager.popBackStack() @@ -88,9 +152,14 @@ class TrackDetailSheet : Fragment() { val rv = view.findViewById<RecyclerView>(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<View>(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<EditText>(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<Button>(R.id.btn_note_camera).setOnClickListener { + val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg") + pendingNotePhotoPath = file.absolutePath + val uri = FileProvider.getUriForFile( + requireContext(), "${requireContext().packageName}.fileprovider", file + ) + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, uri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + noteCameraLauncher.launch(intent) + } + + dialogView.findViewById<Button>(R.id.btn_note_gallery).setOnClickListener { + noteGalleryLauncher.launch("image/*") + } + + dialogView.findViewById<Button>(R.id.btn_note_remove_photo).setOnClickListener { + pendingNotePhotoPath?.let { File(it).delete() } + pendingNotePhotoPath = null + noteDialogPhotoView?.setImageDrawable(null) + noteDialogPhotoFrame?.visibility = View.GONE + } + + AlertDialog.Builder(requireContext()) + .setTitle("Add note to track") + .setView(dialogView) + .setPositiveButton("Save") { _, _ -> + val text = etNote.text?.toString()?.trim() ?: "" + val path = pendingNotePhotoPath + if (text.isBlank() && path == null) return@setPositiveButton + val anchor = track.points.lastOrNull() + NavApplication.logbookRepository.save( + LogEntry( + timestampMs = track.endMs, + text = text.ifBlank { "(photo only)" }, + entryType = EntryType.GENERAL, + lat = anchor?.lat, + lon = anchor?.lon, + photoPath = path + ) + ) + pendingNotePhotoPath = null + logEventAdapter?.update(buildLogEvents(track)) + } + .setNegativeButton("Cancel") { _, _ -> + pendingNotePhotoPath?.let { File(it).delete() } + pendingNotePhotoPath = null + } + .show() + } + private fun drawTrack(style: Style, points: List<TrackPoint>) { if (points.size < 2) return val source = GeoJsonSource("track-detail-source", @@ -171,6 +301,8 @@ class TrackDetailSheet : Fragment() { } override fun onDestroyView() { + noteDialogPhotoFrame = null + noteDialogPhotoView = null if (::trackMapView.isInitialized) trackMapView.onDestroy() super.onDestroyView() viewModel.clearSelectedTrack() @@ -195,7 +327,7 @@ class TrackDetailSheet : Fragment() { "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) } - // Log entries saved during this track's time window + // Log entries saved during or associated with this track's time window val logEntries = NavApplication.logbookRepository.getAll() .filter { it.timestampMs in track.startMs..track.endMs } for (entry in logEntries) { @@ -229,10 +361,15 @@ private val TIME_FMT: DateTimeFormatter = .withZone(ZoneId.systemDefault()) private class LogEventAdapter( - private val events: List<LogEvent>, + private var events: List<LogEvent>, private val onTap: (LogEvent) -> Unit ) : RecyclerView.Adapter<LogEventAdapter.VH>() { + fun update(newEvents: List<LogEvent>) { + events = newEvents + notifyDataSetChanged() + } + inner class VH(view: View) : RecyclerView.ViewHolder(view) { val tvIcon = view.findViewById<TextView>(R.id.tv_log_icon) val tvTime = view.findViewById<TextView>(R.id.tv_log_time) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt index e7a425f..632f616 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt @@ -9,7 +9,6 @@ import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton -import com.google.android.material.chip.ChipGroup import kotlinx.coroutines.launch import org.terst.nav.NavApplication import org.terst.nav.R @@ -18,14 +17,13 @@ class TripReportFragment : Fragment() { private val viewModel by lazy { TripReportViewModel( - trackRepository = NavApplication.trackRepository, + trackRepository = NavApplication.trackRepository, logbookRepository = NavApplication.logbookRepository ) } private lateinit var tvNarrativeContent: TextView private lateinit var btnRefresh: MaterialButton - private lateinit var chipGroup: ChipGroup private lateinit var progress: ProgressBar override fun onCreateView( @@ -38,27 +36,15 @@ class TripReportFragment : Fragment() { super.onViewCreated(view, savedInstanceState) tvNarrativeContent = view.findViewById(R.id.tv_narrative_content) - btnRefresh = view.findViewById(R.id.btn_refresh_report) - chipGroup = view.findViewById(R.id.chip_group_styles) - progress = view.findViewById(R.id.progress_report) + btnRefresh = view.findViewById(R.id.btn_refresh_report) + progress = view.findViewById(R.id.progress_report) btnRefresh.setOnClickListener { viewModel.generateReport() } - chipGroup.setOnCheckedStateChangeListener { _, checkedIds -> - val style = when (checkedIds.firstOrNull()) { - R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS - R.id.chip_journal -> NarrativeStyle.JOURNAL - R.id.chip_pirate -> NarrativeStyle.PIRATE - else -> NarrativeStyle.PROFESSIONAL - } - viewModel.setStyle(style) - } - viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { state -> renderState(state) } } - // Initial generation viewModel.generateReport() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt index bbf00b1..2c7f77f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt @@ -2,13 +2,10 @@ package org.terst.nav.tripreport import org.terst.nav.logbook.LogEntry import org.terst.nav.track.TrackPoint - -enum class NarrativeStyle { - PROFESSIONAL, - ADVENTUROUS, - JOURNAL, - PIRATE -} +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale data class TripSummary( val startTimeMs: Long, @@ -32,12 +29,12 @@ class TripReportGenerator { val startTime = points.first().timestampMs val endTime = points.last().timestampMs - + var totalDist = 0.0 for (i in 0 until points.size - 1) { totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon) } - val distanceNm = totalDist / 1852.0 // meters to nautical miles + val distanceNm = totalDist / 1852.0 val maxSog = points.maxOf { it.sogKnots } val avgSog = points.map { it.sogKnots }.average() @@ -48,21 +45,21 @@ class TripReportGenerator { val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull() return TripSummary( - startTimeMs = startTime, - endTimeMs = endTime, - distanceNm = distanceNm, - maxSogKts = maxSog, - avgSogKts = avgSog, - minAirTempC = minTemp, - maxAirTempC = maxTemp, - maxWaveHeightM = maxWave, - logEntries = logEntries.filter { it.timestampMs in startTime..endTime }, - points = points + startTimeMs = startTime, + endTimeMs = endTime, + distanceNm = distanceNm, + maxSogKts = maxSog, + avgSogKts = avgSog, + minAirTempC = minTemp, + maxAirTempC = maxTemp, + maxWaveHeightM = maxWave, + logEntries = logEntries.filter { it.timestampMs in startTime..endTime }, + points = points ) } private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { - val r = 6371e3 // Earth radius in meters + val r = 6371e3 val phi1 = Math.toRadians(lat1) val phi2 = Math.toRadians(lat2) val deltaPhi = Math.toRadians(lat2 - lat1) @@ -76,42 +73,51 @@ class TripReportGenerator { return r * c } - fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String { - val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0 - val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " + - "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) + - (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") + - "Events: ${summary.logEntries.joinToString { it.text }}" - - return when (style) { - NarrativeStyle.PROFESSIONAL -> { - "VOYAGE SUMMARY\n" + - "Duration: %.1f hours\n".format(durationHrs) + - "Total Distance: %.1f NM\n".format(summary.distanceNm) + - "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) + - "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" + - "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" } - } - NarrativeStyle.ADVENTUROUS -> { - "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) + - "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) + - (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" + - "During our journey, we logged some great moments:\n" + - summary.logEntries.joinToString("\n") { "🔥 ${it.text}" } - } - NarrativeStyle.JOURNAL -> { - "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) + - "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) + - "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " + - "It was a meaningful passage." + fun generateNarrative(summary: TripSummary): String { + val zone = ZoneId.systemDefault() + val dateFmt = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).withZone(zone) + val timeFmt = DateTimeFormatter.ofPattern("HHmm", Locale.getDefault()).withZone(zone) + val startInst = Instant.ofEpochMilli(summary.startTimeMs) + val endInst = Instant.ofEpochMilli(summary.endTimeMs) + + val durationMs = summary.endTimeMs - summary.startTimeMs + val durationStr = if (durationMs >= 3_600_000) { + val h = durationMs / 3_600_000 + val m = (durationMs % 3_600_000) / 60_000 + "${h}h ${m}m" + } else { + "${durationMs / 60_000}m" + } + + val sb = StringBuilder() + sb.appendLine("PASSAGE · ${dateFmt.format(startInst).uppercase(Locale.getDefault())}") + sb.appendLine("${timeFmt.format(startInst)}–${timeFmt.format(endInst)} · $durationStr") + sb.appendLine() + sb.appendLine("%.1f nm avg %.1f kt max %.1f kt".format( + summary.distanceNm, summary.avgSogKts, summary.maxSogKts)) + + val conditions = buildList<String> { + summary.maxWaveHeightM?.let { add("seas %.1f m".format(it)) } + val minT = summary.minAirTempC + val maxT = summary.maxAirTempC + if (minT != null && maxT != null) { + add(if (minT == maxT) "%.0f°C".format(minT) else "%.0f–%.0f°C".format(minT, maxT)) } - NarrativeStyle.PIRATE -> { - "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) + - "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) + - "Listen to the tales from the log:\n" + - summary.logEntries.joinToString("\n") { "🏴☠️ ${it.text}" } + "\n" + - "Arr, it was a fine voyage indeed!" + } + if (conditions.isNotEmpty()) { + sb.appendLine() + sb.appendLine(conditions.joinToString(" ")) + } + + if (summary.logEntries.isNotEmpty()) { + sb.appendLine() + for (entry in summary.logEntries.sortedBy { it.timestampMs }) { + val time = timeFmt.format(Instant.ofEpochMilli(entry.timestampMs)) + val photoMarker = if (entry.photoPath != null) " [photo]" else "" + sb.appendLine("$time ${entry.text}$photoMarker") } } + + return sb.toString().trimEnd() } } 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 45cbed5..603d769 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 @@ -25,14 +25,6 @@ class TripReportViewModel( private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle) val state: StateFlow<TripReportState> = _state.asStateFlow() - private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL) - val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow() - - fun setStyle(style: NarrativeStyle) { - _selectedStyle.value = style - generateReport() - } - fun generateReport() { viewModelScope.launch { _state.value = TripReportState.Loading @@ -43,8 +35,8 @@ class TripReportViewModel( return@launch } val logEntries = logbookRepository.getAll() - val summary = generator.generateSummary(points, logEntries) - val narrative = generator.generateNarrative(summary, _selectedStyle.value) + val summary = generator.generateSummary(points, logEntries) + val narrative = generator.generateNarrative(summary) _state.value = TripReportState.Success(summary, narrative) } catch (e: Exception) { _state.value = TripReportState.Error(e.message ?: "Unknown error generating report") diff --git a/android-app/app/src/main/res/layout/dialog_add_track_note.xml b/android-app/app/src/main/res/layout/dialog_add_track_note.xml new file mode 100644 index 0000000..639a27a --- /dev/null +++ b/android-app/app/src/main/res/layout/dialog_add_track_note.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="16dp"> + + <EditText + android:id="@+id/et_note_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="Note" + android:minLines="3" + android:gravity="top|start" + android:inputType="textMultiLine" + android:padding="8dp" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginTop="12dp"> + + <Button + android:id="@+id/btn_note_camera" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Camera" + android:layout_marginEnd="4dp" /> + + <Button + android:id="@+id/btn_note_gallery" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Gallery" + android:layout_marginStart="4dp" /> + + </LinearLayout> + + <FrameLayout + android:id="@+id/frame_note_photo" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:visibility="gone"> + + <ImageView + android:id="@+id/iv_note_photo" + android:layout_width="match_parent" + android:layout_height="120dp" + android:scaleType="centerCrop" + android:contentDescription="Selected photo" /> + + <Button + android:id="@+id/btn_note_remove_photo" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top|end" + android:layout_margin="4dp" + android:text="✕" + android:textSize="10sp" + android:paddingHorizontal="6dp" + android:paddingVertical="2dp" /> + + </FrameLayout> + +</LinearLayout> diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml index 1ce0bde..7228fa9 100644 --- a/android-app/app/src/main/res/layout/fragment_trip_report.xml +++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml @@ -14,61 +14,10 @@ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Trip Narrative" + android:text="Trip Report" android:textSize="24sp" android:textStyle="bold" - android:layout_marginBottom="16dp" /> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Choose Narrative Style:" - android:textSize="14sp" - android:layout_marginBottom="8dp" /> - - <HorizontalScrollView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="24dp"> - - <com.google.android.material.chip.ChipGroup - android:id="@+id/chip_group_styles" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:singleSelection="true" - app:selectionRequired="true"> - - <com.google.android.material.chip.Chip - android:id="@+id/chip_professional" - style="@style/Widget.Material3.Chip.Filter" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Professional" - android:checked="true" /> - - <com.google.android.material.chip.Chip - android:id="@+id/chip_adventurous" - style="@style/Widget.Material3.Chip.Filter" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Adventurous" /> - - <com.google.android.material.chip.Chip - android:id="@+id/chip_journal" - style="@style/Widget.Material3.Chip.Filter" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Journal" /> - - <com.google.android.material.chip.Chip - android:id="@+id/chip_pirate" - style="@style/Widget.Material3.Chip.Filter" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Pirate" /> - - </com.google.android.material.chip.ChipGroup> - </HorizontalScrollView> + android:layout_marginBottom="24dp" /> <com.google.android.material.card.MaterialCardView android:layout_width="match_parent" @@ -88,8 +37,9 @@ android:id="@+id/tv_narrative_content" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Generate a report to see your story..." - android:textSize="16sp" + android:text="Generate a report to see your passage summary…" + android:textSize="15sp" + android:fontFamily="monospace" android:lineSpacingExtra="4dp" /> </LinearLayout> @@ -100,7 +50,7 @@ android:layout_width="match_parent" android:layout_height="60dp" android:layout_marginTop="24dp" - android:text="REFRESH REPORT" /> + android:text="GENERATE REPORT" /> <ProgressBar android:id="@+id/progress_report" 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 993edf8..4a77a36 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 @@ -97,17 +97,36 @@ <View android:layout_width="match_parent" android:layout_height="1dp" android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" /> - <TextView + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingHorizontal="20dp" + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingStart="20dp" + android:paddingEnd="8dp" android:paddingTop="8dp" - android:paddingBottom="4dp" - android:text="Event Log" - android:textSize="13sp" - android:textAllCaps="true" - android:letterSpacing="0.08" - android:textColor="?attr/colorOnSurfaceVariant" /> + android:paddingBottom="4dp"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Event Log" + android:textSize="13sp" + android:textAllCaps="true" + android:letterSpacing="0.08" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <Button + android:id="@+id/btn_add_note" + style="?attr/borderlessButtonStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="+ Add note" + android:textSize="12sp" + android:paddingHorizontal="8dp" /> + + </LinearLayout> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_log_entries" diff --git a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt index eea3234..8209d1a 100644 --- a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt +++ b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/TripReportGeneratorTest.kt @@ -14,8 +14,8 @@ class TripReportGeneratorTest { private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long) = TrackPoint(lat = lat, lon = lon, sogKnots = sog, cogDeg = 0.0, timestampMs = ts) - private fun entry(ts: Long, text: String = "note") = LogEntry( - timestampMs = ts, text = text, entryType = EntryType.GENERAL + private fun entry(ts: Long, text: String = "note", photoPath: String? = null) = LogEntry( + timestampMs = ts, text = text, entryType = EntryType.GENERAL, photoPath = photoPath ) // ── generateSummary ─────────────────────────────────────────────────────── @@ -30,7 +30,6 @@ class TripReportGeneratorTest { @Test fun `distance between two points one nm apart`() { - // 1 nm north along prime meridian ≈ 0.01667° latitude val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 60_000L)) val s = gen.generateSummary(points, emptyList()) assertEquals(1.0, s.distanceNm, 0.02) @@ -70,20 +69,29 @@ class TripReportGeneratorTest { // ── generateNarrative ───────────────────────────────────────────────────── @Test - fun `professional narrative contains distance and speed`() { + fun `narrative contains passage header and distance`() { val points = listOf(pt(0.0, 0.0, sog = 5.0, ts = 0L), pt(0.016667, 0.0, sog = 5.0, ts = 3_600_000L)) val s = gen.generateSummary(points, emptyList()) - val narrative = gen.generateNarrative(s, NarrativeStyle.PROFESSIONAL) - assertTrue("Expected distance in narrative", narrative.contains("NM") || narrative.contains("nm")) + val narrative = gen.generateNarrative(s) + assertTrue("Expected PASSAGE header", narrative.contains("PASSAGE")) + assertTrue("Expected distance in nm", narrative.contains("nm")) + assertTrue("Expected avg speed", narrative.contains("avg")) + assertTrue("Expected max speed", narrative.contains("max")) } @Test - fun `all narrative styles produce non-empty output`() { + fun `narrative includes log entry text`() { val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 3_600_000L)) - val s = gen.generateSummary(points, listOf(entry(1_000L))) - for (style in NarrativeStyle.values()) { - val narrative = gen.generateNarrative(s, style) - assertTrue("Narrative for $style should not be blank", narrative.isNotBlank()) - } + val s = gen.generateSummary(points, listOf(entry(1_000L, "hoisted main"))) + val narrative = gen.generateNarrative(s) + assertTrue("Expected log entry in narrative", narrative.contains("hoisted main")) + } + + @Test + fun `narrative marks photo entries`() { + val points = listOf(pt(0.0, 0.0, ts = 0L), pt(0.016667, 0.0, ts = 3_600_000L)) + val s = gen.generateSummary(points, listOf(entry(1_000L, "crew photo", photoPath = "/some/path.jpg"))) + val narrative = gen.generateNarrative(s) + assertTrue("Expected [photo] marker", narrative.contains("[photo]")) } } |
