summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-30 08:11:15 +0000
committerClaude <noreply@anthropic.com>2026-05-30 08:11:15 +0000
commit2b6a7599b3a9b0159ac3f45917e6e5d77841328c (patch)
tree3aebbc058a3b3e5b45194997d4a5a5a8077a97cf
parent1437d78b95b78e3f662b0d7e00669d386c3eedb6 (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
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt183
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt9
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml190
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>