summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org/terst')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt122
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt116
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt162
9 files changed, 348 insertions, 122 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 996892e..e6355bd 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -296,6 +296,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun hideOverlays() {
fragmentContainer.visibility = View.GONE
fabRecordTrack.visibility = View.VISIBLE
+ // Re-apply cached conditions in case they arrived while the overlay was covering the sheet
+ lastConditions?.let { applyConditions(it) }
}
override fun onQuitRequested() {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt
index 17cebfb..c038547 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt
@@ -8,5 +8,6 @@ data class LogEntry(
val text: String,
val entryType: EntryType,
val lat: Double? = null,
- val lon: Double? = null
+ val lon: Double? = null,
+ val photoPath: String? = null // absolute file path or content URI string
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
index 067cbaf..0a68ba8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
@@ -20,12 +20,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
_state.value = VoiceLogState.Error(message)
}
- fun confirmAndSave() {
- val current = _state.value as? VoiceLogState.Result ?: return
+ /**
+ * Save an entry with the given text and optional photo path.
+ * Called directly by the fragment from the Save button so the user
+ * can have edited the recognized text in the EditText beforehand.
+ */
+ fun save(text: String, photoPath: String? = null) {
+ if (text.isBlank() && photoPath == null) return
val entry = LogEntry(
timestampMs = System.currentTimeMillis(),
- text = current.recognized,
- entryType = EntryType.GENERAL
+ text = text.ifBlank { "(photo only)" },
+ entryType = EntryType.GENERAL,
+ photoPath = photoPath
)
val saved = repository.save(entry)
_state.value = VoiceLogState.Saved(saved)
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 c88dc48..ac1350d 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
@@ -11,13 +11,21 @@ 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 com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
+import com.google.android.material.datepicker.CalendarConstraints
+import com.google.android.material.datepicker.DateValidatorPointForward
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
import kotlinx.coroutines.launch
import org.terst.nav.NavApplication
import org.terst.nav.R
import org.terst.nav.ui.MainViewModel
+import java.text.SimpleDateFormat
+import java.util.Calendar
import java.util.Locale
class PreTripReportFragment : Fragment() {
@@ -26,7 +34,9 @@ class PreTripReportFragment : Fragment() {
private lateinit var viewModel: PreTripReportViewModel
private lateinit var chipGroupBoats: ChipGroup
- private lateinit var btnGenerate: com.google.android.material.button.MaterialButton
+ private lateinit var tvDepartureTime: TextView
+ private lateinit var btnPickDeparture: MaterialButton
+ private lateinit var btnGenerate: MaterialButton
private lateinit var progress: ProgressBar
private lateinit var cardConditions: MaterialCardView
private lateinit var conditionsTable: LinearLayout
@@ -40,6 +50,8 @@ class PreTripReportFragment : Fragment() {
private lateinit var tvSimilar: TextView
private lateinit var tvError: TextView
+ private val departureSdf = SimpleDateFormat("EEE MMM d, h:mm a", Locale.getDefault())
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_pretrip_report, container, false)
@@ -47,7 +59,6 @@ class PreTripReportFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- // Construct ViewModel with the application-level repo
viewModel = ViewModelProvider(
requireActivity(),
PreTripReportViewModel.Factory(NavApplication.boatProfileRepository)
@@ -57,28 +68,30 @@ class PreTripReportFragment : Fragment() {
observeViewModel()
btnGenerate.setOnClickListener { triggerGenerate() }
+ btnPickDeparture.setOnClickListener { showDeparturePicker() }
- // 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)
+ chipGroupBoats = view.findViewById(R.id.chip_group_boats)
+ tvDepartureTime = view.findViewById(R.id.tv_departure_time)
+ btnPickDeparture = view.findViewById(R.id.btn_pick_departure)
+ 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() {
@@ -91,19 +104,80 @@ class PreTripReportFragment : Fragment() {
}
}
viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.departureMs.collect { ms -> renderDepartureLabel(ms) }
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { renderState(it) }
}
}
+ // ── Departure picker ──────────────────────────────────────────────────────
+
+ private fun showDeparturePicker() {
+ val currentMs = viewModel.departureMs.value
+
+ // Constrain to today or future
+ val constraints = CalendarConstraints.Builder()
+ .setValidator(DateValidatorPointForward.now())
+ .build()
+
+ val datePicker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText("Departure date")
+ .setSelection(currentMs)
+ .setCalendarConstraints(constraints)
+ .build()
+
+ datePicker.addOnPositiveButtonClickListener { dateMs ->
+ // dateMs is midnight UTC of the selected date; add current time-of-day offset
+ val cal = Calendar.getInstance().apply { timeInMillis = dateMs }
+ // Default to current hour of day, rounded to nearest half-hour
+ val now = Calendar.getInstance()
+ cal.set(Calendar.HOUR_OF_DAY, now.get(Calendar.HOUR_OF_DAY))
+ cal.set(Calendar.MINUTE, 0)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+
+ val timePicker = MaterialTimePicker.Builder()
+ .setTitleText("Departure time")
+ .setTimeFormat(TimeFormat.CLOCK_12H)
+ .setHour(cal.get(Calendar.HOUR_OF_DAY))
+ .setMinute(0)
+ .build()
+
+ timePicker.addOnPositiveButtonClickListener {
+ cal.set(Calendar.HOUR_OF_DAY, timePicker.hour)
+ cal.set(Calendar.MINUTE, timePicker.minute)
+ viewModel.setDeparture(cal.timeInMillis)
+ // Re-generate with new departure time
+ if (mainViewModel.forecast.value.isNotEmpty()) triggerGenerate()
+ }
+
+ timePicker.show(childFragmentManager, "time_picker")
+ }
+
+ datePicker.show(childFragmentManager, "date_picker")
+ }
+
+ private fun renderDepartureLabel(ms: Long) {
+ val now = System.currentTimeMillis()
+ tvDepartureTime.text = if (abs(ms - now) < 3_600_000L) {
+ "Now"
+ } else {
+ departureSdf.format(ms)
+ }
+ }
+
+ // ── Boat chips ────────────────────────────────────────────────────────────
+
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
+ text = profile.name
isCheckable = true
isChecked = (profile.id == selectedId)
- tag = profile.id
+ tag = profile.id
}
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
@@ -168,10 +242,10 @@ class PreTripReportFragment : Fragment() {
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)
+ 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
@@ -218,4 +292,6 @@ class PreTripReportFragment : Fragment() {
"S","SSW","SW","WSW","W","WNW","NW","NNW")
return dirs[((deg + 11.25) / 22.5).toInt() % 16]
}
+
+ private fun abs(x: Long) = if (x < 0) -x else x
}
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 2508d44..2840d76 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
@@ -4,7 +4,10 @@ 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.text.SimpleDateFormat
import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
import kotlin.math.*
class PreTripReportGenerator {
@@ -12,13 +15,14 @@ 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)
+ * @param lat Current position latitude
+ * @param lon Current position longitude
+ * @param forecastItems Hourly forecast list (168 slots)
+ * @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)
+ * @param departureDateTimeMs Unix millis for planned departure (default = now)
*/
fun generateReport(
lat: Double,
@@ -27,14 +31,19 @@ class PreTripReportGenerator {
conditions: MarineConditions?,
boatProfile: BoatProfile,
pastTracks: List<List<TrackPoint>> = emptyList(),
- durationHrs: Double = 3.0
+ durationHrs: Double = 3.0,
+ departureDateTimeMs: Long = System.currentTimeMillis()
): PreTripReport {
- val current = forecastItems.firstOrNull()
+ // Find the forecast slot nearest to the planned departure time
+ val startIndex = findDepartureSlot(forecastItems, departureDateTimeMs)
+ val window = forecastItems.drop(startIndex).take(4)
+
+ val current = window.firstOrNull()
val windKt = current?.windKt ?: 0.0
val windDir = current?.windDirDeg ?: 0.0
val summary = PreTripSummary(
- timestampMs = System.currentTimeMillis(),
+ timestampMs = departureDateTimeMs,
lat = lat,
lon = lon,
windSpeedKt = windKt,
@@ -46,27 +55,71 @@ class PreTripReportGenerator {
return PreTripReport(
summary = summary,
- conditionWindow = buildConditionWindow(forecastItems),
+ conditionWindow = buildConditionWindow(window, departureDateTimeMs),
routeProjection = projectRoute(windDir, windKt,
conditions?.currentSpeedKt ?: 0.0,
conditions?.currentDirDeg ?: 0.0,
durationHrs, boatProfile),
- sailPlan = suggestSailPlan(windKt, forecastItems, boatProfile),
- watchItems = buildWatchList(conditions, forecastItems, boatProfile),
+ sailPlan = suggestSailPlan(windKt, window, boatProfile),
+ watchItems = buildWatchList(conditions, window, boatProfile, departureDateTimeMs),
similarTrips = findSimilarTrips(pastTracks, windKt, windDir)
)
}
+ // ── Departure slot lookup ─────────────────────────────────────────────────
+
+ /**
+ * Finds the index of the forecast item closest to [departureMs].
+ * Open-Meteo returns times as UTC ISO strings ("yyyy-MM-dd'T'HH:mm").
+ */
+ private fun findDepartureSlot(items: List<ForecastItem>, departureMs: Long): Int {
+ if (items.isEmpty()) return 0
+ val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+
+ var bestIdx = 0
+ var bestDiff = Long.MAX_VALUE
+ items.forEachIndexed { idx, item ->
+ try {
+ val itemMs = sdf.parse(item.timeIso)?.time ?: return@forEachIndexed
+ val diff = abs(itemMs - departureMs)
+ if (diff < bestDiff) {
+ bestDiff = diff
+ bestIdx = idx
+ }
+ } catch (_: Exception) {}
+ }
+ // Leave at least 4 items for the condition window
+ return bestIdx.coerceAtMost((items.size - 4).coerceAtLeast(0))
+ }
+
// ── Condition window ──────────────────────────────────────────────────────
- private fun buildConditionWindow(items: List<ForecastItem>): List<ConditionSlice> {
- val labels = listOf("Now", "+1 h", "+2 h", "+3 h")
+ private fun buildConditionWindow(
+ items: List<ForecastItem>,
+ departureDateTimeMs: Long
+ ): List<ConditionSlice> {
+ val isNearNow = abs(departureDateTimeMs - System.currentTimeMillis()) < 3_600_000L
+ val utcSdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US).also {
+ it.timeZone = TimeZone.getTimeZone("UTC")
+ }
+ // Display times in device local time (e.g. "2 PM")
+ val displaySdf = SimpleDateFormat("h a", Locale.getDefault())
+
return items.take(4).mapIndexed { i, item ->
+ val label = when {
+ isNearNow && i == 0 -> "Now"
+ isNearNow -> "+${i}h"
+ else -> try {
+ val t = utcSdf.parse(item.timeIso)
+ if (t != null) displaySdf.format(t) else "+${i}h"
+ } catch (_: Exception) { "+${i}h" }
+ }
ConditionSlice(
- label = labels.getOrElse(i) { "+${i} h" },
+ label = label,
windKt = item.windKt,
windDirDeg = item.windDirDeg,
- waveHeightM = null, // marine API doesn't give hourly per slot here; use conditions
+ waveHeightM = null,
weatherDescription = item.weatherDescription()
)
}
@@ -94,7 +147,6 @@ class PreTripReportGenerator {
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
@@ -109,20 +161,17 @@ class PreTripReportGenerator {
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 adds %.1f kt outbound.".format(currentComponent)
currentComponent < -0.15 -> "Current opposes %.1f kt outbound.".format(-currentComponent)
else -> ""
}
@@ -203,20 +252,16 @@ class PreTripReportGenerator {
): List<SailSuggestion> {
val suggestions = mutableListOf<SailSuggestion>()
- // 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"
@@ -225,7 +270,6 @@ class PreTripReportGenerator {
}
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
@@ -250,7 +294,6 @@ class PreTripReportGenerator {
): 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"
@@ -270,12 +313,12 @@ class PreTripReportGenerator {
private fun buildWatchList(
conditions: MarineConditions?,
forecastItems: List<ForecastItem>,
- boatProfile: BoatProfile
+ boatProfile: BoatProfile,
+ departureDateTimeMs: Long = System.currentTimeMillis()
): 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))
@@ -285,7 +328,6 @@ class PreTripReportGenerator {
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) {
@@ -293,25 +335,23 @@ class PreTripReportGenerator {
"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)
+ // Use departure hour for time-of-day trade warnings
+ val departureHour = Calendar.getInstance().also { it.timeInMillis = departureDateTimeMs }
+ .get(Calendar.HOUR_OF_DAY)
+ if (departureHour in 11..14)
items += WatchItem("ℹ️", "Departing midday — Kona trades typically build 5–8 kt through afternoon")
- else if (hour >= 15)
+ else if (departureHour >= 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)
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 5d4cd14..64a739a 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
@@ -33,6 +33,9 @@ class PreTripReportViewModel(
private val _selectedProfile = MutableStateFlow<BoatProfile?>(null)
val selectedProfile: StateFlow<BoatProfile?> = _selectedProfile.asStateFlow()
+ private val _departureMs = MutableStateFlow(System.currentTimeMillis())
+ val departureMs: StateFlow<Long> = _departureMs.asStateFlow()
+
init {
val profiles = boatRepo.loadProfiles()
_profiles.value = profiles
@@ -44,6 +47,10 @@ class PreTripReportViewModel(
_selectedProfile.value = _profiles.value.firstOrNull { it.id == id }
}
+ fun setDeparture(ms: Long) {
+ _departureMs.value = ms
+ }
+
fun generate(forecastItems: List<ForecastItem>, conditions: MarineConditions?) {
val profile = _selectedProfile.value ?: return
val pos = LocationService.bestPosition.value
@@ -53,12 +60,13 @@ class PreTripReportViewModel(
try {
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
+ lat = pos?.latitude ?: 19.664, // Honokohau fallback
+ lon = pos?.longitude ?: -156.024,
+ forecastItems = forecastItems,
+ conditions = conditions,
+ boatProfile = profile,
+ pastTracks = pastTracks,
+ departureDateTimeMs = _departureMs.value
)
_state.value = PreTripState.Success(report)
} catch (e: Exception) {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt
index 8440edb..da3f020 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt
@@ -28,6 +28,14 @@ class LearnFragment : Fragment() {
openDoc("docs/migrate_sea_people.md")
}
+ // Reference — offline docs
+ view.findViewById<MaterialCardView>(R.id.card_colregs).setOnClickListener {
+ openDoc("docs/colregs_reference.md")
+ }
+ view.findViewById<MaterialCardView>(R.id.card_sailing_reference).setOnClickListener {
+ openDoc("docs/sailing_reference.md")
+ }
+
// ASA / external links — open in browser
view.findViewById<MaterialCardView>(R.id.card_asa_courses).setOnClickListener {
openUrl("https://www.asa.com/courses/")
@@ -35,9 +43,6 @@ class LearnFragment : Fragment() {
view.findViewById<MaterialCardView>(R.id.card_asa_online).setOnClickListener {
openUrl("https://www.asa.com/online-courses/")
}
- view.findViewById<MaterialCardView>(R.id.card_colregs).setOnClickListener {
- openUrl("https://www.navcen.uscg.gov/international-regulations-for-preventing-collisions-at-sea")
- }
view.findViewById<MaterialCardView>(R.id.card_flashcards).setOnClickListener {
openUrl("https://quizlet.com/subject/sailing/")
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
index 24b25db..26d9ea2 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
@@ -8,6 +8,7 @@ import android.util.AttributeSet
import android.view.View
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
+import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@@ -38,10 +39,12 @@ class ParticleWindView @JvmOverloads constructor(
private var windSpeedKt = 0.0
private val N = 300
+ private var activeN = 150 // wind-speed-dependent; updated in setWind()
private val particleLat = FloatArray(N)
private val particleLon = FloatArray(N)
private val particleAge = FloatArray(N)
- private val MAX_AGE = 8f // seconds before forced respawn
+ private val MAX_AGE = 25f // seconds before forced respawn
+ private var lastLatRange = 0f // for zoom-out scatter detection
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3.5f
@@ -62,6 +65,8 @@ class ParticleWindView @JvmOverloads constructor(
fun setWind(dirFromDeg: Double, speedKt: Double) {
windDirFromDeg = dirFromDeg
windSpeedKt = speedKt
+ // Scale particle count: 60 at calm → 300 at 25+ kt
+ activeN = (60 + (speedKt.coerceIn(0.0, 25.0) / 25.0 * 240)).toInt()
}
// ── Rendering ────────────────────────────────────────────────────────────
@@ -86,6 +91,11 @@ class ParticleWindView @JvmOverloads constructor(
val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
val latRange = latNorth - latSouth
+ // If the user zoomed out significantly, immediately redistribute particles
+ // across the new viewport rather than waiting for them to drift to the edges.
+ if (lastLatRange > 0f && latRange > lastLatRange * 1.8f) scatter(m)
+ lastLatRange = latRange
+
// West edge = left screen side; east edge = right screen side.
// Using screen-ordered corners handles antimeridian crossing correctly.
val lonWest = minOf(nL.longitude, fL.longitude).toFloat()
@@ -109,7 +119,7 @@ class ParticleWindView @JvmOverloads constructor(
val tailDx = sin(screenRad).toFloat() * TAIL_PX
val tailDy = (-cos(screenRad)).toFloat() * TAIL_PX
- for (i in 0 until N) {
+ for (i in 0 until activeN) {
particleLat[i] += dlat
particleLon[i] += dlon
// Wrap longitude into [-180, 180] after movement
@@ -137,7 +147,9 @@ class ParticleWindView @JvmOverloads constructor(
LatLng(particleLat[i].toDouble(), particleLon[i].toDouble())
)
- val alpha = ((1f - particleAge[i] / MAX_AGE) * 220).toInt().coerceIn(40, 220)
+ // Sine curve: smooth fade-in from birth, peak at mid-life, smooth fade-out.
+ // No bright birth flash — particles ease in and ease out.
+ val alpha = (sin(PI * particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(15, 200)
paint.color = Color.argb(alpha, 30, 100, 255)
canvas.drawLine(pt.x - tailDx, pt.y - tailDy, pt.x, pt.y, paint)
@@ -170,7 +182,7 @@ class ParticleWindView @JvmOverloads constructor(
val lonEast = maxOf(nR.longitude, fR.longitude).toFloat()
val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f
- for (i in 0 until N) {
+ for (i in 0 until activeN) {
particleLat[i] = latSouth + Random.nextFloat() * latRange
var newLon = lonWest + Random.nextFloat() * lonSpan
if (newLon > 180f) newLon -= 360f
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 1c797d5..ab30d96 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
@@ -3,6 +3,8 @@ package org.terst.nav.ui.voicelog
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.net.Uri
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
@@ -10,37 +12,75 @@ import android.speech.SpeechRecognizer
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.Button
-import android.widget.LinearLayout
+import android.widget.FrameLayout
+import android.widget.ImageView
import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
+import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.launch
import org.terst.nav.R
-import org.terst.nav.logbook.InMemoryLogbookRepository
import org.terst.nav.logbook.VoiceLogState
import org.terst.nav.logbook.VoiceLogViewModel
+import java.io.File
+import java.io.FileOutputStream
import java.util.Locale
class VoiceLogFragment : Fragment() {
- private lateinit var speechRecognizer: SpeechRecognizer
private val viewModel by lazy {
VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
}
- private lateinit var tvStatus: TextView
- private lateinit var tvRecognized: TextView
+ private lateinit var speechRecognizer: SpeechRecognizer
+ private lateinit var etNote: TextInputEditText
private lateinit var fabMic: FloatingActionButton
- private lateinit var llConfirm: LinearLayout
- private lateinit var btnSave: Button
- private lateinit var btnRetry: Button
+ private lateinit var btnCamera: MaterialButton
+ private lateinit var btnGallery: MaterialButton
+ private lateinit var tvStatus: TextView
+ private lateinit var framePhoto: FrameLayout
+ private lateinit var ivPhoto: ImageView
+ private lateinit var btnRemovePhoto: MaterialButton
+ private lateinit var btnSave: MaterialButton
+ private lateinit var btnClear: MaterialButton
private lateinit var tvSavedConfirmation: TextView
private lateinit var btnGenerateReport: MaterialButton
+ /** Path or URI string for the currently attached photo, null if none. */
+ private var pendingPhotoPath: String? = null
+
+ // ── Camera (TakePicturePreview returns a thumbnail bitmap — no FileProvider needed) ──
+
+ private val cameraLauncher = registerForActivityResult(
+ ActivityResultContracts.TakePicturePreview()
+ ) { bitmap: Bitmap? ->
+ if (bitmap != null) {
+ val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg")
+ try {
+ FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
+ setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) }
+ } catch (_: Exception) {
+ tvStatus.text = "Couldn't save photo"
+ }
+ }
+ }
+
+ // ── Gallery (GetContent — no storage permission needed; URI valid for this session) ──
+
+ private val galleryLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) }
+ }
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -50,20 +90,33 @@ class VoiceLogFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- tvStatus = view.findViewById(R.id.tv_status)
- tvRecognized = view.findViewById(R.id.tv_recognized)
- fabMic = view.findViewById(R.id.fab_mic)
- llConfirm = view.findViewById(R.id.ll_confirm_buttons)
- btnSave = view.findViewById(R.id.btn_save)
- btnRetry = view.findViewById(R.id.btn_retry)
+ etNote = view.findViewById(R.id.et_note)
+ fabMic = view.findViewById(R.id.fab_mic)
+ btnCamera = view.findViewById(R.id.btn_camera)
+ btnGallery = view.findViewById(R.id.btn_gallery)
+ tvStatus = view.findViewById(R.id.tv_status)
+ framePhoto = view.findViewById(R.id.frame_photo)
+ ivPhoto = view.findViewById(R.id.iv_photo)
+ btnRemovePhoto = view.findViewById(R.id.btn_remove_photo)
+ btnSave = view.findViewById(R.id.btn_save)
+ btnClear = view.findViewById(R.id.btn_clear)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
- btnGenerateReport = view.findViewById(R.id.btn_generate_report)
+ btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
- btnSave.setOnClickListener { viewModel.confirmAndSave() }
- btnRetry.setOnClickListener { viewModel.retry() }
+ btnCamera.setOnClickListener { cameraLauncher.launch(null) }
+ btnGallery.setOnClickListener { galleryLauncher.launch("image/*") }
+ btnRemovePhoto.setOnClickListener { clearPhoto() }
+
+ btnSave.setOnClickListener {
+ val text = etNote.text?.toString()?.trim() ?: ""
+ viewModel.save(text, pendingPhotoPath)
+ }
+
+ btnClear.setOnClickListener { clearEntry() }
+
btnGenerateReport.setOnClickListener {
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
@@ -76,18 +129,20 @@ class VoiceLogFragment : Fragment() {
}
}
+ // ── Speech ────────────────────────────────────────────────────────────────
+
private fun setupSpeechRecognizer() {
if (!SpeechRecognizer.isRecognitionAvailable(requireContext())) {
- tvStatus.text = "Speech recognition not available"
fabMic.isEnabled = false
+ tvStatus.text = "Speech recognition unavailable"
return
}
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext())
speechRecognizer.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() }
override fun onResults(results: Bundle?) {
- val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
- val text = matches?.firstOrNull() ?: ""
+ val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ ?.firstOrNull() ?: ""
if (text.isNotBlank()) viewModel.onSpeechRecognized(text)
else viewModel.onRecognitionError("Could not understand speech")
}
@@ -95,11 +150,11 @@ class VoiceLogFragment : Fragment() {
viewModel.onRecognitionError("Recognition error: $error")
}
override fun onBeginningOfSpeech() {}
- override fun onBufferReceived(buffer: ByteArray?) {}
+ override fun onBufferReceived(b: ByteArray?) {}
override fun onEndOfSpeech() {}
- override fun onEvent(eventType: Int, params: Bundle?) {}
- override fun onPartialResults(partialResults: Bundle?) {}
- override fun onRmsChanged(rmsdB: Float) {}
+ override fun onEvent(t: Int, p: Bundle?) {}
+ override fun onPartialResults(p: Bundle?) {}
+ override fun onRmsChanged(r: Float) {}
})
}
@@ -119,38 +174,57 @@ class VoiceLogFragment : Fragment() {
speechRecognizer.startListening(intent)
}
+ // ── Photo helpers ─────────────────────────────────────────────────────────
+
+ private fun setPhoto(path: String, applyImage: () -> Unit) {
+ pendingPhotoPath = path
+ applyImage()
+ framePhoto.visibility = View.VISIBLE
+ }
+
+ private fun clearPhoto() {
+ pendingPhotoPath = null
+ ivPhoto.setImageDrawable(null)
+ framePhoto.visibility = View.GONE
+ }
+
+ private fun clearEntry() {
+ etNote.setText("")
+ clearPhoto()
+ viewModel.retry()
+ tvSavedConfirmation.text = ""
+ tvStatus.text = ""
+ }
+
+ // ── State rendering ───────────────────────────────────────────────────────
+
private fun renderState(state: VoiceLogState) {
when (state) {
is VoiceLogState.Idle -> {
- tvStatus.text = "Tap microphone to log"
- tvRecognized.text = ""
- llConfirm.visibility = View.GONE
- tvSavedConfirmation.text = ""
+ tvStatus.text = ""
fabMic.isEnabled = true
}
is VoiceLogState.Listening -> {
tvStatus.text = "Listening…"
- tvRecognized.text = ""
- llConfirm.visibility = View.GONE
fabMic.isEnabled = false
}
is VoiceLogState.Result -> {
- tvStatus.text = "Recognized:"
- tvRecognized.text = state.recognized
- llConfirm.visibility = View.VISIBLE
- fabMic.isEnabled = false
+ // Fill the text field; user can edit before saving
+ etNote.setText(state.recognized)
+ etNote.setSelection(state.recognized.length)
+ tvStatus.text = ""
+ fabMic.isEnabled = true
}
is VoiceLogState.Saved -> {
- tvStatus.text = "Saved!"
- tvRecognized.text = state.entry.text
- tvSavedConfirmation.text = "[${state.entry.entryType}] entry saved"
- llConfirm.visibility = View.GONE
+ val photoNote = if (state.entry.photoPath != null) " + photo" else ""
+ tvSavedConfirmation.text = "Saved: ${state.entry.text.take(60)}$photoNote"
+ etNote.setText("")
+ clearPhoto()
fabMic.isEnabled = true
+ tvStatus.text = ""
}
is VoiceLogState.Error -> {
- tvStatus.text = "Error: ${state.message}"
- tvRecognized.text = ""
- llConfirm.visibility = View.GONE
+ tvStatus.text = state.message
fabMic.isEnabled = true
}
}
@@ -162,7 +236,9 @@ class VoiceLogFragment : Fragment() {
permissions: Array<out String>,
grantResults: IntArray
) {
- if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ if (requestCode == RC_AUDIO
+ && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
+ ) {
startListening()
}
}