summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-04 02:31:54 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-04 02:31:54 +0000
commite182619ce43bddea8dbee73592e3318fa9fbfc71 (patch)
treec68e2bb68f88647749a6c6a88f71c1944a8d3dc0 /android-app/app/src/main/kotlin
parent0e867ffb8aa287ecaed4e8f58c52a9cfef1da01a (diff)
feat(tripreport): add AI trip narrative generator with multiple styles
- Consolidate track data, weather, and log entries into a TripSummary - Implement TripReportGenerator with Professional, Adventurous, Journal, and Pirate styles - Add TripReportFragment and ViewModel for UI interaction - Share TrackRepository and LogbookRepository via NavApplication singleton - Fix compilation error in MainViewModel rich metadata recording Co-Authored-By: Gemini CLI <gemini-cli@google.com>
Diffstat (limited to 'android-app/app/src/main/kotlin')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt86
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt117
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt10
6 files changed, 274 insertions, 6 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 3b8b596..9b8cb8a 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -12,6 +12,8 @@ import java.util.Locale
class NavApplication : Application() {
companion object {
+ val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ val trackRepository = org.terst.nav.track.TrackRepository()
var isTesting: Boolean = false
get() {
if (field) return true
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
new file mode 100644
index 0000000..e7a425f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -0,0 +1,86 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+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
+
+class TripReportFragment : Fragment() {
+
+ private val viewModel by lazy {
+ TripReportViewModel(
+ 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(
+ 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)
+
+ 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.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()
+ }
+
+ private fun renderState(state: TripReportState) {
+ when (state) {
+ is TripReportState.Idle -> {
+ progress.visibility = View.GONE
+ }
+ is TripReportState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnRefresh.isEnabled = false
+ }
+ is TripReportState.Success -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = state.narrative
+ }
+ is TripReportState.Error -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = "Error: ${state.message}"
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..bbf00b1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -0,0 +1,117 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.logbook.LogEntry
+import org.terst.nav.track.TrackPoint
+
+enum class NarrativeStyle {
+ PROFESSIONAL,
+ ADVENTUROUS,
+ JOURNAL,
+ PIRATE
+}
+
+data class TripSummary(
+ val startTimeMs: Long,
+ val endTimeMs: Long,
+ val distanceNm: Double,
+ val maxSogKts: Double,
+ val avgSogKts: Double,
+ val minAirTempC: Double?,
+ val maxAirTempC: Double?,
+ val maxWaveHeightM: Double?,
+ val logEntries: List<LogEntry>,
+ val points: List<TrackPoint>
+)
+
+class TripReportGenerator {
+
+ fun generateSummary(points: List<TrackPoint>, logEntries: List<LogEntry>): TripSummary {
+ if (points.isEmpty()) {
+ return TripSummary(0, 0, 0.0, 0.0, 0.0, null, null, null, logEntries, points)
+ }
+
+ 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 maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val airTemps = points.mapNotNull { it.airTempC }
+ val minTemp = airTemps.minOrNull()
+ val maxTemp = airTemps.maxOrNull()
+ 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
+ )
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 6371e3 // Earth radius in meters
+ val phi1 = Math.toRadians(lat1)
+ val phi2 = Math.toRadians(lat2)
+ val deltaPhi = Math.toRadians(lat2 - lat1)
+ val deltaLambda = Math.toRadians(lon2 - lon1)
+
+ val a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) *
+ Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ 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."
+ }
+ 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!"
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..e474cd2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.track.TrackRepository
+
+sealed class TripReportState {
+ object Idle : TripReportState()
+ object Loading : TripReportState()
+ data class Success(val summary: TripSummary, val narrative: String) : TripReportState()
+ data class Error(val message: String) : TripReportState()
+}
+
+class TripReportViewModel(
+ private val trackRepository: TrackRepository,
+ private val logbookRepository: InMemoryLogbookRepository,
+ private val generator: TripReportGenerator = TripReportGenerator()
+) : ViewModel() {
+
+ 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
+ try {
+ val points = trackRepository.getPoints()
+ if (points.isEmpty()) {
+ _state.value = TripReportState.Error("No track data available. Start recording a track first.")
+ return@launch
+ }
+ val logEntries = logbookRepository.getAll()
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ _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/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index a81a76f..7caabe7 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
@@ -49,7 +49,7 @@ class MainViewModel(
private val aisRepository = AisRepository()
- private val trackRepository = TrackRepository()
+ private val trackRepository = org.terst.nav.NavApplication.trackRepository
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
@@ -70,13 +70,14 @@ class MainViewModel(
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
val conditions = _marineConditions.value
+ val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
lat = lat, lon = lon,
sogKnots = sogKnots, cogDeg = cogDeg,
- airTempC = conditions?.airTemp,
- waveHeightM = conditions?.waveHeight,
- currentSpeedKts = conditions?.currentSpeed,
- currentDirDeg = conditions?.currentDir,
+ airTempC = forecast?.tempC,
+ waveHeightM = conditions?.waveHeightM,
+ currentSpeedKts = conditions?.currentSpeedKt,
+ currentDirDeg = conditions?.currentDirDeg,
timestampMs = System.currentTimeMillis()
)
if (trackRepository.addPoint(point)) {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
index ef48d37..86fd67c 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
@@ -28,7 +28,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var speechRecognizer: SpeechRecognizer
private val viewModel by lazy {
- VoiceLogViewModel(repository = InMemoryLogbookRepository())
+ VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
}
private lateinit var tvStatus: TextView
@@ -38,6 +38,7 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnSave: Button
private lateinit var btnRetry: Button
private lateinit var tvSavedConfirmation: TextView
+ private lateinit var btnGenerateReport: MaterialButton
override fun onCreateView(
inflater: LayoutInflater,
@@ -55,12 +56,19 @@ class VoiceLogFragment : Fragment() {
btnSave = view.findViewById(R.id.btn_save)
btnRetry = view.findViewById(R.id.btn_retry)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
+ btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
btnSave.setOnClickListener { viewModel.confirmAndSave() }
btnRetry.setOnClickListener { viewModel.retry() }
+ btnGenerateReport.setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }