From 4a2d0298ab2caa3d62cfbd54c0071ae47eb89ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 02:27:51 +0000 Subject: Four features: outbound link markers, offline content, log text/photo, departure picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learn tab - ic_open_in_new.xml: external link icon (box + arrow) applied to all browser-opening cards - Migration guide cards retain internal › chevron; ASA/Flashcards cards show ↗ icon - New REFERENCE section (offline, works without connectivity): - ColRegs Rules of the Road → colregs_reference.md (rules 1–38, lights table, sound signals, day shapes, memory aids) - Sailing Quick Reference → sailing_reference.md (points of sail, Beaufort scale, nav lights, knots, buoyage IALA-B, VHF channels, distress signals, tide rule of 12) - ColRegs card moved from external ASA section to offline REFERENCE section Log entry - LogEntry: add photoPath field (absolute file path or content URI string) - VoiceLogViewModel: replace confirmAndSave() with save(text, photoPath?) so the fragment controls text (user may edit recognized speech before saving) - VoiceLogFragment: redesigned layout with EditText (editable, voice fills it), camera button (TakePicturePreview → JPEG in cacheDir), gallery button (GetContent), photo thumbnail with remove button, Save / Clear row - Manifest: add android.hardware.camera uses-feature (required=false) Departure date/time picker (trip planning) - ic_calendar.xml: calendar icon for the picker button - PreTripReportViewModel: _departureMs StateFlow (default = now), setDeparture(ms) - PreTripReportGenerator.generateReport(): departureDateTimeMs param; findDepartureSlot() matches nearest UTC forecast item; condition window labels show actual local times (e.g. "2 PM") when departure is not near-now; buildWatchList uses departure hour for Kona trades warning instead of system clock - fragment_pretrip_report.xml: DEPART card with label + calendar button above generate - PreTripReportFragment: MaterialDatePicker (future dates only) → MaterialTimePicker chain; auto-regenerates after picker confirms https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../main/kotlin/org/terst/nav/logbook/LogEntry.kt | 3 +- .../org/terst/nav/logbook/VoiceLogViewModel.kt | 14 +- .../terst/nav/tripreport/PreTripReportFragment.kt | 122 +++++++++++++--- .../terst/nav/tripreport/PreTripReportGenerator.kt | 116 ++++++++++----- .../terst/nav/tripreport/PreTripReportViewModel.kt | 20 ++- .../kotlin/org/terst/nav/ui/learn/LearnFragment.kt | 11 +- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 162 +++++++++++++++------ 7 files changed, 330 insertions(+), 118 deletions(-) (limited to 'android-app/app/src/main/kotlin/org/terst') 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() { @@ -90,20 +103,81 @@ class PreTripReportFragment : Fragment() { if (profile != null) syncChipSelection(profile.id) } } + 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) { 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(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) + 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 @@ -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> = 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, 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): List { - val labels = listOf("Now", "+1 h", "+2 h", "+3 h") + private fun buildConditionWindow( + items: List, + departureDateTimeMs: Long + ): List { + 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 { val suggestions = mutableListOf() - // 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, - boatProfile: BoatProfile + boatProfile: BoatProfile, + departureDateTimeMs: Long = System.currentTimeMillis() ): 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)) @@ -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(null) val selectedProfile: StateFlow = _selectedProfile.asStateFlow() + private val _departureMs = MutableStateFlow(System.currentTimeMillis()) + val departureMs: StateFlow = _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, 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(R.id.card_colregs).setOnClickListener { + openDoc("docs/colregs_reference.md") + } + view.findViewById(R.id.card_sailing_reference).setOnClickListener { + openDoc("docs/sailing_reference.md") + } + // ASA / external links — open in browser view.findViewById(R.id.card_asa_courses).setOnClickListener { openUrl("https://www.asa.com/courses/") @@ -35,9 +43,6 @@ class LearnFragment : Fragment() { view.findViewById(R.id.card_asa_online).setOnClickListener { openUrl("https://www.asa.com/online-courses/") } - view.findViewById(R.id.card_colregs).setOnClickListener { - openUrl("https://www.navcen.uscg.gov/international-regulations-for-preventing-collisions-at-sea") - } view.findViewById(R.id.card_flashcards).setOnClickListener { openUrl("https://quizlet.com/subject/sailing/") } 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, grantResults: IntArray ) { - if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + if (requestCode == RC_AUDIO + && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED + ) { startListening() } } -- cgit v1.2.3