From ca57e40adc0b89e7dc5409475f7510c0c188d715 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 18:09:53 +0000 Subject: feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/main/res/drawable/ic_track_record.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 android-app/app/src/main/res/drawable/ic_track_record.xml (limited to 'android-app/app/src/main/res/drawable') diff --git a/android-app/app/src/main/res/drawable/ic_track_record.xml b/android-app/app/src/main/res/drawable/ic_track_record.xml new file mode 100644 index 0000000..9016369 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_track_record.xml @@ -0,0 +1,17 @@ + + + + + + + -- cgit v1.2.3 From d98b441f2f9ca8b11a04406240dd19ecc0cac7ab Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 6 Apr 2026 18:03:39 +0000 Subject: feat(nav): replace Map+Instruments with Map+Layers in bottom nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers acts as an action button — shows LayerPickerSheet and snaps back to Map so it never stays selected. Instruments tab removed; sheet expand/collapse via swipe as before. Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/main/kotlin/org/terst/nav/MainActivity.kt | 15 +++++++++++---- android-app/app/src/main/res/drawable/ic_layers.xml | 9 +++++++++ android-app/app/src/main/res/menu/bottom_nav_menu.xml | 6 +++--- 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 android-app/app/src/main/res/drawable/ic_layers.xml (limited to 'android-app/app/src/main/res/drawable') 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 de1f4dd..0309364 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 @@ -130,10 +130,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED true } - R.id.nav_instruments -> { - hideOverlays() - bottomSheetBehavior.isHideable = false - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + R.id.nav_layers -> { + // Action button — show picker then snap back to Map + val currentStyle = loadedStyleFlow.value + if (currentStyle != null) { + LayerPickerSheet( + manager = layerManager, + onBaseChanged = { preset -> layerManager.setBasePreset(currentStyle, preset) }, + onWindChanged = { enabled -> layerManager.setWindEnabled(currentStyle, enabled) } + ).show(supportFragmentManager, "layer_picker") + } + bottomNav.post { bottomNav.selectedItemId = R.id.nav_map } true } R.id.nav_log -> { diff --git a/android-app/app/src/main/res/drawable/ic_layers.xml b/android-app/app/src/main/res/drawable/ic_layers.xml new file mode 100644 index 0000000..f86f83a --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_layers.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml index b29fb08..e7fc15d 100644 --- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml +++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml @@ -5,9 +5,9 @@ android:icon="@drawable/ic_map" android:title="Map" /> + android:id="@+id/nav_layers" + android:icon="@drawable/ic_layers" + android:title="Layers" /> Date: Fri, 10 Apr 2026 09:30:55 +0000 Subject: Area conditions HUD redesign: map-center conditions refresh + boat HUD strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persistent top-of-map HUD strip (SOG/COG/BSP/Depth) replaces instrument grid in bottom sheet - Bottom sheet now shows area conditions only (Wind/Temp/Baro + wave forecast) — always visible - loadConditions() fires on every camera idle event so panning the map refreshes conditions - Crosshair appears at map center while panning; hides when following GPS - Added tempC field to MarineConditions (already fetched from Open-Meteo hourly) - InstrumentHandler slimmed to area conditions only; updateDisplay() removed - LocationService pipes nmeaBoatSpeedData from NmeaStreamManager to companion flow https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- .../main/kotlin/org/terst/nav/LocationService.kt | 11 ++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 86 ++++++---- .../org/terst/nav/data/model/MarineConditions.kt | 1 + .../terst/nav/data/repository/WeatherRepository.kt | 1 + .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 86 ++++------ .../app/src/main/res/drawable/ic_crosshair.xml | 28 +++ .../app/src/main/res/layout/activity_main.xml | 24 +++ .../main/res/layout/layout_instruments_sheet.xml | 187 +++------------------ .../app/src/main/res/layout/layout_nav_hud.xml | 161 ++++++++++++++++++ 9 files changed, 329 insertions(+), 256 deletions(-) create mode 100644 android-app/app/src/main/res/drawable/ic_crosshair.xml create mode 100644 android-app/app/src/main/res/layout/layout_nav_hud.xml (limited to 'android-app/app/src/main/res/drawable') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index b18db8d..41fb2ec 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.asStateFlow import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager +import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData @@ -128,6 +129,13 @@ class LocationService : Service() { } } + // Collect NMEA Boat Speed Data + serviceScope.launch { + nmeaStreamManager.nmeaBoatSpeedData.collectLatest { bspData -> + _nmeaBoatSpeedDataFlow.emit(bspData) + } + } + locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> @@ -393,6 +401,9 @@ class LocationService : Service() { private val _nmeaHeadingDataFlow = MutableSharedFlow(replay = 1) val nmeaHeadingDataFlow: SharedFlow get() = _nmeaHeadingDataFlow + private val _nmeaBoatSpeedDataFlow = MutableSharedFlow(replay = 1) + val nmeaBoatSpeedData: SharedFlow get() = _nmeaBoatSpeedDataFlow + private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) val currentPowerMode: StateFlow get() = _currentPowerMode } 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 6d02014..21aa55b 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 @@ -11,6 +11,7 @@ import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout +import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -57,6 +58,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView + private lateinit var mapCrosshair: View + + // HUD TextViews — wired to layout_nav_hud.xml + private lateinit var hudSog: TextView + private lateinit var hudCog: TextView + private lateinit var hudBsp: TextView + private lateinit var hudDepth: TextView + private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } private val viewModel: MainViewModel by viewModels() @@ -88,6 +97,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) + mapCrosshair = findViewById(R.id.map_crosshair) + + // HUD views (inside the included layout_nav_hud) + hudSog = findViewById(R.id.hud_sog) + hudCog = findViewById(R.id.hud_cog) + hudBsp = findViewById(R.id.hud_bsp) + hudDepth = findViewById(R.id.hud_depth) + + // Initialise HUD with dashes + hudSog.text = "—" + hudCog.text = "—" + hudBsp.text = "—" + hudDepth.text = "—" setupBottomSheet() setupBottomNavigation() @@ -184,12 +206,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fragmentContainer.visibility = View.GONE } - private fun showReport(fragment: androidx.fragment.app.Fragment) { - bottomSheetBehavior.isHideable = true - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - showOverlay(fragment) - } - private fun onQuitRequested() { if (viewModel.isRecording.value) { androidx.appcompat.app.AlertDialog.Builder(this) @@ -222,18 +238,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun setupHandlers() { instrumentHandler = InstrumentHandler( - valueAws = findViewById(R.id.value_aws), - valueTws = findViewById(R.id.value_tws), - valueHdg = findViewById(R.id.value_hdg), - valueCog = findViewById(R.id.value_cog), - valueBsp = findViewById(R.id.value_bsp), - valueSog = findViewById(R.id.value_sog), - valueDepth = findViewById(R.id.value_depth), - valueBaro = findViewById(R.id.value_baro), - arrowAws = findViewById(R.id.arrow_aws), - arrowTws = findViewById(R.id.arrow_tws), - arrowHdg = findViewById(R.id.arrow_hdg), - arrowCog = findViewById(R.id.arrow_cog), + valueTws = findViewById(R.id.value_tws), + arrowTws = findViewById(R.id.arrow_tws), + bearingTws = findViewById(R.id.bearing_tws), + valueTemp = findViewById(R.id.value_temp), + valueBaro = findViewById(R.id.value_baro), valueCurrSpd = findViewById(R.id.value_curr_spd), valueWaveHt = findViewById(R.id.value_wave_ht), valueSwellHt = findViewById(R.id.value_swell_ht), @@ -246,12 +255,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bearingSwell = findViewById(R.id.bearing_swell), waveView = findViewById(R.id.wave_divider) ) - instrumentHandler?.updateDisplay( - aws = "—", tws = "—", hdg = "—", - cog = "—", bsp = "—", sog = "—", - baro = "—" + instrumentHandler?.updateConditions( + tws = "—", baro = "—", currSpd = "—" ) - instrumentHandler?.updateConditions(currSpd = "—") } private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() @@ -302,11 +308,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { mapHandler!!.isFollowing.collect { following -> + mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE if (following) { fadeOut(fabRecenter, gone = true) - fadeIn(bottomSheet, bottomNav, fabMob, fabRecordTrack) + fadeIn(bottomNav, fabMob, fabRecordTrack) } else { - fadeOut(bottomSheet, bottomNav, fabMob, fabRecordTrack, gone = true) + fadeOut(bottomNav, fabMob, fabRecordTrack, gone = true) fadeIn(fabRecenter) } } @@ -339,6 +346,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { corners.minOf { it.longitude }, corners.maxOf { it.longitude } ) } + // Refresh conditions for current map center (works in both follow + pan mode) + maplibreMap.cameraPosition.target?.let { center -> + viewModel.loadConditions(center.latitude, center.longitude) + } } maplibreMap.addOnMapLongClickListener { _ -> @@ -360,11 +371,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat()) viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog) - instrumentHandler?.updateDisplay( - sog = "%.1f".format(Locale.getDefault(), gpsData.sog), - cog = "%.0f°".format(Locale.getDefault(), gpsData.cog), - cogBearingDeg = gpsData.cog.toFloat() - ) + // HUD — SOG and COG come from GPS + hudSog.text = "%.1f".format(Locale.getDefault(), gpsData.sog) + hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) if (!conditionsLoaded) { conditionsLoaded = true viewModel.loadConditions(gpsData.latitude, gpsData.longitude) @@ -391,18 +400,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.barometerStatus.collect { status -> if (status.history.isNotEmpty()) { - instrumentHandler?.updateDisplay(baro = status.formatPressure()) + instrumentHandler?.updateConditions(baro = status.formatPressure()) } } } lifecycleScope.launch { viewModel.marineConditions.collect { c -> if (c == null) return@collect - instrumentHandler?.updateDisplay( - tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, - twsBearingDeg = c.windDirDeg?.toFloat() - ) instrumentHandler?.updateConditions( + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) }, + twsBearingDeg = c.windDirDeg?.toFloat(), + tempC = c.tempC, currSpd = c.currentSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—", currDirDeg = c.currentDirDeg?.toFloat(), waveHeightM = c.waveHeightM, @@ -420,7 +428,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } lifecycleScope.launch { LocationService.nmeaDepthDataFlow.collect { depthData -> - instrumentHandler?.updateDisplay(depthM = depthData.depthMeters) + // Update HUD depth (converted to feet) + hudDepth.text = "%.1f".format(Locale.getDefault(), depthData.depthMeters * 3.28084) + } + } + lifecycleScope.launch { + LocationService.nmeaBoatSpeedData.collect { bspData -> + hudBsp.text = "%.1f".format(Locale.getDefault(), bspData.bspKnots) } } lifecycleScope.launch { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt index 3cde023..557d6a2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt @@ -7,6 +7,7 @@ package org.terst.nav.data.model data class MarineConditions( val windSpeedKt: Double?, val windDirDeg: Double?, + val tempC: Double?, val waveHeightM: Double?, val waveDirDeg: Double?, val swellHeightM: Double?, diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt index c79366d..dc47a20 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -59,6 +59,7 @@ class WeatherRepository( MarineConditions( windSpeedKt = w.windspeed10m.firstOrNull(), windDirDeg = w.winddirection10m.firstOrNull(), + tempC = w.temperature2m.firstOrNull(), waveHeightM = m.waveHeight.firstOrNull(), waveDirDeg = m.waveDirection.firstOrNull(), swellHeightM = m.swellWaveHeight.firstOrNull(), diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt index cb59a3a..48ebb3b 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt @@ -25,10 +25,11 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = // ── InstrumentHandler ──────────────────────────────────────────────────────── /** - * Drives all text fields, direction arrows, and the wave view in the - * instrument bottom sheet. + * Drives the area-conditions bottom sheet: wind header row, wave view, + * and the forecast section (current / waves / swell). * - * Forecast [DirectionArrowView] instances are initialised with OCEAN style. + * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly + * in MainActivity via the HUD strip. * * Units contract: * - Speed values: pre-formatted strings in knots (caller's responsibility) @@ -37,34 +38,26 @@ fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String = * into bearing TextViews by this class */ class InstrumentHandler( - // ── Instrument section TextViews ───────────────────────────────── - private val valueAws: TextView, - private val valueTws: TextView, - private val valueHdg: TextView, - private val valueCog: TextView, - private val valueBsp: TextView, - private val valueSog: TextView, - private val valueDepth: TextView, - private val valueBaro: TextView, - // ── Instrument section DirectionArrowViews ─────────────────────── - private val arrowAws: DirectionArrowView, - private val arrowTws: DirectionArrowView, - private val arrowHdg: DirectionArrowView, - private val arrowCog: DirectionArrowView, - // ── Forecast section TextViews ─────────────────────────────────── + // ── Conditions header ──────────────────────────────────────────────── + private val valueTws: TextView, + private val arrowTws: DirectionArrowView, + private val bearingTws: TextView, + private val valueTemp: TextView, + private val valueBaro: TextView, + // ── Forecast section TextViews ─────────────────────────────────────── private val valueCurrSpd: TextView, private val valueWaveHt: TextView, private val valueSwellHt: TextView, private val valueSwellPer: TextView, - // ── Forecast section DirectionArrowViews ───────────────────────── + // ── Forecast section DirectionArrowViews ───────────────────────────── private val arrowCurr: DirectionArrowView, private val arrowWaves: DirectionArrowView, private val arrowSwell: DirectionArrowView, - // ── Forecast section bearing TextViews ─────────────────────────── + // ── Forecast section bearing TextViews ─────────────────────────────── private val bearingCurr: TextView, private val bearingWaves: TextView, private val bearingSwell: TextView, - // ── Wave view ──────────────────────────────────────────────────── + // ── Wave view ──────────────────────────────────────────────────────── private val waveView: WaveView ) { init { @@ -74,48 +67,27 @@ class InstrumentHandler( } /** - * Updates instrument-section text and arrows. + * Updates all area-conditions fields. * Null arguments leave the current value unchanged. - * [depthM] is raw metres — converted to feet internally. - */ - fun updateDisplay( - aws: String? = null, awsBearingDeg: Float? = null, - tws: String? = null, twsBearingDeg: Float? = null, - hdg: String? = null, hdgBearingDeg: Float? = null, - cog: String? = null, cogBearingDeg: Float? = null, - bsp: String? = null, - sog: String? = null, - depthM: Double? = null, - baro: String? = null - ) { - aws?.let { valueAws.text = it } - tws?.let { valueTws.text = it } - hdg?.let { valueHdg.text = it } - cog?.let { valueCog.text = it } - bsp?.let { valueBsp.text = it } - sog?.let { valueSog.text = it } - baro?.let { valueBaro.text = it } - depthM?.let { valueDepth.text = formatFt(metresToFeet(it)) } - - awsBearingDeg?.let { arrowAws.bearing = it } - twsBearingDeg?.let { arrowTws.bearing = it } - hdgBearingDeg?.let { arrowHdg.bearing = it } - cogBearingDeg?.let { arrowCog.bearing = it } - } - - /** - * Updates the forecast section. * [waveHeightM] and [swellHeightM] are raw metres — converted to feet internally. */ fun updateConditions( - currSpd: String? = null, - currDirDeg: Float? = null, - waveHeightM: Double? = null, - waveDirDeg: Float? = null, - swellHeightM: Double? = null, - swellDirDeg: Float? = null, + tws: String? = null, twsBearingDeg: Float? = null, + tempC: Double? = null, + baro: String? = null, + currSpd: String? = null, currDirDeg: Float? = null, + waveHeightM: Double? = null, waveDirDeg: Float? = null, + swellHeightM: Double? = null, swellDirDeg: Float? = null, swellPeriodS: Double? = null ) { + tws?.let { valueTws.text = it } + baro?.let { valueBaro.text = it } + tempC?.let { valueTemp.text = "%.0f".format(Locale.getDefault(), it) } + twsBearingDeg?.let { + arrowTws.bearing = it + bearingTws.text = formatBearing(it.toDouble()) + } + currSpd?.let { valueCurrSpd.text = it } currDirDeg?.let { arrowCurr.bearing = it diff --git a/android-app/app/src/main/res/drawable/ic_crosshair.xml b/android-app/app/src/main/res/drawable/ic_crosshair.xml new file mode 100644 index 0000000..609538e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_crosshair.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 1741c62..5147506 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -29,6 +29,30 @@ android:clickable="false" android:focusable="false" /> + + + + + + - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - + + - + - + tools:text="18" /> + + android:background="@color/md_theme_outline" /> + diff --git a/android-app/app/src/main/res/layout/layout_nav_hud.xml b/android-app/app/src/main/res/layout/layout_nav_hud.xml new file mode 100644 index 0000000..4623bb1 --- /dev/null +++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- cgit v1.2.3 From fc7192288109fc3542670cbeeaebe0de2a75eb74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 22:47:27 +0000 Subject: Add Learn tab; move quit to Safety; remove persistent MOB FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI cleanup: - Remove always-visible MOB FAB — Safety screen button is sufficient - Remove floating quit button; move to bottom of Safety screen - fragment_container is now clickable/focusable, blocking map touch-through for all overlays (trip reports, log, safety) - Record Track FAB hidden while any overlay is shown, visible on map tab New Learn tab (5th nav item): - Migration guides for Navionics and Sea People (local markdown via DocFragment) - ASA Course Catalog, ASA Online Learning, ColRegs, Sailing Flashcards (open in browser) - Remove blanket assets/ .gitignore so markdown docs are tracked https://claude.ai/code/session_01HXPjBsogsJVRwCiekDGkJX --- android-app/.gitignore | 4 +- .../app/src/main/assets/docs/migrate_navionics.md | 56 +++++ .../app/src/main/assets/docs/migrate_sea_people.md | 59 ++++++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 20 +- .../kotlin/org/terst/nav/ui/learn/LearnFragment.kt | 56 +++++ .../org/terst/nav/ui/safety/SafetyFragment.kt | 5 + android-app/app/src/main/res/drawable/ic_learn.xml | 9 + .../app/src/main/res/layout/activity_main.xml | 35 +-- .../app/src/main/res/layout/fragment_learn.xml | 235 +++++++++++++++++++++ .../app/src/main/res/layout/fragment_safety.xml | 11 + .../app/src/main/res/menu/bottom_nav_menu.xml | 4 + 11 files changed, 450 insertions(+), 44 deletions(-) create mode 100644 android-app/app/src/main/assets/docs/migrate_navionics.md create mode 100644 android-app/app/src/main/assets/docs/migrate_sea_people.md create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt create mode 100644 android-app/app/src/main/res/drawable/ic_learn.xml create mode 100644 android-app/app/src/main/res/layout/fragment_learn.xml (limited to 'android-app/app/src/main/res/drawable') diff --git a/android-app/.gitignore b/android-app/.gitignore index 7db1cd2..acd3de6 100644 --- a/android-app/.gitignore +++ b/android-app/.gitignore @@ -11,8 +11,8 @@ build/ *.iws local.properties -# Generated files -app/src/main/assets/ +# Generated assets (add specific patterns here if needed) +# app/src/main/assets/ — removed blanket ignore; docs are tracked # Keystore *.jks diff --git a/android-app/app/src/main/assets/docs/migrate_navionics.md b/android-app/app/src/main/assets/docs/migrate_navionics.md new file mode 100644 index 0000000..83b28fa --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_navionics.md @@ -0,0 +1,56 @@ +# Migrating from Navionics + +Welcome to Nav. This guide covers the key differences and how to move your data over. + +--- + +## What's Different + +| Feature | Navionics | Nav | +|---|---|---| +| Charts | Navionics SonarChart + Raster | OpenFreeMap vector tiles | +| Track recording | In-app GPX | GPX saved to Documents/Nav/ | +| Trip log | Voyage log | Voice log (transcribed) | +| Departure planning | None | Pre-trip briefing with sail plan | +| Wind overlay | Basic | Wind particles + hourly forecast | + +--- + +## Exporting Your Tracks from Navionics + +1. Open Navionics on your phone or tablet +2. Go to **My Charts → Tracks** +3. Tap a track → **Share → Export GPX** +4. Save or AirDrop the `.gpx` file to your device + +--- + +## Importing Tracks into Nav + +Nav automatically reads GPX files from the **Documents/Nav/** folder on your device. + +1. Move your exported `.gpx` files to `Documents/Nav/` using the Files app +2. Re-open Nav — tracks are loaded on startup +3. Similar-conditions trip comparison in the Pre-Trip Briefing will use these tracks + +--- + +## Waypoints + +Nav does not currently manage waypoints. Use Navionics or a dedicated chart plotter for waypoint routing. Nav focuses on departure briefing, track recording, and the sail log. + +--- + +## Charts + +Nav uses **OpenFreeMap** vector tiles. These are free, fast, and work well for coastal cruising. They do not include depth contours or hazard overlays — continue using Navionics or a NOAA chart app for navigation in unfamiliar waters. + +--- + +## Daily Workflow + +**Before you sail:** Open the **Safety → Plan Trip** screen for your departure briefing — sail plan, heading recommendation, watch items, and a look at how conditions will change over your trip window. + +**On the water:** Tap **Log** to dictate voice notes. The GPS track records automatically when you tap the record button. + +**After you return:** The post-trip report summarises distance, duration, and max SOG. diff --git a/android-app/app/src/main/assets/docs/migrate_sea_people.md b/android-app/app/src/main/assets/docs/migrate_sea_people.md new file mode 100644 index 0000000..b1920b4 --- /dev/null +++ b/android-app/app/src/main/assets/docs/migrate_sea_people.md @@ -0,0 +1,59 @@ +# Migrating from Sea People + +Welcome to Nav. This guide covers what transfers over and what works differently. + +--- + +## What's Different + +| Feature | Sea People | Nav | +|---|---|---| +| Social feed | Community posts | Not applicable — Nav is single-user | +| Trip log | Text entries + photos | Voice log (transcribed audio) | +| Boat profiles | Basic boat card | Detailed sail inventory with reef points | +| Weather | Integrated forecast | Open-Meteo hourly + NOAA marine | +| Departure planning | Manual notes | Pre-trip briefing with sail plan & route | + +--- + +## Exporting Your Log from Sea People + +1. Open Sea People → **Profile → My Logs** +2. Use the **Export** option (CSV or PDF depending on your version) +3. Save entries you want to keep for reference + +Nav's voice log works differently — entries are dictated on the water and transcribed. You won't be able to import Sea People entries directly, but you can review them alongside Nav going forward. + +--- + +## Boat Profiles + +Nav stores a detailed boat profile with your sail inventory: + +- **Headsails** with wind range for each (e.g. 155% Genoa ≤ 13 kt, 100% Jib 10–21 kt, 65% Blade 18+ kt) +- **Main reef points** +- **Hull length** (used for hull-speed estimates in the departure briefing) + +Your boats are pre-seeded on first launch. Profiles are stored locally and used in the **Pre-Trip Briefing** to give sail recommendations specific to your boat. + +--- + +## Trip Log Workflow + +**On the water:** Tap the **Log** tab and use the microphone button to dictate a note. Notes are timestamped with your GPS position. + +**Voice notes work well for:** +- Sea state observations ("1.5m swell, 10s period, comfortable") +- Traffic ("fishing vessel on starboard, gave way") +- Sail changes ("reefed main at 18 kt") +- Time stamps ("rounded the point, heading west") + +--- + +## Daily Workflow + +**Before you sail:** Safety → Plan Trip for your departure briefing. + +**On the water:** Log tab for voice notes; tap the record FAB to start the GPS track. + +**After you return:** The post-trip report generates a narrative summary you can save or share. 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 86dd531..996892e 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 @@ -54,9 +54,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomSheetBehavior: BottomSheetBehavior private lateinit var fragmentContainer: FrameLayout private lateinit var fabRecordTrack: FloatingActionButton - private lateinit var fabMob: FloatingActionButton private lateinit var fabRecenter: MaterialButton - private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private lateinit var mapCrosshair: View @@ -117,9 +115,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun initializeUI() { fragmentContainer = findViewById(R.id.fragment_container) fabRecordTrack = findViewById(R.id.fab_record_track) - fabMob = findViewById(R.id.fab_mob) fabRecenter = findViewById(R.id.fab_recenter) - btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) mapCrosshair = findViewById(R.id.map_crosshair) @@ -166,9 +162,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } else false } - fabMob.setOnClickListener { onActivateMob() } fabRecenter.setOnClickListener { mapHandler?.recenter() } - btnQuit.setOnClickListener { onQuitRequested() } lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -280,6 +274,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN true } + R.id.nav_learn -> { + showOverlay(org.terst.nav.ui.learn.LearnFragment()) + bottomSheetBehavior.isHideable = true + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + true + } else -> false } } @@ -287,6 +287,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun showOverlay(fragment: androidx.fragment.app.Fragment) { fragmentContainer.visibility = View.VISIBLE + fabRecordTrack.visibility = View.GONE supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, fragment) .commit() @@ -294,9 +295,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun hideOverlays() { fragmentContainer.visibility = View.GONE + fabRecordTrack.visibility = View.VISIBLE } - private fun onQuitRequested() { + override fun onQuitRequested() { if (viewModel.isRecording.value) { androidx.appcompat.app.AlertDialog.Builder(this) .setMessage("Recording in progress. Quit and discard the current track?") @@ -405,9 +407,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE if (following) { fadeOut(fabRecenter, gone = true) - fadeIn(bottomNav, fabMob, fabRecordTrack) + fadeIn(bottomNav, fabRecordTrack) } else { - fadeOut(bottomNav, fabMob, fabRecordTrack, gone = true) + fadeOut(bottomNav, fabRecordTrack, gone = true) fadeIn(fabRecenter) } } 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 new file mode 100644 index 0000000..8440edb --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt @@ -0,0 +1,56 @@ +package org.terst.nav.ui.learn + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.android.material.card.MaterialCardView +import org.terst.nav.R +import org.terst.nav.ui.doc.DocFragment + +class LearnFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_learn, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Migration guides — open local markdown docs + view.findViewById(R.id.card_migrate_navionics).setOnClickListener { + openDoc("docs/migrate_navionics.md") + } + view.findViewById(R.id.card_migrate_seapeople).setOnClickListener { + openDoc("docs/migrate_sea_people.md") + } + + // ASA / external links — open in browser + view.findViewById(R.id.card_asa_courses).setOnClickListener { + openUrl("https://www.asa.com/courses/") + } + 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/") + } + } + + private fun openDoc(path: String) { + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, DocFragment.newInstance(path)) + .addToBackStack(null) + .commit() + } + + private fun openUrl(url: String) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt index 4bc0c7a..6dd7411 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt @@ -15,6 +15,7 @@ class SafetyFragment : Fragment() { interface SafetyListener { fun onActivateMob() fun onConfigureAnchor() + fun onQuitRequested() } private var listener: SafetyListener? = null @@ -55,6 +56,10 @@ class SafetyFragment : Fragment() { .addToBackStack(null) .commit() } + + view.findViewById(R.id.button_quit).setOnClickListener { + listener?.onQuitRequested() + } } fun updateAnchorStatus(statusText: String) { diff --git a/android-app/app/src/main/res/drawable/ic_learn.xml b/android-app/app/src/main/res/drawable/ic_learn.xml new file mode 100644 index 0000000..1574693 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_learn.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index 5943949..1bb88b3 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -59,26 +59,10 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" + android:clickable="true" + android:focusable="true" android:background="?attr/colorSurface" /> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml index f90420e..60ea1aa 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -113,4 +113,15 @@ android:text="PLAN TRIP (PRE-TRIP REPORT)" app:layout_constraintTop_toBottomOf="@id/card_anchor" /> + + diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml index e7fc15d..3037b7e 100644 --- a/android-app/app/src/main/res/menu/bottom_nav_menu.xml +++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml @@ -16,4 +16,8 @@ android:id="@+id/nav_safety" android:icon="@drawable/ic_safety" android:title="Safety" /> + -- cgit v1.2.3 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 --- android-app/app/src/main/AndroidManifest.xml | 1 + .../app/src/main/assets/docs/colregs_reference.md | 230 +++++++++++++++++++++ .../app/src/main/assets/docs/sailing_reference.md | 206 ++++++++++++++++++ .../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 +++++++++++---- .../app/src/main/res/drawable/ic_calendar.xml | 44 ++++ .../app/src/main/res/drawable/ic_open_in_new.xml | 30 +++ .../app/src/main/res/layout/fragment_learn.xml | 226 +++++++++++++++----- .../main/res/layout/fragment_pretrip_report.xml | 52 +++++ .../app/src/main/res/layout/fragment_voice_log.xml | 142 ++++++++++--- 15 files changed, 1176 insertions(+), 203 deletions(-) create mode 100644 android-app/app/src/main/assets/docs/colregs_reference.md create mode 100644 android-app/app/src/main/assets/docs/sailing_reference.md create mode 100644 android-app/app/src/main/res/drawable/ic_calendar.xml create mode 100644 android-app/app/src/main/res/drawable/ic_open_in_new.xml (limited to 'android-app/app/src/main/res/drawable') diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index bcab20c..7cbafc7 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/android-app/app/src/main/assets/docs/colregs_reference.md b/android-app/app/src/main/assets/docs/colregs_reference.md new file mode 100644 index 0000000..6e5c39a --- /dev/null +++ b/android-app/app/src/main/assets/docs/colregs_reference.md @@ -0,0 +1,230 @@ +# ColRegs — Rules of the Road + +International Regulations for Preventing Collisions at Sea (COLREGS), 1972 with amendments. This is a summary reference; consult the official text for legal purposes. + +--- + +## Part A — General (Rules 1–3) + +**Rule 1 — Application** +Applies to all vessels on the high seas and connected navigable waters. + +**Rule 2 — Responsibility** +Nothing in these rules exonerates a vessel, owner, master, or crew from the consequences of neglect. Good seamanship always applies. + +**Rule 3 — Definitions** +- *Vessel* — any watercraft, including seaplanes and WIG craft +- *Power-driven vessel* — any vessel propelled by machinery +- *Sailing vessel* — under sail only; if engine is running, she is power-driven +- *Vessel engaged in fishing* — using nets, lines, trawls that restrict maneuverability +- *Underway* — not at anchor, aground, or made fast to shore +- *Restricted visibility* — fog, mist, falling snow, heavy rain, sandstorm, or similar conditions + +--- + +## Part B — Steering and Sailing Rules + +### Section I — Conduct in Any Visibility (Rules 4–10) + +**Rule 4** — Applies in any condition of visibility. + +**Rule 5 — Look-out** +Every vessel shall maintain a proper look-out at all times by sight, hearing, and all available means. + +**Rule 6 — Safe Speed** +Every vessel shall proceed at a safe speed. Factors: visibility, traffic density, vessel maneuverability, background lights at night, radar state, sea state. + +**Rule 7 — Risk of Collision** +Risk exists if the compass bearing of an approaching vessel does not appreciably change. When in doubt, assume risk exists. + +**Rule 8 — Action to Avoid Collision** +- Action must be positive, made in ample time, and large enough to be readily apparent +- Course or speed changes should be large enough to be noticed +- If necessary, stop or reverse + +**Rule 9 — Narrow Channels** +- Keep to the starboard side of a narrow channel +- Vessels under 20 m or sailing vessels shall not impede vessels that can safely navigate only in the channel +- Overtaking only when safe and the overtaken vessel signals agreement +- Do not cross a narrow channel if it impedes a through-traffic vessel + +**Rule 10 — Traffic Separation Schemes** +- Join/leave at end; if joining from side, at acute angle +- Keep out of separation zones +- Crossing traffic does so at right angles where practicable +- Inshore traffic zones: use only if < 20 m, or sailing, or fishing + +--- + +### Section II — Conduct in Sight of One Another (Rules 11–18) + +**Rule 11** — Applies to vessels in sight of one another. + +**Rule 12 — Sailing Vessels** +- Vessel on port tack gives way to vessel on starboard tack +- Both on same tack: windward vessel gives way to leeward vessel +- Port tack vessel cannot determine which tack the other is on: gives way + +**Rule 13 — Overtaking** +Any vessel overtaking gives way. Overtaking means coming up from more than 22.5° abaft the other's beam. Overtaking status persists until clear and past. + +**Rule 14 — Head-on Situation** +Both vessels altering course to starboard so each passes on the port side of the other. Applies when risk of collision exists and vessels are nearly end-on. + +**Rule 15 — Crossing Situation** +The vessel that has the other on its own starboard side gives way (the "burdened" or give-way vessel). The stand-on vessel is on the right. + +**Rule 16 — Action by Give-way Vessel** +Take early and substantial action to keep well clear. + +**Rule 17 — Action by Stand-on Vessel** +- May take action to avoid collision by own maneuver alone when it becomes apparent the give-way vessel is not taking sufficient action +- Must take action when collision cannot be avoided by give-way vessel alone +- Course change to port for a vessel on your port side is avoided if possible + +**Rule 18 — Responsibilities Between Vessels** + +Hierarchy (higher number gives way to all above): +1. Vessel not under command (NUC) +2. Vessel restricted in ability to maneuver (RAM) +3. Vessel constrained by draft +4. Vessel engaged in fishing +5. Sailing vessel +6. Power-driven vessel underway + +*Note:* Sailing and power vessels give way to NUC, RAM, constrained, and fishing vessels. A power vessel gives way to a sailing vessel. + +--- + +### Section III — Conduct in Restricted Visibility (Rule 19) + +**Rule 19 — Restricted Visibility** +- Proceed at safe speed adapted to conditions +- Have engines ready for immediate maneuver +- On hearing fog signal apparently forward of beam: reduce to bare steerage or stop +- Avoid alteration of course to port for a vessel forward of beam (except overtaking) +- Avoid alteration toward a vessel abeam or abaft beam + +--- + +## Part C — Lights and Shapes (Rules 20–31) + +### Lights (Rules 20–22) + +**Rule 20 — Application** +Lights required from sunset to sunrise and in restricted visibility. + +**Rule 21 — Definitions** +- *Masthead light* — white forward light, 225° arc +- *Side lights* — red (port) and green (starboard), 112.5° each +- *Stern light* — white aft, 135° arc +- *Towing light* — yellow, same arc as stern light +- *All-round light* — 360° arc +- *Flashing light* — 120+ flashes/minute + +**Rule 22 — Visibility of Lights** + +| Vessel size | Masthead | Side | Stern | All-round | +|---|---|---|---|---| +| ≥ 50 m | 6 nm | 3 nm | 3 nm | 3 nm | +| 12–50 m | 5 nm | 2 nm | 2 nm | 2 nm | +| 7–12 m | 3 nm | 1 nm | 2 nm | 2 nm | +| < 7 m | — | — | — | 2 nm | + +--- + +### Light Combinations to Know + +**Under power (≥ 50 m):** Two masthead lights (forward lower, aft higher) + sidelights + stern light + +**Under power (< 50 m):** One masthead light + sidelights + stern light + +**Under sail (underway):** Sidelights + stern light only. *No masthead light when under sail.* + +**Sail + engine:** Power-driven vessel rules apply — show cone (point down) by day. + +**At anchor (< 50 m):** One white all-round light forward. +**At anchor (≥ 50 m):** White all-round forward + aft. + +**Not under command:** Two red all-round lights (vertical). If making way: add sidelights + stern light. + +**Restricted in ability to maneuver:** Red-white-red all-round lights (vertical). If making way: add masthead + sidelights + stern. + +**Vessel aground:** Anchor lights + two red all-round lights (vertical). + +**Towing vessel:** Extra masthead light(s) + yellow towing light instead of (or in addition to) stern light. + +**Fishing (trawling):** Green over white all-round (vertical) + sidelights + stern if making way. +**Fishing (other):** Red over white all-round (vertical) + sidelights + stern if making way + white toward gear if gear > 150 m. + +**Pilot vessel on duty:** White over red all-round lights. + +--- + +### Day Shapes (Rule 28) + +| Shape | Vessel Type | +|---|---| +| Black ball | At anchor | +| Black cone (apex down) | Sailing vessel with engine | +| Two black balls (vertical) | Not under command | +| Ball-diamond-ball (vertical) | Restricted in ability to maneuver | +| Black cylinder | Constrained by draft | +| Basket | Engaged in fishing | +| Cone (apex up) | Vessel being towed (if requested) | + +--- + +## Part D — Sound and Light Signals (Rules 32–37) + +**Rule 32 — Definitions** +- *Short blast* — about 1 second +- *Prolonged blast* — 4–6 seconds + +**Rule 33 — Equipment** +- ≥ 12 m: whistle + bell +- ≥ 100 m: also gong + +**Rule 34 — Maneuvering and Warning Signals** + +| Signal | Meaning | +|---|---| +| 1 short | I am altering course to starboard | +| 2 shorts | I am altering course to port | +| 3 shorts | I am operating astern propulsion | +| 5+ shorts (rapid) | Danger / doubt signal | +| 1 prolonged | Vessel leaving berth | + +**Rule 35 — Sound Signals in Restricted Visibility** + +| Signal | Vessel | +|---|---| +| 1 prolonged (≤ 2 min) | Power-driven vessel making way | +| 2 prolonged (≤ 2 min) | Power-driven vessel underway but stopped | +| 1 long + 2 short (≤ 2 min) | NUC, RAM, sailing, fishing, towing | +| 1 long + 3 short | Vessel being towed (last vessel) | +| Rapid bell (5 sec, ≤ 1 min) | At anchor (< 100 m) | +| Bell + gong (≤ 1 min) | At anchor (≥ 100 m) | +| 3 strokes + rapid bell + 3 strokes | Vessel aground | + +**Rule 36 — Attention Signal** +Five or more short and rapid blasts. Also a light signal of the same pattern. + +**Rule 37 — Distress Signals** +Gun fired at ~1 min intervals; continuous foghorn; SOS (···−−−···); MAYDAY by voice; orange smoke; flames; parachute flare; dye; square flag + ball; high-intensity white light flashing; radio alarm signal. + +--- + +## Part E — Exemptions (Rule 38) + +Older vessels may be exempt from some lighting requirements for a period of years after the rules came into force. + +--- + +## Quick Memory Aids + +**Starboard right-of-way:** When another vessel is on your starboard side in a crossing situation, YOU give way. + +**Lights mnemonic — red over green, sailing machine:** A sailing vessel shows red (port side) and green (starboard) sidelights plus a white stern light. No masthead light while under sail alone. + +**The hierarchy:** NUC → RAM → Constrained → Fishing → Sail → Power diff --git a/android-app/app/src/main/assets/docs/sailing_reference.md b/android-app/app/src/main/assets/docs/sailing_reference.md new file mode 100644 index 0000000..7fc7bdb --- /dev/null +++ b/android-app/app/src/main/assets/docs/sailing_reference.md @@ -0,0 +1,206 @@ +# Sailing Quick Reference + +--- + +## Points of Sail + +The point of sail describes the angle between the boat's heading and the true wind direction. + +| Point of Sail | True Wind Angle | Description | +|---|---|---| +| In irons | 0–30° | Head-to-wind, sails luffing, no drive | +| Close hauled | ~30–45° | Sailing as close to the wind as possible | +| Close reach | ~45–60° | Between close hauled and beam reach | +| Beam reach | ~90° | Wind directly abeam — often fastest point | +| Broad reach | ~120–150° | Wind on the quarter — comfortable, fast | +| Run | ~150–180° | Wind from directly behind | + +**No-go zone:** ~0–30° on either side of the wind — the boat cannot make progress sailing directly into the wind. + +--- + +## Tacking vs. Gybing + +**Tacking** — turning the bow through the wind (bow crosses the wind). The boom swings across from one side to the other. Used to head upwind. + +**Gybing** — turning the stern through the wind (stern crosses the wind). The boom can swing violently — always control the mainsheet. Used to change direction downwind. + +--- + +## Sail Trim Basics + +**Telltales** — strips of yarn or fabric on the sail. +- Both telltales streaming aft → sail trimmed correctly +- Windward telltale lifting → sheet in (trim), or bear away +- Leeward telltale lifting → sheet out (ease), or head up + +**In irons fix:** Let sails luff, push boom to one side, fall off onto a tack. + +**Reef** — reducing sail area by partially lowering the mainsail and tying off the excess. Reef before you think you need to. Typical thresholds: first reef ~15–18 kt, second reef ~21–25 kt. + +--- + +## Hull Speed + +The theoretical maximum displacement hull speed: + +**Hull speed (kt) ≈ 1.34 × √(waterline length in feet)** + +| LOA | Hull Speed | +|---|---| +| 20 ft | ~6.0 kt | +| 23 ft | ~6.4 kt | +| 30 ft | ~7.3 kt | +| 40 ft | ~8.5 kt | + +A modern fin-keel boat can exceed hull speed in planing conditions (surfing downwind in big waves). + +--- + +## Navigation Lights — Quick Reference + +| Situation | What You See | What It Is | +|---|---|---| +| Red + green + white | Two side lights + stern | Head-on approach | +| Red only | Port sidelight | Vessel crossing left-to-right in front of you | +| Green only | Starboard sidelight | Vessel crossing right-to-left — you are give-way | +| White only (masthead) + green | Overtaking from starboard | Vessel overtaking you on starboard | +| Two white (stacked) + red/green | Two masthead lights | Large ship (≥50 m) underway under power | +| Red + white (all-round, vertical) | Not under command | Give way — vessel cannot maneuver | +| Green + white (all-round, vertical) | Trawler | Give way — engaged in fishing | +| White all-round only | At anchor | Avoid — vessel at anchor | +| White + red all-round (vertical) | Pilot vessel | Pilot boat on duty | + +--- + +## Day Shapes + +| Shape | Meaning | +|---|---| +| ⚫ Black ball | Vessel at anchor | +| 🔻 Black cone (apex down) | Sailing vessel motorsailing | +| ⚫ ⚫ Two balls (vertical) | Not under command | +| ⚫ ◆ ⚫ Ball-diamond-ball | Restricted in ability to maneuver | +| ▬ Black cylinder | Constrained by draft | + +--- + +## Beaufort Wind Scale + +| Force | kt | Description | Sea State | +|---|---|---|---| +| 0 | < 1 | Calm | Mirror smooth | +| 1 | 1–3 | Light air | Ripples | +| 2 | 4–6 | Light breeze | Small wavelets | +| 3 | 7–10 | Gentle breeze | Scattered whitecaps | +| 4 | 11–16 | Moderate breeze | Moderate waves, frequent whitecaps | +| 5 | 17–21 | Fresh breeze | Long waves, many whitecaps, spray | +| 6 | 22–27 | Strong breeze | Large waves, spray, whitecaps everywhere | +| 7 | 28–33 | Near gale | Sea heaping up, foam streaks | +| 8 | 34–40 | Gale | Moderately high waves, edges blowing | +| 9 | 41–47 | Strong gale | High waves, dense foam, visibility affected | +| 10 | 48–55 | Storm | Very high waves, sea white, heavy sea roll | +| 11 | 56–63 | Violent storm | Exceptionally high waves | +| 12 | 64+ | Hurricane force | Air filled with foam, visibility nil | + +--- + +## Common Knots + +**Bowline** — fixed loop that won't slip. The classic sailing knot. "The rabbit comes out of the hole, round the tree, and back down the hole." + +**Cleat hitch** — securing a line to a cleat. Take a round turn around the base, then two figure-8 turns, then one locking hitch over the horn. + +**Clove hitch** — temporary attachment to a post or rail. Two half hitches; easy to adjust and release. + +**Figure-eight** — stopper knot. Prevents a line from running through a block or fairlead. + +**Round turn and two half hitches** — secure, adjustable attachment to a ring or rail. + +**Reef knot** — joining two lines of similar diameter. Right over left, left over right. Not for critical loads — use a sheet bend for mismatched diameters. + +**Sheet bend** — joining two lines of different diameter. The thicker line forms the loop. + +**Rolling hitch** — attaching to another line or spar under load. Grips when pulled along the spar. + +**Anchor hitch (fisherman's bend)** — the correct knot for attaching a line to an anchor. + +--- + +## Buoyage — IALA System B (Americas, Japan, Philippines, Korea) + +**Red right returning** — red buoys on the starboard side when returning from sea. + +| Mark | Shape | Color | Top Mark | Meaning | +|---|---|---|---|---| +| Port lateral | Can / pillar | Red | None | Keep to starboard (IALA-B: keep red to starboard) | +| Starboard lateral | Nun / cone | Green | Cone | Keep to port | +| Safe water | Sphere | Red + white vertical stripes | Sphere | Safe water on all sides | +| Isolated danger | Pillar / spar | Black + red bands | Two black balls | Isolated danger, safe water around it | +| Special mark | Any | Yellow | Yellow X | Special purpose (mooring, racing, TSS) | +| Cardinal (N) | Pillar / spar | Black over yellow | Two cones pointing up | Pass to the north | +| Cardinal (S) | Pillar / spar | Yellow over black | Two cones pointing down | Pass to the south | +| Cardinal (E) | Pillar / spar | Black-yellow-black bands | Cones base-to-base | Pass to the east | +| Cardinal (W) | Pillar / spar | Yellow-black-yellow bands | Cones point-to-point | Pass to the west | + +*IALA-A (Europe, Africa, most of Asia):* Red/green assignments are reversed — "red left returning." + +--- + +## VHF Radio Channels + +| Channel | Use | +|---|---| +| 16 | **International distress, safety, and calling** — always monitor | +| 22A | US Coast Guard working channel | +| 9 | Boater calling channel (US) | +| 6 | Ship-to-ship safety communications | +| 13 | Bridge-to-bridge (1 watt) | +| 70 | DSC digital selective calling — do not use for voice | +| 24, 25, 26, 27, 28 | Public correspondence (marine operator) | + +**MAYDAY procedure:** +1. MAYDAY MAYDAY MAYDAY +2. This is [vessel name × 3] +3. MAYDAY [vessel name] +4. Position +5. Nature of distress +6. Number of persons aboard +7. Any other information +8. Over + +--- + +## Tide and Current Basics + +**Flood** — tide coming in (rising sea level). +**Ebb** — tide going out (falling sea level). +**Slack** — the period of minimal current around high and low water. + +Rule of twelfths — tide rises/falls unevenly: +- Hour 1: 1/12 of range +- Hour 2: 2/12 of range +- Hour 3: 3/12 of range ← fastest +- Hour 4: 3/12 of range ← fastest +- Hour 5: 2/12 of range +- Hour 6: 1/12 of range + +**Spring tides** — larger range; occur near new and full moon. +**Neap tides** — smaller range; occur near quarter moons. + +--- + +## Distress Signals (Rule 37 / SOLAS) + +Any of these signals indicate distress and request assistance: +- Red parachute flare or red hand flare +- Orange smoke signal +- MAYDAY spoken over radio (Ch 16) +- SOS (···−−−···) by any signaling method +- Continuous foghorn sound +- Gun fired at approximately 1-minute intervals +- Flames on the vessel +- Slowly and repeatedly raising and lowering both arms +- Square flag with ball above or below it +- Orange dye in water +- Satellite EPIRB signal 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() } } diff --git a/android-app/app/src/main/res/drawable/ic_calendar.xml b/android-app/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 0000000..dd8030c --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_open_in_new.xml b/android-app/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..4645522 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/android-app/app/src/main/res/layout/fragment_learn.xml b/android-app/app/src/main/res/layout/fragment_learn.xml index f41e577..8813ba2 100644 --- a/android-app/app/src/main/res/layout/fragment_learn.xml +++ b/android-app/app/src/main/res/layout/fragment_learn.xml @@ -96,11 +96,11 @@ - + + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + android:text="›" + android:textSize="20sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + + + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + - + + + + + + + + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + @@ -211,22 +317,38 @@ - - + android:orientation="horizontal" + android:padding="16dp" + android:gravity="center_vertical"> - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + 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 510411b..8cb094a 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 @@ -54,6 +54,58 @@ + + + + + + + + + + + + + + + + + + + android:padding="20dp"> - + + android:layout_marginBottom="12dp" + style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> + + + + + + + + + + + + + + + + + -