From 11226211d9bbfd9ec255d9a957531faaa9ace1ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 21:08:13 +0000 Subject: Add boat profiles and overhaul pre-trip departure briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BoatProfileRepository: Moshi+SharedPrefs persistence, pre-seeds Erickson 23 (155%/100%/65% headsails, 2 reefs) and Cal 20 (1 reef) - PreTripModels: expand with SailConfig, ConditionSlice, RouteProjection, WatchItem, SimilarTripSummary; full BoatProfile with sail inventory - PreTripReportGenerator: full rewrite — 4-hour conditions window, Honokohau candidate-heading route projection with TWA scoring and current-component calc, boat-specific sail plan from inventory, hazard watchlist, similar-trip comparison via past GPX tracks - PreTripReportViewModel: inject BoatProfileRepository, fix GPS to LocationService.bestPosition (Honokohau fallback), pass full forecastItems list and pastTracks to generator - PreTripReportFragment: boat selector ChipGroup, auto-generate on open, render all sections (conditions table, route, sail plan, watchlist, similar trips) - fragment_pretrip_report.xml: redesign with all report cards - item_condition_column.xml: new layout for conditions-window columns - NavApplication: instantiate BoatProfileRepository as app singleton https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../main/kotlin/org/terst/nav/NavApplication.kt | 2 + .../terst/nav/tripreport/BoatProfileRepository.kt | 137 ++++++++ .../org/terst/nav/tripreport/PreTripModels.kt | 74 +++- .../terst/nav/tripreport/PreTripReportFragment.kt | 209 +++++++++--- .../terst/nav/tripreport/PreTripReportGenerator.kt | 380 ++++++++++++++++++--- .../terst/nav/tripreport/PreTripReportViewModel.kt | 56 ++- .../main/res/layout/fragment_pretrip_report.xml | 246 +++++++++---- .../src/main/res/layout/item_condition_column.xml | 44 +++ 8 files changed, 977 insertions(+), 171 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt create mode 100644 android-app/app/src/main/res/layout/item_condition_column.xml (limited to 'android-app/app/src/main') 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 7c43dd5..0f1e245 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 @@ -14,6 +14,7 @@ class NavApplication : Application() { companion object { val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository() lateinit var trackRepository: org.terst.nav.track.TrackRepository + lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository var isTesting: Boolean = false get() { if (field) return true @@ -29,6 +30,7 @@ class NavApplication : Application() { override fun onCreate() { super.onCreate() trackRepository = org.terst.nav.track.TrackRepository(this) + boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this) FirebaseCrashlytics.getInstance().sendUnsentReports() installCrashLogger() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt new file mode 100644 index 0000000..090e928 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt @@ -0,0 +1,137 @@ +package org.terst.nav.tripreport + +import android.content.Context +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +/** + * Persists boat profiles to SharedPreferences as a JSON array. + * Pre-seeds Erickson 23 and Cal 20 on first launch. + */ +class BoatProfileRepository(context: Context) { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + private val listType = Types.newParameterizedType(List::class.java, ProfileJson::class.java) + private val adapter = moshi.adapter>(listType) + + // ── Public API ──────────────────────────────────────────────────────────── + + fun loadProfiles(): List { + val json = prefs.getString(KEY_PROFILES, null) + val list = if (json != null) runCatching { adapter.fromJson(json) }.getOrNull() else null + return if (list.isNullOrEmpty()) { + val defaults = defaultProfiles() + saveProfiles(defaults) + defaults + } else { + list.map { it.toDomain() } + } + } + + fun saveProfiles(profiles: List) { + prefs.edit().putString(KEY_PROFILES, adapter.toJson(profiles.map { ProfileJson.from(it) })).apply() + } + + fun selectedId(): String? = prefs.getString(KEY_SELECTED, null) + + fun setSelectedId(id: String) = prefs.edit().putString(KEY_SELECTED, id).apply() + + fun selectedProfile(): BoatProfile? { + val id = selectedId() ?: return loadProfiles().firstOrNull() + return loadProfiles().firstOrNull { it.id == id } + } + + // ── JSON transfer object (Moshi needs simple primitives) ───────────────── + + @JsonClass(generateAdapter = true) + data class SailJson( + val name: String, + val minWindKt: Int, + val maxWindKt: Int, + val isHeadsail: Boolean + ) { + fun toDomain() = SailConfig(name, minWindKt, maxWindKt, isHeadsail) + companion object { + fun from(s: SailConfig) = SailJson(s.name, s.minWindKt, s.maxWindKt, s.isHeadsail) + } + } + + @JsonClass(generateAdapter = true) + data class ProfileJson( + val id: String, + val name: String, + val lengthFt: Double, + val type: String, // BoatType.name + val rig: String, // RigType.name + val headsails: List, + val mainReefs: Int, + val hasSpinnaker: Boolean, + val hasGennaker: Boolean, + val notes: String + ) { + fun toDomain() = BoatProfile( + id = id, + name = name, + lengthFt = lengthFt, + type = runCatching { BoatType.valueOf(type) }.getOrDefault(BoatType.MONOHULL), + rig = runCatching { RigType.valueOf(rig) }.getOrDefault(RigType.SLOOP), + headsails = headsails.map { it.toDomain() }, + mainReefs = mainReefs, + hasSpinnaker = hasSpinnaker, + hasGennaker = hasGennaker, + notes = notes + ) + companion object { + fun from(p: BoatProfile) = ProfileJson( + id = p.id, + name = p.name, + lengthFt = p.lengthFt, + type = p.type.name, + rig = p.rig.name, + headsails = p.headsails.map { SailJson.from(it) }, + mainReefs = p.mainReefs, + hasSpinnaker = p.hasSpinnaker, + hasGennaker = p.hasGennaker, + notes = p.notes + ) + } + } + + companion object { + private const val PREF_NAME = "boat_profiles" + private const val KEY_PROFILES = "profiles" + private const val KEY_SELECTED = "selected_id" + + fun defaultProfiles() = listOf( + BoatProfile( + id = "e23", + name = "Erickson 23", + lengthFt = 23.0, + type = BoatType.MONOHULL, + rig = RigType.SLOOP, + headsails = listOf( + SailConfig("155% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true), + SailConfig("100% Jib", minWindKt = 10, maxWindKt = 21, isHeadsail = true), + SailConfig("65% Blade", minWindKt = 18, maxWindKt = 40, isHeadsail = true) + ), + mainReefs = 2 + ), + BoatProfile( + id = "cal20", + name = "Cal 20", + lengthFt = 20.0, + type = BoatType.MONOHULL, + rig = RigType.SLOOP, + headsails = listOf( + SailConfig("150% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true), + SailConfig("100% Jib", minWindKt = 10, maxWindKt = 22, isHeadsail = true), + SailConfig("65% Blade", minWindKt = 19, maxWindKt = 40, isHeadsail = true) + ), + mainReefs = 1 + ) + ) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt index 2362079..c1bc6e7 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt @@ -1,25 +1,34 @@ package org.terst.nav.tripreport -enum class BoatType { - MONOHULL, - MULTIHULL -} +enum class BoatType { MONOHULL, MULTIHULL } +enum class RigType { SLOOP, CUTTER, KETCH } -enum class RigType { - SLOOP, - CUTTER, - KETCH -} +/** + * A single headsail (or main-sail configuration) in the boat's inventory. + * Wind ranges are approximate apparent-wind speeds at which the sail is appropriate. + */ +data class SailConfig( + val name: String, // e.g. "155% Genoa", "100% Jib", "65% Blade" + val minWindKt: Int = 0, + val maxWindKt: Int, + val isHeadsail: Boolean +) data class BoatProfile( + val id: String, val name: String, val lengthFt: Double, val type: BoatType, val rig: RigType, + val headsails: List = emptyList(), + val mainReefs: Int = 0, val hasSpinnaker: Boolean = false, - val hasGennaker: Boolean = false + val hasGennaker: Boolean = false, + val notes: String = "" ) +// ── Snapshot used to build the report ──────────────────────────────────────── + data class PreTripSummary( val timestampMs: Long, val lat: Double, @@ -31,13 +40,52 @@ data class PreTripSummary( val boatProfile: BoatProfile ) +// ── Output models ───────────────────────────────────────────────────────────── + +/** One hourly step in the trip-window forecast table. */ +data class ConditionSlice( + val label: String, // "Now", "+1 h", "+2 h", "+3 h" + val windKt: Double, + val windDirDeg: Double, + val waveHeightM: Double?, + val weatherDescription: String +) + +/** Suggested heading and distance for a round-trip sail. */ +data class RouteProjection( + val departureHeadingDeg: Double, + val outboundLegNm: Double, // nm before recommended turn-around + val returnHeadingDeg: Double, + val outboundPointOfSail: String, // "Beam Reach", "Close Hauled", etc. + val returnPointOfSail: String, + val rationale: String // human-readable description +) + +/** A single hazard or advisory notice. */ +data class WatchItem( + val severity: String, // "⚠️" warning or "ℹ️" info + val message: String +) + +/** Aggregated stats from past trips that had similar wind conditions. */ +data class SimilarTripSummary( + val count: Int, + val avgDistanceNm: Double, + val avgDurationHrs: Double, + val avgMaxSogKt: Double, + val conditionDescription: String // e.g. "10–15 kt NE" +) + data class SailSuggestion( val sailName: String, - val action: String // e.g., "Full Main", "1 Reef", "Furl" + val action: String ) data class PreTripReport( val summary: PreTripSummary, - val routingSuggestion: String, - val sailPlan: List + val conditionWindow: List, + val routeProjection: RouteProjection, + val sailPlan: List, + val watchItems: List, + val similarTrips: SimilarTripSummary? ) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt index 819485f..c88dc48 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt @@ -4,67 +4,134 @@ 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.ViewModelProvider import androidx.lifecycle.lifecycleScope -import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView -import kotlinx.coroutines.flow.first +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup import kotlinx.coroutines.launch +import org.terst.nav.NavApplication import org.terst.nav.R import org.terst.nav.ui.MainViewModel import java.util.Locale class PreTripReportFragment : Fragment() { - private val viewModel: PreTripReportViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels() + private lateinit var viewModel: PreTripReportViewModel - private lateinit var tvWeatherSummary: TextView - private lateinit var tvRoutingContent: TextView - private lateinit var tvSailPlanContent: TextView - private lateinit var cardReport: MaterialCardView - private lateinit var btnGenerate: MaterialButton + private lateinit var chipGroupBoats: ChipGroup + private lateinit var btnGenerate: com.google.android.material.button.MaterialButton private lateinit var progress: ProgressBar + private lateinit var cardConditions: MaterialCardView + private lateinit var conditionsTable: LinearLayout + private lateinit var cardRoute: MaterialCardView + private lateinit var tvRoute: TextView + private lateinit var cardSailPlan: MaterialCardView + private lateinit var tvSailPlan: TextView + private lateinit var cardWatchlist: MaterialCardView + private lateinit var tvWatchlist: TextView + private lateinit var cardSimilar: MaterialCardView + private lateinit var tvSimilar: TextView + private lateinit var tvError: TextView override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? = inflater.inflate(R.layout.fragment_pretrip_report, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - tvWeatherSummary = view.findViewById(R.id.tv_weather_summary) - tvRoutingContent = view.findViewById(R.id.tv_routing_content) - tvSailPlanContent = view.findViewById(R.id.tv_sail_plan_content) - cardReport = view.findViewById(R.id.card_report) - btnGenerate = view.findViewById(R.id.btn_generate_pretrip) - progress = view.findViewById(R.id.progress_pretrip) + // Construct ViewModel with the application-level repo + viewModel = ViewModelProvider( + requireActivity(), + PreTripReportViewModel.Factory(NavApplication.boatProfileRepository) + )[PreTripReportViewModel::class.java] - btnGenerate.setOnClickListener { - generateReport() + bindViews(view) + observeViewModel() + + btnGenerate.setOnClickListener { triggerGenerate() } + + // Auto-generate if forecast data is already available + if (mainViewModel.forecast.value.isNotEmpty()) { + triggerGenerate() } + } + private fun bindViews(view: View) { + chipGroupBoats = view.findViewById(R.id.chip_group_boats) + btnGenerate = view.findViewById(R.id.btn_generate_pretrip) + progress = view.findViewById(R.id.progress_pretrip) + cardConditions = view.findViewById(R.id.card_conditions) + conditionsTable = view.findViewById(R.id.conditions_table) + cardRoute = view.findViewById(R.id.card_route) + tvRoute = view.findViewById(R.id.tv_route) + cardSailPlan = view.findViewById(R.id.card_sail_plan) + tvSailPlan = view.findViewById(R.id.tv_sail_plan) + cardWatchlist = view.findViewById(R.id.card_watchlist) + tvWatchlist = view.findViewById(R.id.tv_watchlist) + cardSimilar = view.findViewById(R.id.card_similar) + tvSimilar = view.findViewById(R.id.tv_similar) + tvError = view.findViewById(R.id.tv_error) + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.profiles.collect { profiles -> rebuildBoatChips(profiles) } + } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.selectedProfile.collect { profile -> + if (profile != null) syncChipSelection(profile.id) + } + } viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { renderState(it) } } } - private fun generateReport() { - viewLifecycleOwner.lifecycleScope.launch { - val forecast = mainViewModel.forecast.value.firstOrNull() - val conditions = mainViewModel.marineConditions.value - // For now, use 0,0 if no location, but ideally we'd have last known - // In a real app, we'd get this from a LocationProvider - viewModel.generate(0.0, 0.0, forecast, conditions) + private fun rebuildBoatChips(profiles: List) { + chipGroupBoats.removeAllViews() + val selectedId = viewModel.selectedProfile.value?.id + profiles.forEach { profile -> + val chip = Chip(requireContext()).apply { + text = profile.name + isCheckable = true + isChecked = (profile.id == selectedId) + tag = profile.id + } + chip.setOnCheckedChangeListener { _, checked -> + if (checked) { + viewModel.selectProfile(profile.id) + triggerGenerate() + } + } + chipGroupBoats.addView(chip) + } + } + + private fun syncChipSelection(selectedId: String) { + for (i in 0 until chipGroupBoats.childCount) { + val chip = chipGroupBoats.getChildAt(i) as? Chip ?: continue + chip.isChecked = (chip.tag == selectedId) } } + private fun triggerGenerate() { + val forecast = mainViewModel.forecast.value + val conditions = mainViewModel.marineConditions.value + viewModel.generate(forecast, conditions) + } + + // ── Rendering ───────────────────────────────────────────────────────────── + private fun renderState(state: PreTripState) { + tvError.visibility = View.GONE when (state) { is PreTripState.Loading -> { progress.visibility = View.VISIBLE @@ -73,30 +140,82 @@ class PreTripReportFragment : Fragment() { is PreTripState.Success -> { progress.visibility = View.GONE btnGenerate.isEnabled = true - cardReport.visibility = View.VISIBLE - - val r = state.report - tvWeatherSummary.text = "Wind: %.1f kts from %.0f°\nWaves: %s\nSky: %s".format( - Locale.getDefault(), - r.summary.windSpeedKt, - r.summary.windDirDeg, - r.summary.waveHeightM?.let { "%.1fm".format(it) } ?: "N/A", - r.summary.weatherDescription - ) - - tvRoutingContent.text = r.routingSuggestion - - val sailPlanText = r.sailPlan.joinToString("\n") { - "• ${it.sailName}: ${it.action}" - } - tvSailPlanContent.text = sailPlanText + renderReport(state.report) } is PreTripState.Error -> { progress.visibility = View.GONE btnGenerate.isEnabled = true - // Show toast or error message + tvError.text = state.message + tvError.visibility = View.VISIBLE } - else -> {} + else -> { + progress.visibility = View.GONE + btnGenerate.isEnabled = true + } + } + } + + private fun renderReport(r: PreTripReport) { + renderConditionsWindow(r.conditionWindow) + renderRoute(r.routeProjection) + renderSailPlan(r.sailPlan) + renderWatchlist(r.watchItems) + renderSimilarTrips(r.similarTrips) + } + + private fun renderConditionsWindow(slices: List) { + conditionsTable.removeAllViews() + slices.forEach { slice -> + val col = LayoutInflater.from(requireContext()) + .inflate(R.layout.item_condition_column, conditionsTable, false) + col.findViewById(R.id.col_label).text = slice.label + col.findViewById(R.id.col_wind).text = "%.0f kt".format(slice.windKt) + col.findViewById(R.id.col_wind_dir).text = cardinalDir(slice.windDirDeg) + col.findViewById(R.id.col_sky).text = slice.weatherDescription.take(12) + conditionsTable.addView(col) + } + cardConditions.visibility = if (slices.isNotEmpty()) View.VISIBLE else View.GONE + } + + private fun renderRoute(proj: RouteProjection) { + tvRoute.text = proj.rationale + cardRoute.visibility = View.VISIBLE + } + + private fun renderSailPlan(plan: List) { + tvSailPlan.text = plan.joinToString("\n") { "• ${it.sailName}: ${it.action}" } + cardSailPlan.visibility = View.VISIBLE + } + + private fun renderWatchlist(items: List) { + if (items.isEmpty()) { + cardWatchlist.visibility = View.GONE + return } + tvWatchlist.text = items.joinToString("\n") { "${it.severity} ${it.message}" } + cardWatchlist.visibility = View.VISIBLE + } + + private fun renderSimilarTrips(similar: SimilarTripSummary?) { + if (similar == null) { + cardSimilar.visibility = View.GONE + return + } + tvSimilar.text = "%d trip%s in %s\n• Avg distance %.1f nm\n• Avg duration %.1f h\n• Avg max SOG %.1f kt".format( + Locale.getDefault(), + similar.count, + if (similar.count == 1) "" else "s", + similar.conditionDescription, + similar.avgDistanceNm, + similar.avgDurationHrs, + similar.avgMaxSogKt + ) + cardSimilar.visibility = View.VISIBLE + } + + private fun cardinalDir(deg: Double): String { + val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE", + "S","SSW","SW","WSW","W","WNW","NW","NNW") + return dirs[((deg + 11.25) / 22.5).toInt() % 16] } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt index 2ccabfb..2508d44 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt @@ -2,65 +2,367 @@ package org.terst.nav.tripreport import org.terst.nav.data.model.ForecastItem import org.terst.nav.data.model.MarineConditions +import org.terst.nav.track.TrackPoint +import org.terst.nav.track.summarise +import java.util.Calendar +import kotlin.math.* class PreTripReportGenerator { + /** + * Builds a full pre-trip briefing. + * + * @param lat Current position latitude + * @param lon Current position longitude + * @param forecastItems Hourly forecast list; first item = current hour + * @param conditions Current marine snapshot (waves, current, swell) + * @param boatProfile Selected vessel with sail inventory + * @param pastTracks All saved tracks for similar-conditions comparison + * @param durationHrs Planned trip duration in hours (default 3) + */ fun generateReport( lat: Double, lon: Double, - forecast: ForecastItem?, + forecastItems: List, conditions: MarineConditions?, - boatProfile: BoatProfile + boatProfile: BoatProfile, + pastTracks: List> = emptyList(), + durationHrs: Double = 3.0 ): PreTripReport { + val current = forecastItems.firstOrNull() + val windKt = current?.windKt ?: 0.0 + val windDir = current?.windDirDeg ?: 0.0 + val summary = PreTripSummary( - timestampMs = System.currentTimeMillis(), - lat = lat, - lon = lon, - windSpeedKt = forecast?.windKt ?: 0.0, - windDirDeg = forecast?.windDirDeg ?: 0.0, - waveHeightM = conditions?.waveHeightM, - weatherDescription = forecast?.weatherDescription() ?: "Unknown", - boatProfile = boatProfile + timestampMs = System.currentTimeMillis(), + lat = lat, + lon = lon, + windSpeedKt = windKt, + windDirDeg = windDir, + waveHeightM = conditions?.waveHeightM, + weatherDescription = current?.weatherDescription() ?: "Unknown", + boatProfile = boatProfile ) - val routing = suggestRouting(summary) - val sailPlan = suggestSailPlan(summary) + return PreTripReport( + summary = summary, + conditionWindow = buildConditionWindow(forecastItems), + routeProjection = projectRoute(windDir, windKt, + conditions?.currentSpeedKt ?: 0.0, + conditions?.currentDirDeg ?: 0.0, + durationHrs, boatProfile), + sailPlan = suggestSailPlan(windKt, forecastItems, boatProfile), + watchItems = buildWatchList(conditions, forecastItems, boatProfile), + similarTrips = findSimilarTrips(pastTracks, windKt, windDir) + ) + } + + // ── Condition window ────────────────────────────────────────────────────── - return PreTripReport(summary, routing, sailPlan) + private fun buildConditionWindow(items: List): List { + val labels = listOf("Now", "+1 h", "+2 h", "+3 h") + return items.take(4).mapIndexed { i, item -> + ConditionSlice( + label = labels.getOrElse(i) { "+${i} h" }, + windKt = item.windKt, + windDirDeg = item.windDirDeg, + waveHeightM = null, // marine API doesn't give hourly per slot here; use conditions + weatherDescription = item.weatherDescription() + ) + } } - private fun suggestRouting(summary: PreTripSummary): String { - val wind = summary.windSpeedKt - val waves = summary.waveHeightM ?: 0.0 - - return when { - wind > 35.0 -> "STORM WARNING: Winds exceed 35kts. Consider remaining in port or seeking shelter immediately." - wind > 25.0 && waves > 2.5 -> "HEAVY WEATHER: Expect challenging conditions. Coastal routing advised to minimize fetch." - wind < 5.0 -> "LIGHT WINDS: Motor-sailing likely required for efficient passage." - else -> "FAVORABLE CONDITIONS: Standard routing based on destination bearing should be effective." + // ── Route projection ────────────────────────────────────────────────────── + + /** + * Candidate headings from Honokohau — each carries local context. + * Format: (headingDeg, label, localNote) + */ + private val candidates = listOf( + Triple(350.0, "N", "Kohala coast — upwind grind out on NE trades, fast run home"), + Triple(305.0, "NW", "Offshore — watch for trade acceleration past Upolu Point"), + Triple(270.0, "W", "Open water — comfortable in most trade conditions"), + Triple(225.0, "SW", "Toward Keauhou — broad reach out, may be a beat home if trades build"), + Triple(180.0, "S", "Keauhou Bay — note Alenuihāhā Channel shipping traffic") + ) + + private fun projectRoute( + windDirDeg: Double, + twsKt: Double, + currentSpeedKt: Double, + currentDirDeg: Double, + durationHrs: Double, + boatProfile: BoatProfile + ): RouteProjection { + // Score each candidate heading + data class Candidate( + val heading: Double, val label: String, val note: String, + val twa: Double, val sog: Double, val score: Double + ) + + val scored = candidates.map { (hdg, lbl, note) -> + val twa = twa(windDirDeg, hdg) + val sog = estimatedSogKt(boatProfile.lengthFt, twsKt, twa) + val score = headingScore(twa) + Candidate(hdg, lbl, note, twa, sog, score) + } + + val best = scored.maxByOrNull { it.score }!! + + // Return leg is the reciprocal heading + val returnHdg = (best.heading + 180.0) % 360.0 + val returnTwa = twa(windDirDeg, returnHdg) + val returnSog = estimatedSogKt(boatProfile.lengthFt, twsKt, returnTwa) + + // Outbound leg distance (half the trip duration at outbound SOG) + val outboundHrs = durationHrs / 2.0 + val outboundNm = best.sog * outboundHrs + + // Current component along outbound heading + val currentComponent = currentSpeedKt * + cos(Math.toRadians(currentDirDeg - best.heading)) + val currentNote = when { + currentComponent > 0.15 -> "Current adds %.1f kt outbound.".format(currentComponent) + currentComponent < -0.15 -> "Current opposes %.1f kt outbound.".format(-currentComponent) + else -> "" + } + + val outboundPos = pointOfSailName(best.twa) + val returnPos = pointOfSailName(returnTwa) + + val rationale = buildString { + append("Head ${best.label} (${best.heading.toInt()}°) — $outboundPos ") + append("at ~%.1f kt. ".format(best.sog)) + append("Est. turn-around %.1f nm out".format(outboundNm)) + append(", ~%.0f min. ".format(outboundHrs * 60)) + append("Return (${returnHdg.toInt()}°): $returnPos at ~%.1f kt.".format(returnSog)) + if (currentNote.isNotEmpty()) append(" $currentNote") + append("\n${best.note}") + } + + return RouteProjection( + departureHeadingDeg = best.heading, + outboundLegNm = outboundNm, + returnHeadingDeg = returnHdg, + outboundPointOfSail = outboundPos, + returnPointOfSail = returnPos, + rationale = rationale + ) + } + + /** True wind angle in degrees (0–180) for a given course relative to wind direction. */ + private fun twa(windDirFrom: Double, boatHdg: Double): Double { + var angle = abs(windDirFrom - boatHdg) % 360.0 + if (angle > 180.0) angle = 360.0 - angle + return angle + } + + /** Simplified polar SOG estimate based on hull speed limit and point of sail. */ + fun estimatedSogKt(lengthFt: Double, twsKt: Double, twaDegs: Double): Double { + val hullSpeed = 1.34 * sqrt(lengthFt) + val efficiency = when { + twaDegs < 40 -> 0.45 + twaDegs < 70 -> 0.70 + twaDegs < 120 -> 0.85 // beam reach — optimum + twaDegs < 150 -> 0.78 + else -> 0.60 // dead run } + val windFactor = when { + twsKt < 5 -> 0.30 + twsKt < 10 -> 0.65 + twsKt < 15 -> 0.85 + twsKt < 22 -> 1.00 + else -> 0.88 // reefed, slightly slower + } + return (hullSpeed * efficiency * windFactor).coerceAtMost(hullSpeed) } - private fun suggestSailPlan(summary: PreTripSummary): List { - val wind = summary.windSpeedKt + /** Higher = better heading; prefer beam reach, penalise hard beat or dead run. */ + private fun headingScore(twa: Double): Double = when { + twa < 35 -> 0.2 + twa < 55 -> 0.5 + twa < 125 -> 1.0 // broad beam reach window — ideal + twa < 150 -> 0.75 + else -> 0.4 + } + + private fun pointOfSailName(twa: Double): String = when { + twa < 45 -> "Close Hauled" + twa < 70 -> "Close Reach" + twa < 115 -> "Beam Reach" + twa < 150 -> "Broad Reach" + else -> "Run" + } + + // ── Sail plan ───────────────────────────────────────────────────────────── + + private fun suggestSailPlan( + windKt: Double, + forecastItems: List, + boatProfile: BoatProfile + ): List { val suggestions = mutableListOf() - // Main sail - suggestions.add(when { - wind > 30.0 -> SailSuggestion("Main", "Deep Reef / Trysail") - wind > 22.0 -> SailSuggestion("Main", "2nd Reef") - wind > 16.0 -> SailSuggestion("Main", "1st Reef") - else -> SailSuggestion("Main", "Full Main") - }) - - // Headsail - suggestions.add(when { - wind > 25.0 -> SailSuggestion("Headsail", "Storm Jib / Furl 50%") - wind > 18.0 -> SailSuggestion("Headsail", "Working Jib / Furl 30%") - wind < 10.0 && summary.boatProfile.hasGennaker -> SailSuggestion("Gennaker", "Deploy for light air reach") - else -> SailSuggestion("Headsail", "Full Genoa") - }) + // Pick best-fit headsail from inventory (highest maxWindKt that still covers windKt, + // falling back to smallest sail if wind exceeds all) + val headsail = boatProfile.headsails + .sortedBy { it.maxWindKt } + .firstOrNull { windKt <= it.maxWindKt } + ?: boatProfile.headsails.minByOrNull { it.maxWindKt } + + if (headsail != null) { + // Build a rationale note for this headsail choice + val note = headsailNote(headsail, windKt, forecastItems, boatProfile) + suggestions.add(SailSuggestion(headsail.name, note)) + } + + // Main reef recommendation + val mainAction = when { + windKt > 21 && boatProfile.mainReefs >= 2 -> "2nd Reef" + windKt > 15 && boatProfile.mainReefs >= 1 -> "1st Reef" + windKt > 22 && boatProfile.mainReefs == 1 -> "Full Reef" + else -> "Full Main" + } + suggestions.add(SailSuggestion("Main", mainAction)) + + // If wind is likely to build into a higher band during the trip, add a heads-up + val maxForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + if (maxForecastWind > windKt + 4) { + val nextSail = boatProfile.headsails + .sortedBy { it.maxWindKt } + .firstOrNull { maxForecastWind <= it.maxWindKt && it != headsail } + if (nextSail != null) { + suggestions.add(SailSuggestion( + "On deck", + "${nextSail.name} — winds forecast to reach %.0f kt".format(maxForecastWind) + )) + } + } return suggestions } + + private fun headsailNote( + sail: SailConfig, + windKt: Double, + forecastItems: List, + profile: BoatProfile + ): String { + val maxForecast = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + + // E23-specific 155% overlap note + if (sail.name.contains("155%") && windKt > 10) { + return if (maxForecast > sail.maxWindKt) + "Flying now, but winds forecast to ${maxForecast.toInt()} kt — swap to 100% before departure" + else + "Good in current conditions; 155% sheets outside shrouds, plan tacks early" + } + + return when { + windKt <= sail.maxWindKt * 0.6 -> "Light for this sail — ${sail.name} is correct choice" + windKt <= sail.maxWindKt -> "In the sweet spot for ${sail.name}" + else -> "Approaching upper limit — consider smaller headsail" + } + } + + // ── Watch list ──────────────────────────────────────────────────────────── + + private fun buildWatchList( + conditions: MarineConditions?, + forecastItems: List, + boatProfile: BoatProfile + ): List { + val items = mutableListOf() + val windKt = forecastItems.firstOrNull()?.windKt ?: 0.0 + + // Swell hazards + conditions?.swellHeightM?.let { swell -> + if (swell > 2.0) + items += WatchItem("⚠️", "Significant swell %.1fm — expect motion".format(swell)) + } + conditions?.swellPeriodS?.let { period -> + if (period < 8.0) + items += WatchItem("⚠️", "Short swell period %.0fs — choppy, uncomfortable".format(period)) + } + + // Building wind in the trip window + val peakForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt + val peakHour = forecastItems.take(4).indexOfFirst { it.windKt == peakForecastWind } + if (peakForecastWind > windKt + 5) { + items += WatchItem("⚠️", + "Wind building to ${peakForecastWind.toInt()} kt in ~${peakHour} h — plan to be back before then") + } + + // Rain + val maxPrecip = forecastItems.take(4).maxOfOrNull { it.precipProbabilityPct } ?: 0 + if (maxPrecip > 30) + items += WatchItem("⚠️", "Rain likely ($maxPrecip% chance) — visibility may reduce") + + // Time-of-day trade build + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + if (hour in 11..14) + items += WatchItem("ℹ️", "Departing midday — Kona trades typically build 5–8 kt through afternoon") + else if (hour >= 15) + items += WatchItem("ℹ️", "Afternoon departure — trades may be at their strongest; build extra time margin") + + // E23 155% genoa overpower warning + val largeGenoa = boatProfile.headsails.firstOrNull { it.name.contains("155%") } + if (largeGenoa != null && windKt > largeGenoa.maxWindKt) { + items += WatchItem("⚠️", "155% Genoa will overpower ${boatProfile.name} at ${windKt.toInt()} kt — rig 100% Jib instead") + } + + // General storm check + if (windKt > 30) + items += WatchItem("⚠️", "Winds ${windKt.toInt()} kt — consider staying in port") + else if (windKt > 22) + items += WatchItem("⚠️", "Strong winds — reef before casting off") + + return items + } + + // ── Similar trips ───────────────────────────────────────────────────────── + + private fun findSimilarTrips( + pastTracks: List>, + windKt: Double, + windDirDeg: Double + ): SimilarTripSummary? { + if (pastTracks.isEmpty()) return null + + val matched = pastTracks.filter { points -> + if (points.size < 5) return@filter false + val winds = points.mapNotNull { it.windSpeedKnots } + if (winds.isEmpty()) return@filter false + val avgWind = winds.average() + val avgDir = points.mapNotNull { it.windAngleDeg }.average().takeIf { !it.isNaN() } ?: return@filter false + abs(avgWind - windKt) <= 5.0 && angleDiff(avgDir, windDirDeg) <= 30.0 + } + + if (matched.isEmpty()) return null + + val summaries = matched.map { summarise(it) } + val condLo = (windKt - 5).coerceAtLeast(0.0).toInt() + val condHi = (windKt + 5).toInt() + val dirLabel = cardinalDir(windDirDeg) + + return SimilarTripSummary( + count = matched.size, + avgDistanceNm = summaries.map { it.distanceNm }.average(), + avgDurationHrs = summaries.map { it.durationMs / 3_600_000.0 }.average(), + avgMaxSogKt = summaries.map { it.maxSogKt }.average(), + conditionDescription = "$condLo–$condHi kt $dirLabel" + ) + } + + private fun angleDiff(a: Double, b: Double): Double { + var d = abs(a - b) % 360.0 + if (d > 180.0) d = 360.0 - d + return d + } + + private fun cardinalDir(deg: Double): String { + val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE", + "S","SSW","SW","WSW","W","WNW","NW","NNW") + return dirs[((deg + 11.25) / 22.5).toInt() % 16] + } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt index 9fd32c7..5d4cd14 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt @@ -1,51 +1,77 @@ package org.terst.nav.tripreport import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider 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.LocationService +import org.terst.nav.NavApplication import org.terst.nav.data.model.ForecastItem import org.terst.nav.data.model.MarineConditions sealed class PreTripState { - object Idle : PreTripState() + object Idle : PreTripState() object Loading : PreTripState() data class Success(val report: PreTripReport) : PreTripState() data class Error(val message: String) : PreTripState() } class PreTripReportViewModel( + private val boatRepo: BoatProfileRepository, private val generator: PreTripReportGenerator = PreTripReportGenerator() ) : ViewModel() { private val _state = MutableStateFlow(PreTripState.Idle) val state: StateFlow = _state.asStateFlow() - private val _boatProfile = MutableStateFlow( - BoatProfile("Default Sloop", 35.0, BoatType.MONOHULL, RigType.SLOOP) - ) - val boatProfile: StateFlow = _boatProfile.asStateFlow() + private val _profiles = MutableStateFlow>(emptyList()) + val profiles: StateFlow> = _profiles.asStateFlow() - fun updateBoatProfile(profile: BoatProfile) { - _boatProfile.value = profile + private val _selectedProfile = MutableStateFlow(null) + val selectedProfile: StateFlow = _selectedProfile.asStateFlow() + + init { + val profiles = boatRepo.loadProfiles() + _profiles.value = profiles + _selectedProfile.value = boatRepo.selectedProfile() ?: profiles.firstOrNull() + } + + fun selectProfile(id: String) { + boatRepo.setSelectedId(id) + _selectedProfile.value = _profiles.value.firstOrNull { it.id == id } } - fun generate( - lat: Double, - lon: Double, - forecast: ForecastItem?, - conditions: MarineConditions? - ) { + fun generate(forecastItems: List, conditions: MarineConditions?) { + val profile = _selectedProfile.value ?: return + val pos = LocationService.bestPosition.value + viewModelScope.launch { _state.value = PreTripState.Loading try { - val report = generator.generateReport(lat, lon, forecast, conditions, _boatProfile.value) + val pastTracks = NavApplication.trackRepository.getPastTracks() + val report = generator.generateReport( + lat = pos?.latitude ?: 19.664, // Honokohau fallback + lon = pos?.longitude ?: -156.024, + forecastItems = forecastItems, + conditions = conditions, + boatProfile = profile, + pastTracks = pastTracks + ) _state.value = PreTripState.Success(report) } catch (e: Exception) { - _state.value = PreTripState.Error(e.message ?: "Failed to generate pre-trip report") + _state.value = PreTripState.Error(e.message ?: "Failed to generate report") } } } + + // ── Factory ─────────────────────────────────────────────────────────────── + + class Factory(private val boatRepo: BoatProfileRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + PreTripReportViewModel(boatRepo) as T + } } diff --git a/android-app/app/src/main/res/layout/fragment_pretrip_report.xml b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml index d7ede49..5f3fb67 100644 --- a/android-app/app/src/main/res/layout/fragment_pretrip_report.xml +++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml @@ -1,32 +1,32 @@ - + android:background="?attr/colorBackground"> + android:padding="16dp"> + - + + android:layout_marginBottom="12dp" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + + + + + + + + + + + + + + + + + + + android:text="CONDITIONS WINDOW" + android:textSize="11sp" + android:textAllCaps="true" + android:letterSpacing="0.12" + android:textColor="?attr/colorOnSurfaceVariant" + android:layout_marginBottom="12dp" /> + + + - + + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> + android:padding="16dp"> + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.3" /> - + + + + + + + + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.4" /> - + + + + + + + + android:textAppearance="?attr/textAppearanceBodyMedium" + android:lineSpacingMultiplier="1.5" /> - + + android:layout_height="wrap_content" + android:layout_marginBottom="12dp" + android:visibility="gone" + app:cardElevation="2dp" + app:cardCornerRadius="12dp"> - + + + + + + + + + + + android:visibility="gone" + android:textColor="?attr/colorError" + android:textAppearance="?attr/textAppearanceBodyMedium" /> diff --git a/android-app/app/src/main/res/layout/item_condition_column.xml b/android-app/app/src/main/res/layout/item_condition_column.xml new file mode 100644 index 0000000..1ffcd55 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_condition_column.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + -- cgit v1.2.3