summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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/BoatProfileRepository.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt74
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt209
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt380
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt56
-rw-r--r--android-app/app/src/main/res/layout/fragment_pretrip_report.xml246
-rw-r--r--android-app/app/src/main/res/layout/item_condition_column.xml44
8 files changed, 977 insertions, 171 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 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<List<ProfileJson>>(listType)
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ fun loadProfiles(): List<BoatProfile> {
+ 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<BoatProfile>) {
+ 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<SailJson>,
+ 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<SailConfig> = 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<SailSuggestion>
+ val conditionWindow: List<ConditionSlice>,
+ val routeProjection: RouteProjection,
+ val sailPlan: List<SailSuggestion>,
+ val watchItems: List<WatchItem>,
+ 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<BoatProfile>) {
+ 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<ConditionSlice>) {
+ conditionsTable.removeAllViews()
+ slices.forEach { slice ->
+ val col = LayoutInflater.from(requireContext())
+ .inflate(R.layout.item_condition_column, conditionsTable, false)
+ col.findViewById<TextView>(R.id.col_label).text = slice.label
+ col.findViewById<TextView>(R.id.col_wind).text = "%.0f kt".format(slice.windKt)
+ col.findViewById<TextView>(R.id.col_wind_dir).text = cardinalDir(slice.windDirDeg)
+ col.findViewById<TextView>(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<SailSuggestion>) {
+ tvSailPlan.text = plan.joinToString("\n") { "• ${it.sailName}: ${it.action}" }
+ cardSailPlan.visibility = View.VISIBLE
+ }
+
+ private fun renderWatchlist(items: List<WatchItem>) {
+ 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<ForecastItem>,
conditions: MarineConditions?,
- boatProfile: BoatProfile
+ boatProfile: BoatProfile,
+ pastTracks: List<List<TrackPoint>> = 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<ForecastItem>): List<ConditionSlice> {
+ 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<SailSuggestion> {
- 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<ForecastItem>,
+ boatProfile: BoatProfile
+ ): List<SailSuggestion> {
val suggestions = mutableListOf<SailSuggestion>()
- // 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<ForecastItem>,
+ 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<ForecastItem>,
+ boatProfile: BoatProfile
+ ): List<WatchItem> {
+ val items = mutableListOf<WatchItem>()
+ 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<List<TrackPoint>>,
+ 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>(PreTripState.Idle)
val state: StateFlow<PreTripState> = _state.asStateFlow()
- private val _boatProfile = MutableStateFlow(
- BoatProfile("Default Sloop", 35.0, BoatType.MONOHULL, RigType.SLOOP)
- )
- val boatProfile: StateFlow<BoatProfile> = _boatProfile.asStateFlow()
+ private val _profiles = MutableStateFlow<List<BoatProfile>>(emptyList())
+ val profiles: StateFlow<List<BoatProfile>> = _profiles.asStateFlow()
- fun updateBoatProfile(profile: BoatProfile) {
- _boatProfile.value = profile
+ private val _selectedProfile = MutableStateFlow<BoatProfile?>(null)
+ val selectedProfile: StateFlow<BoatProfile?> = _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<ForecastItem>, 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 <T : ViewModel> create(modelClass: Class<T>): 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 @@
<?xml version="1.0" encoding="utf-8"?>
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="?attr/colorSurface">
+ android:background="?attr/colorBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:padding="24dp">
+ android:padding="16dp">
+ <!-- ── Title ──────────────────────────────────────────────── -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Pre-Trip Planning"
- android:textSize="24sp"
- android:textStyle="bold"
+ android:text="Departure Briefing"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:layout_marginBottom="16dp" />
- <!-- Boat Config (Simple for now) -->
+ <!-- ── Boat selector ──────────────────────────────────────── -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="24dp"
- app:cardCornerRadius="12dp"
- app:strokeWidth="1dp"
- app:strokeColor="?attr/colorOutlineVariant">
+ android:layout_marginBottom="12dp"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -37,108 +37,236 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Vessel Profile"
- android:textStyle="bold"
+ android:text="VESSEL"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_boats"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Generate button + progress ─────────────────────────── -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="16dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_pretrip"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Refresh Briefing" />
+
+ <ProgressBar
+ android:id="@+id/progress_pretrip"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_marginStart="12dp"
+ android:visibility="gone" />
+ </LinearLayout>
+
+ <!-- ── Conditions window ───────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_conditions"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
<TextView
- android:id="@+id/tv_vessel_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="35ft Sloop (Monohull)"
- android:textSize="14sp" />
+ android:text="CONDITIONS WINDOW"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="12dp" />
+
+ <!-- 4-column table built programmatically -->
+ <LinearLayout
+ android:id="@+id/conditions_table"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
- <!-- Report Content -->
+ <!-- ── Route projection ────────────────────────────────────── -->
<com.google.android.material.card.MaterialCardView
- android:id="@+id/card_report"
+ android:id="@+id/card_route"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
android:visibility="gone"
- app:cardCornerRadius="16dp"
- app:cardElevation="4dp">
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:padding="20dp">
+ android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Weather Summary"
- android:textStyle="bold"
- android:textSize="18sp"
+ android:text="ROUTE PROJECTION"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<TextView
- android:id="@+id/tv_weather_summary"
+ android:id="@+id/tv_route"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:textSize="16sp"
- android:layout_marginBottom="16dp" />
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.3" />
- <View
- android:layout_width="match_parent"
- android:layout_height="1dp"
- android:background="?attr/colorOutlineVariant"
- android:layout_marginBottom="16dp" />
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Sail plan ───────────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_sail_plan"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Routing Suggestion"
- android:textStyle="bold"
- android:textSize="18sp"
+ android:text="SAIL PLAN"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<TextView
- android:id="@+id/tv_routing_content"
+ android:id="@+id/tv_sail_plan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:textSize="16sp"
- android:layout_marginBottom="16dp" />
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.4" />
- <View
- android:layout_width="match_parent"
- android:layout_height="1dp"
- android:background="?attr/colorOutlineVariant"
- android:layout_marginBottom="16dp" />
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Watch list ──────────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_watchlist"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp"
+ app:strokeColor="?attr/colorError"
+ app:strokeWidth="1dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Sail Plan"
- android:textStyle="bold"
- android:textSize="18sp"
+ android:text="WATCH FOR"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<TextView
- android:id="@+id/tv_sail_plan_content"
+ android:id="@+id/tv_watchlist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:textSize="16sp" />
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.5" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_generate_pretrip"
+ <!-- ── Similar trips ───────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_similar"
android:layout_width="match_parent"
- android:layout_height="60dp"
- android:layout_marginTop="24dp"
- android:text="GENERATE PRE-TRIP REPORT" />
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
- <ProgressBar
- android:id="@+id/progress_pretrip"
- android:layout_width="wrap_content"
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="SIMILAR TRIPS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_similar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.3" />
+
+ </LinearLayout>
+ </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:layout_gravity="center"
- android:layout_marginTop="16dp"
- android:visibility="gone" />
+ android:visibility="gone"
+ android:textColor="?attr/colorError"
+ android:textAppearance="?attr/textAppearanceBodyMedium" />
</LinearLayout>
</ScrollView>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center_horizontal"
+ android:padding="4dp">
+
+ <TextView
+ android:id="@+id/col_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="10sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/col_wind"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:layout_marginBottom="2dp" />
+
+ <TextView
+ android:id="@+id/col_wind_dir"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/col_sky"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="10sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:gravity="center" />
+
+</LinearLayout>