diff options
| author | Claude <noreply@anthropic.com> | 2026-05-30 08:11:15 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-30 08:11:15 +0000 |
| commit | 2b6a7599b3a9b0159ac3f45917e6e5d77841328c (patch) | |
| tree | 3aebbc058a3b3e5b45194997d4a5a5a8077a97cf | |
| parent | 1437d78b95b78e3f662b0d7e00669d386c3eedb6 (diff) | |
Redesign trip report: structured layout, remove generate button, fix photo entries
Layout:
- Stats displayed as three large-number columns (distance / avg kt / max kt)
- Date and time range as a clean header above stats
- Conditions row (seas + temp) conditionally shown
- Log entries rendered as a proper list with HH:mm timestamps
- No GENERATE REPORT button — report auto-generates on open; RETRY shown only on error
- Pirate mode (long-press title) still renders monospace narrative as before
Generator:
- Time format changed from 4-digit HHmm to HH:mm
- "(photo only)" entries display as 📷 instead of literal "(photo only) [photo]"
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
3 files changed, 332 insertions, 50 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 0a112a8..aa86c25 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 @@ -4,22 +4,29 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import androidx.fragment.app.Fragment 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.launch import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.logbook.LogEntry import org.terst.nav.ui.MainViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale class TripReportFragment : Fragment() { private val viewModel by lazy { TripReportViewModel( - trackRepository = NavApplication.trackRepository, + trackRepository = NavApplication.trackRepository, logbookRepository = NavApplication.logbookRepository ) } @@ -36,51 +43,77 @@ class TripReportFragment : Fragment() { } } - private lateinit var tvReportTitle: TextView - private lateinit var tvNarrativeContent: TextView - private lateinit var btnRefresh: MaterialButton + private lateinit var tvTitle: TextView + private lateinit var layoutStructured: LinearLayout + private lateinit var tvPassageDate: TextView + private lateinit var tvPassageTime: TextView + private lateinit var tvStatDistance: TextView + private lateinit var tvStatAvg: TextView + private lateinit var tvStatMax: TextView + private lateinit var layoutConditions: LinearLayout + private lateinit var tvConditions: TextView + private lateinit var dividerLog: View + private lateinit var layoutLogSection: LinearLayout + private lateinit var llLogEntries: LinearLayout + private lateinit var cvPirate: MaterialCardView + private lateinit var tvNarrative: TextView + private lateinit var tvError: TextView + private lateinit var btnRetry: MaterialButton private lateinit var progress: ProgressBar private var pirateMode = false private val generator = TripReportGenerator() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = inflater.inflate(R.layout.fragment_trip_report, container, false) + private val dateFmt = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).withZone(ZoneId.systemDefault()) + private val timeFmt = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).withZone(ZoneId.systemDefault()) + private val logTimeFmt = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).withZone(ZoneId.systemDefault()) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.fragment_trip_report, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - tvReportTitle = view.findViewById(R.id.tv_report_title) - tvNarrativeContent = view.findViewById(R.id.tv_narrative_content) - btnRefresh = view.findViewById(R.id.btn_refresh_report) - progress = view.findViewById(R.id.progress_report) + 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) + layoutConditions = view.findViewById(R.id.layout_conditions) + 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) val forSavedTrack = arguments?.getBoolean(ARG_FOR_SAVED_TRACK, false) == true val savedTrack = if (forSavedTrack) mainViewModel.selectedTrack.value else null - if (savedTrack != null) { - viewModel.generateReportForSavedTrack(savedTrack) - btnRefresh.setOnClickListener { viewModel.generateReportForSavedTrack(savedTrack) } + val generateFn: () -> Unit = if (savedTrack != null) { + { viewModel.generateReportForSavedTrack(savedTrack) } } else { - viewModel.generateReport() - btnRefresh.setOnClickListener { viewModel.generateReport() } + { viewModel.generateReport() } } - tvReportTitle.setOnLongClickListener { + generateFn() + btnRetry.setOnClickListener { generateFn() } + + tvTitle.setOnLongClickListener { pirateMode = !pirateMode - tvReportTitle.text = if (pirateMode) "☠ Trip Report ☠" else "Trip Report" + tvTitle.text = if (pirateMode) "☠ Trip Report ☠" else "Trip Report" val s = viewModel.state.value - if (s is TripReportState.Success) { - tvNarrativeContent.text = generator.generateNarrative(s.summary, pirate = pirateMode) - } + if (s is TripReportState.Success) renderState(s) true } viewLifecycleOwner.lifecycleScope.launch { - viewModel.state.collect { state -> renderState(state) } + viewModel.state.collect { renderState(it) } } } @@ -90,19 +123,111 @@ class TripReportFragment : Fragment() { progress.visibility = View.GONE } is TripReportState.Loading -> { + layoutStructured.visibility = View.GONE + cvPirate.visibility = View.GONE + tvError.visibility = View.GONE + btnRetry.visibility = View.GONE progress.visibility = View.VISIBLE - btnRefresh.isEnabled = false } is TripReportState.Success -> { progress.visibility = View.GONE - btnRefresh.isEnabled = true - tvNarrativeContent.text = generator.generateNarrative(state.summary, pirate = pirateMode) + tvError.visibility = View.GONE + btnRetry.visibility = View.GONE + if (pirateMode) { + layoutStructured.visibility = View.GONE + cvPirate.visibility = View.VISIBLE + tvNarrative.text = generator.generateNarrative(state.summary, pirate = true) + } else { + cvPirate.visibility = View.GONE + layoutStructured.visibility = View.VISIBLE + renderSummary(state.summary) + } } is TripReportState.Error -> { + layoutStructured.visibility = View.GONE + cvPirate.visibility = View.GONE progress.visibility = View.GONE - btnRefresh.isEnabled = true - tvNarrativeContent.text = "Error: ${state.message}" + tvError.text = state.message + tvError.visibility = View.VISIBLE + btnRetry.visibility = View.VISIBLE + } + } + } + + private fun renderSummary(s: TripSummary) { + val startInst = Instant.ofEpochMilli(s.startTimeMs) + val endInst = Instant.ofEpochMilli(s.endTimeMs) + + tvPassageDate.text = dateFmt.format(startInst).uppercase(Locale.getDefault()) + + val durationMs = s.endTimeMs - s.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" + } + tvPassageTime.text = "${timeFmt.format(startInst)} – ${timeFmt.format(endInst)} · $durationStr" + + tvStatDistance.text = "%.1f".format(s.distanceNm) + tvStatAvg.text = "%.1f".format(s.avgSogKts) + tvStatMax.text = "%.1f".format(s.maxSogKts) + + val condParts = buildList<String> { + s.maxWaveHeightM?.let { add("Seas %.1f m".format(it)) } + 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)) } } + if (condParts.isNotEmpty()) { + tvConditions.text = condParts.joinToString(" · ") + layoutConditions.visibility = View.VISIBLE + } else { + layoutConditions.visibility = View.GONE + } + + val entries = s.logEntries.sortedBy { it.timestampMs } + if (entries.isNotEmpty()) { + dividerLog.visibility = View.VISIBLE + layoutLogSection.visibility = View.VISIBLE + llLogEntries.removeAllViews() + entries.forEach { entry -> llLogEntries.addView(buildLogRow(entry)) } + } else { + dividerLog.visibility = View.GONE + layoutLogSection.visibility = View.GONE + } + } + + private fun buildLogRow(entry: LogEntry): View { + val ctx = requireContext() + val row = 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() + } + + val isPhotoOnly = entry.text == "(photo only)" + val displayText = when { + isPhotoOnly && entry.photoPath != null -> "📷" + entry.photoPath != null -> "${entry.text} 📷" + 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 } } 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 a101152..f23498e 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 @@ -79,7 +79,7 @@ class TripReportGenerator { private fun generateStandardNarrative(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 timeFmt = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).withZone(zone) val startInst = Instant.ofEpochMilli(summary.startTimeMs) val endInst = Instant.ofEpochMilli(summary.endTimeMs) @@ -116,8 +116,9 @@ class TripReportGenerator { 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") + val text = if (entry.text == "(photo only)") "📷" else entry.text + val photoMarker = if (entry.photoPath != null && entry.text != "(photo only)") " 📷" else "" + sb.appendLine("$time $text$photoMarker") } } @@ -127,7 +128,7 @@ class TripReportGenerator { private fun generatePirateNarrative(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 timeFmt = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).withZone(zone) val startInst = Instant.ofEpochMilli(summary.startTimeMs) val endInst = Instant.ofEpochMilli(summary.endTimeMs) 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 c8092ba..ec3f58e 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 @@ -18,48 +18,204 @@ android:text="Trip Report" android:textSize="24sp" android:textStyle="bold" - android:layout_marginBottom="24dp" + android:layout_marginBottom="20dp" android:longClickable="true" /> - <com.google.android.material.card.MaterialCardView + <!-- Structured content (hidden in pirate mode) --> + <LinearLayout + android:id="@+id/layout_structured_content" android:layout_width="match_parent" android:layout_height="wrap_content" - app:cardCornerRadius="16dp" - app:cardElevation="4dp" - app:strokeWidth="1dp" - app:strokeColor="?attr/colorOutlineVariant"> + android:orientation="vertical" + android:visibility="gone"> + + <!-- Passage header: date + time range --> + <TextView + android:id="@+id/tv_passage_date" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="13sp" + android:textAllCaps="true" + android:letterSpacing="0.1" + android:alpha="0.6" + android:layout_marginBottom="2dp" /> + + <TextView + android:id="@+id/tv_passage_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="17sp" + android:layout_marginBottom="20dp" /> + + <!-- Stats row: distance · avg · max --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginBottom="16dp"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_horizontal"> + <TextView + android:id="@+id/tv_stat_distance" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="34sp" + android:textStyle="bold" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="nm" + android:textSize="11sp" + android:alpha="0.55" /> + </LinearLayout> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_horizontal"> + <TextView + android:id="@+id/tv_stat_avg" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="34sp" + android:textStyle="bold" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="avg kt" + android:textSize="11sp" + android:alpha="0.55" /> + </LinearLayout> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_horizontal"> + <TextView + android:id="@+id/tv_stat_max" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="34sp" + android:textStyle="bold" /> + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="max kt" + android:textSize="11sp" + android:alpha="0.55" /> + </LinearLayout> + + </LinearLayout> + + <!-- Conditions row (seas / temp) — hidden if no data --> + <LinearLayout + android:id="@+id/layout_conditions" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginBottom="20dp" + android:visibility="gone"> + + <TextView + android:id="@+id/tv_conditions" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="14sp" + android:alpha="0.7" /> + + </LinearLayout> + <!-- Divider before log --> + <View + android:id="@+id/divider_log" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/colorOutlineVariant" + android:visibility="gone" /> + + <!-- Log entries — hidden if none --> <LinearLayout + android:id="@+id/layout_log_section" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="16dp"> + android:layout_marginTop="16dp" + android:visibility="gone"> <TextView - android:id="@+id/tv_narrative_content" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="LOG" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:alpha="0.5" + android:layout_marginBottom="8dp" /> + + <LinearLayout + android:id="@+id/ll_log_entries" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Generate a report to see your passage summary…" - android:textSize="15sp" - android:fontFamily="monospace" - android:lineSpacingExtra="4dp" /> + android:orientation="vertical" /> </LinearLayout> + + </LinearLayout> + + <!-- Pirate mode: monospace narrative card --> + <com.google.android.material.card.MaterialCardView + android:id="@+id/cv_pirate" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:cardCornerRadius="16dp" + app:cardElevation="0dp" + app:strokeWidth="1dp" + app:strokeColor="?attr/colorOutlineVariant" + android:visibility="gone"> + + <TextView + android:id="@+id/tv_narrative_content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp" + android:textSize="14sp" + android:fontFamily="monospace" + android:lineSpacingExtra="4dp" /> + </com.google.android.material.card.MaterialCardView> + <!-- Error state --> + <TextView + android:id="@+id/tv_error" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textSize="14sp" + android:alpha="0.7" + android:layout_marginBottom="16dp" + android:visibility="gone" /> + <com.google.android.material.button.MaterialButton - android:id="@+id/btn_refresh_report" + android:id="@+id/btn_retry" android:layout_width="match_parent" - android:layout_height="60dp" - android:layout_marginTop="24dp" - android:text="GENERATE REPORT" /> + android:layout_height="wrap_content" + android:text="RETRY" + android:visibility="gone" /> <ProgressBar android:id="@+id/progress_report" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginTop="16dp" + android:layout_marginTop="48dp" android:visibility="gone" /> </LinearLayout> |
