From 994ddaf2ec8d8aace4fef72a214df31e6ef134ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:19:46 +0000 Subject: Trip report: map, photos, structured layout, preferred units Replaces monospace text block with: - Full-bleed MapLibre track map (260dp) at the top - Structured stats row (distance / avg speed / max speed) - Conditions row (wave height, temp) in user-preferred units - Log entries with full-width photo thumbnails - Pirate mode Easter egg preserved via long-press title - Speed, temp, and depth formatted with UnitPrefs https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD --- .../org/terst/nav/tripreport/TripReportFragment.kt | 241 +++++++++++++-- .../src/main/res/layout/fragment_trip_report.xml | 326 +++++++++++---------- 2 files changed, 387 insertions(+), 180 deletions(-) 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 aa86c25..d9d2263 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 @@ -1,9 +1,14 @@ package org.terst.nav.tripreport +import android.graphics.BitmapFactory +import android.graphics.Outline +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView @@ -12,11 +17,28 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView +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 +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +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.LineString +import org.maplibre.geojson.Point import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.UnitPrefs import org.terst.nav.logbook.LogEntry +import org.terst.nav.track.TrackPoint import org.terst.nav.ui.MainViewModel +import java.io.File import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -43,6 +65,9 @@ class TripReportFragment : Fragment() { } } + private lateinit var reportMapView: MapView + private var reportMap: MapLibreMap? = null + private lateinit var tvTitle: TextView private lateinit var layoutStructured: LinearLayout private lateinit var tvPassageDate: TextView @@ -50,6 +75,8 @@ class TripReportFragment : Fragment() { private lateinit var tvStatDistance: TextView private lateinit var tvStatAvg: TextView private lateinit var tvStatMax: TextView + private lateinit var tvLabelAvg: TextView + private lateinit var tvLabelMax: TextView private lateinit var layoutConditions: LinearLayout private lateinit var tvConditions: TextView private lateinit var dividerLog: View @@ -74,23 +101,28 @@ class TripReportFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - tvTitle = view.findViewById(R.id.tv_report_title) + reportMapView = view.findViewById(R.id.report_map_view) + reportMapView.onCreate(savedInstanceState) + + tvTitle = view.findViewById(R.id.tv_report_title) layoutStructured = view.findViewById(R.id.layout_structured_content) - tvPassageDate = view.findViewById(R.id.tv_passage_date) - tvPassageTime = view.findViewById(R.id.tv_passage_time) - tvStatDistance = view.findViewById(R.id.tv_stat_distance) - tvStatAvg = view.findViewById(R.id.tv_stat_avg) - tvStatMax = view.findViewById(R.id.tv_stat_max) + tvPassageDate = view.findViewById(R.id.tv_passage_date) + tvPassageTime = view.findViewById(R.id.tv_passage_time) + tvStatDistance = view.findViewById(R.id.tv_stat_distance) + tvStatAvg = view.findViewById(R.id.tv_stat_avg) + tvStatMax = view.findViewById(R.id.tv_stat_max) + tvLabelAvg = view.findViewById(R.id.tv_label_avg) + tvLabelMax = view.findViewById(R.id.tv_label_max) layoutConditions = view.findViewById(R.id.layout_conditions) - tvConditions = view.findViewById(R.id.tv_conditions) - dividerLog = view.findViewById(R.id.divider_log) + tvConditions = view.findViewById(R.id.tv_conditions) + dividerLog = view.findViewById(R.id.divider_log) layoutLogSection = view.findViewById(R.id.layout_log_section) - llLogEntries = view.findViewById(R.id.ll_log_entries) - cvPirate = view.findViewById(R.id.cv_pirate) - tvNarrative = view.findViewById(R.id.tv_narrative_content) - tvError = view.findViewById(R.id.tv_error) - btnRetry = view.findViewById(R.id.btn_retry) - progress = view.findViewById(R.id.progress_report) + llLogEntries = view.findViewById(R.id.ll_log_entries) + cvPirate = view.findViewById(R.id.cv_pirate) + tvNarrative = view.findViewById(R.id.tv_narrative_content) + tvError = view.findViewById(R.id.tv_error) + btnRetry = view.findViewById(R.id.btn_retry) + progress = view.findViewById(R.id.progress_report) val forSavedTrack = arguments?.getBoolean(ARG_FOR_SAVED_TRACK, false) == true val savedTrack = if (forSavedTrack) mainViewModel.selectedTrack.value else null @@ -117,6 +149,46 @@ class TripReportFragment : Fragment() { } } + // ── MapView lifecycle delegation ────────────────────────────────────────── + + override fun onStart() { + super.onStart() + if (::reportMapView.isInitialized) reportMapView.onStart() + } + + override fun onResume() { + super.onResume() + if (::reportMapView.isInitialized) reportMapView.onResume() + } + + override fun onPause() { + super.onPause() + if (::reportMapView.isInitialized) reportMapView.onPause() + } + + override fun onStop() { + super.onStop() + if (::reportMapView.isInitialized) reportMapView.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + if (::reportMapView.isInitialized) reportMapView.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (::reportMapView.isInitialized) reportMapView.onSaveInstanceState(outState) + } + + override fun onDestroyView() { + if (::reportMapView.isInitialized) reportMapView.onDestroy() + reportMap = null + super.onDestroyView() + } + + // ── State rendering ─────────────────────────────────────────────────────── + private fun renderState(state: TripReportState) { when (state) { is TripReportState.Idle -> { @@ -170,15 +242,21 @@ class TripReportFragment : Fragment() { } tvPassageTime.text = "${timeFmt.format(startInst)} – ${timeFmt.format(endInst)} · $durationStr" + val units = UnitPrefs(requireContext()) + tvStatDistance.text = "%.1f".format(s.distanceNm) - tvStatAvg.text = "%.1f".format(s.avgSogKts) - tvStatMax.text = "%.1f".format(s.maxSogKts) + tvStatAvg.text = units.formatSpeed(s.avgSogKts) + tvStatMax.text = units.formatSpeed(s.maxSogKts) + tvLabelAvg.text = "avg ${units.speedUnitLabel()}" + tvLabelMax.text = "max ${units.speedUnitLabel()}" val condParts = buildList { - s.maxWaveHeightM?.let { add("Seas %.1f m".format(it)) } + s.maxWaveHeightM?.let { add("Seas ${units.formatDepth(it)} ${units.depthUnitLabel()}") } val minT = s.minAirTempC; val maxT = s.maxAirTempC if (minT != null && maxT != null) { - add(if (minT == maxT) "%.0f°C".format(minT) else "%.0f–%.0f°C".format(minT, maxT)) + val label = units.tempUnitLabel() + add(if (minT == maxT) "${units.formatTemp(minT)}$label" + else "${units.formatTemp(minT)}–${units.formatTemp(maxT)}$label") } } if (condParts.isNotEmpty()) { @@ -198,36 +276,147 @@ class TripReportFragment : Fragment() { dividerLog.visibility = View.GONE layoutLogSection.visibility = View.GONE } + + // Draw the track on the map + reportMapView.getMapAsync { map -> + reportMap = map + map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/bright")) { style -> + if (s.points.size >= 2) { + drawTrack(style, s.points) + zoomToTrack(map, s.points) + } + } + } } + private fun drawTrack(style: Style, points: List) { + if (points.size < 2) return + val source = GeoJsonSource( + "report-track-source", + Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })) + ) + style.addSource(source) + style.addLayer(LineLayer("report-track-layer", "report-track-source").apply { + setProperties( + PropertyFactory.lineColor("#2B8FC4"), + PropertyFactory.lineWidth(4f), + PropertyFactory.lineCap("round"), + PropertyFactory.lineJoin("round") + ) + }) + } + + 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) + } + + // ── Log row builder ─────────────────────────────────────────────────────── + private fun buildLogRow(entry: LogEntry): View { val ctx = requireContext() - val row = LinearLayout(ctx).apply { + val density = resources.displayMetrics.density + + // Outer container — vertical + val container = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, (10 * density).toInt(), 0, (10 * density).toInt()) + } + + // Header row: time + text + val headerRow = LinearLayout(ctx).apply { orientation = LinearLayout.HORIZONTAL - setPadding(0, 10, 0, 10) } val timeView = TextView(ctx).apply { text = logTimeFmt.format(Instant.ofEpochMilli(entry.timestampMs)) textSize = 13f alpha = 0.5f - minWidth = (56 * resources.displayMetrics.density).toInt() + minWidth = (56 * density).toInt() } val isPhotoOnly = entry.text == "(photo only)" val displayText = when { - isPhotoOnly && entry.photoPath != null -> "📷" - entry.photoPath != null -> "${entry.text} 📷" + isPhotoOnly -> "" else -> entry.text } + val bodyView = TextView(ctx).apply { text = displayText textSize = 14f layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) } - row.addView(timeView) - row.addView(bodyView) - return row + headerRow.addView(timeView) + headerRow.addView(bodyView) + container.addView(headerRow) + + // Photo (if present) — full-width, 220dp tall, rounded corners + val photoPath = entry.photoPath + if (photoPath != null) { + val cornerRadiusPx = (12 * density).toInt() + val imageView = ImageView(ctx).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + visibility = View.INVISIBLE // revealed once bitmap loads + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx.toFloat()) + } + } + clipToOutline = true + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + (220 * density).toInt() + ).apply { + topMargin = (8 * density).toInt() + } + } + container.addView(imageView) + + viewLifecycleOwner.lifecycleScope.launch { + val bm = withContext(Dispatchers.IO) { + decodeThumbnail(ctx, photoPath, 800, 600) + } + if (bm != null) { + imageView.setImageBitmap(bm) + imageView.visibility = View.VISIBLE + } else { + imageView.visibility = View.GONE + } + } + } + + return container + } +} + +// ── Bitmap decode utility (mirrors TrackDetailSheet.decodeThumbnail) ────────── + +private fun decodeThumbnail( + context: android.content.Context, + pathOrUri: String, + targetW: Int, + targetH: Int +): android.graphics.Bitmap? { + if (pathOrUri.startsWith("content://")) { + return runCatching { + val uri = Uri.parse(pathOrUri) + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, BitmapFactory.Options().apply { inSampleSize = scale }) + } + }.getOrNull() } + if (!File(pathOrUri).exists()) return null + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(pathOrUri, opts) + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale }) } 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 ec3f58e..5df8938 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 @@ -3,220 +3,238 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurface"> + android:background="?attr/colorSurface" + android:fillViewport="true"> + android:orientation="vertical"> - + + - + - - - + android:padding="20dp"> + android:text="Trip Report" + android:textSize="24sp" + android:textStyle="bold" + android:longClickable="true" + android:layout_marginBottom="16dp" /> - + + android:orientation="vertical" + android:visibility="gone"> + + + + + + - + + - + + + + + + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_horizontal"> + + + + + + + + + + - + android:orientation="horizontal" + android:layout_marginBottom="20dp" + android:visibility="gone"> + + android:textSize="14sp" + android:alpha="0.7" /> + + + + + - + android:layout_marginTop="16dp" + android:visibility="gone"> + + android:textAllCaps="true" + android:letterSpacing="0.12" + android:alpha="0.5" + android:layout_marginBottom="8dp" /> + + + - - + + android:fontFamily="monospace" + android:lineSpacingExtra="4dp" /> - + - - + - - - - - - - - - - - - - + android:text="RETRY" + android:visibility="gone" /> - - - - - - - - + android:layout_gravity="center" + android:layout_marginTop="48dp" + android:visibility="gone" /> - + -- cgit v1.2.3