summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt118
-rw-r--r--android-app/app/src/main/AndroidManifest.xml8
-rw-r--r--android-app/app/src/main/assets/docs/colregs_reference.md230
-rw-r--r--android-app/app/src/main/assets/docs/migrate_navionics.md56
-rw-r--r--android-app/app/src/main/assets/docs/migrate_sea_people.md59
-rw-r--r--android-app/app/src/main/assets/docs/sailing_reference.md206
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt216
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt305
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt584
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt83
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt23
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt10
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt27
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt81
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogEntry.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt79
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt96
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt110
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt163
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt95
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt91
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt297
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt408
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt85
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt86
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt117
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt58
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt170
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt119
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt275
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt225
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt142
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt42
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt158
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt45
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt198
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt56
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt250
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt171
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt59
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt3
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt0
-rw-r--r--android-app/app/src/main/res/drawable/ic_calendar.xml44
-rw-r--r--android-app/app/src/main/res/drawable/ic_crosshair.xml28
-rw-r--r--android-app/app/src/main/res/drawable/ic_layers.xml9
-rw-r--r--android-app/app/src/main/res/drawable/ic_learn.xml9
-rw-r--r--android-app/app/src/main/res/drawable/ic_open_in_new.xml30
-rw-r--r--android-app/app/src/main/res/drawable/ic_track_record.xml17
-rw-r--r--android-app/app/src/main/res/drawable/ic_vessel.xml26
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml168
-rw-r--r--android-app/app/src/main/res/layout/fragment_anchor_watch.xml79
-rw-r--r--android-app/app/src/main/res/layout/fragment_learn.xml205
-rw-r--r--android-app/app/src/main/res/layout/fragment_map.xml14
-rw-r--r--android-app/app/src/main/res/layout/fragment_pretrip_report.xml324
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml164
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml114
-rw-r--r--android-app/app/src/main/res/layout/fragment_vessel_registry.xml177
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml150
-rw-r--r--android-app/app/src/main/res/layout/item_condition_column.xml44
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml375
-rw-r--r--android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml310
-rw-r--r--android-app/app/src/main/res/layout/layout_nav_hud.xml164
-rw-r--r--android-app/app/src/main/res/layout/layout_track_summary_sheet.xml199
-rw-r--r--android-app/app/src/main/res/layout/sheet_dev_log.xml74
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml14
-rw-r--r--android-app/app/src/main/res/values-night/colors.xml40
-rwxr-xr-xandroid-app/app/src/main/res/values/colors.xml42
-rwxr-xr-xandroid-app/app/src/main/res/values/dimens.xml10
-rwxr-xr-xandroid-app/app/src/main/res/values/strings.xml10
-rwxr-xr-xandroid-app/app/src/main/res/values/themes.xml81
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt91
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt180
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt33
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt317
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt178
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt105
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt169
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt32
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt135
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt67
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt56
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt31
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt83
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt56
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt25
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt39
-rw-r--r--android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt0
149 files changed, 12029 insertions, 801 deletions
diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
index 0824abe..2d75cf4 100644
--- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
+++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
@@ -1,35 +1,129 @@
package org.terst.nav
-import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
- * Smoke test: verifies MainActivity launches without crashing.
+ * Smoke tests: verify the main UI surfaces launch and respond correctly.
+ * Run without GPS permission — LocationService is not started.
*
- * Run on an emulator/device via:
- * ./gradlew connectedDebugAndroidTest
- *
- * In CI, requires an emulator step before the Gradle task.
+ * Locally: ./gradlew connectedDebugAndroidTest
+ * CI: smoke-test job via android-emulator-runner
*/
@RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest {
+ companion object {
+ @org.junit.BeforeClass
+ @JvmStatic
+ fun setUpClass() {
+ NavApplication.isTesting = true
+ }
+ }
+
+ @get:Rule
+ val activityRule = ActivityScenarioRule(MainActivity::class.java)
+
@Before
fun setup() {
+ // Redundant but safe
NavApplication.isTesting = true
}
+ // ── Launch ─────────────────────────────────────────────────────────────
+
@Test
fun mainActivity_launches_withoutCrash() {
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- // If we reach this line the activity started without throwing.
- // onActivity lets us assert it is in a resumed state.
- scenario.onActivity { activity ->
- assert(!activity.isFinishing) { "MainActivity finished immediately after launch" }
- }
+ activityRule.scenario.onActivity { activity ->
+ assert(!activity.isFinishing) { "MainActivity finished immediately after launch" }
}
}
+
+ // ── Bottom nav ─────────────────────────────────────────────────────────
+
+ @Test
+ fun bottomNav_allFourTabs_areDisplayed() {
+ onView(withText("Map")).check(matches(isDisplayed()))
+ onView(withText("Instruments")).check(matches(isDisplayed()))
+ onView(withText("Log")).check(matches(isDisplayed()))
+ onView(withText("Safety")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_safetyTab_showsSafetyDashboard() {
+ onView(withText("Safety")).perform(click())
+ onView(withText("Safety Dashboard")).check(matches(isDisplayed()))
+ onView(withText("ACTIVATE MOB")).check(matches(isDisplayed()))
+ onView(withText("CONFIGURE ANCHOR WATCH")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_logTab_showsVoiceLogUi() {
+ onView(withText("Log")).perform(click())
+ onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_instrumentsTab_isSelectable() {
+ onView(withText("Instruments")).perform(click())
+ onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun instrumentSheet_surfacedReportButtons_areDisplayed() {
+ onView(withText("Instruments")).perform(click())
+ onView(withText("PRE-TRIP PLAN")).check(matches(isDisplayed()))
+ onView(withText("GENERATE REPORT")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_mapTab_returnsFromOverlay() {
+ onView(withText("Safety")).perform(click())
+ onView(withText("Map")).perform(click())
+ onView(withId(R.id.mapView)).check(matches(isDisplayed()))
+ }
+
+ // ── Persistent FABs ────────────────────────────────────────────────────
+
+ @Test
+ fun fabMob_isAlwaysVisible() {
+ onView(withContentDescription("Man Overboard")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabMob_remainsVisibleOnSafetyTab() {
+ onView(withText("Safety")).perform(click())
+ onView(withContentDescription("Man Overboard")).check(matches(isDisplayed()))
+ }
+
+ // ── Track recording ────────────────────────────────────────────────────
+
+ @Test
+ fun fabRecordTrack_isDisplayedWithRecordDescription() {
+ onView(withContentDescription("Record Track")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabRecordTrack_togglesToStopRecording_onFirstClick() {
+ onView(withContentDescription("Record Track")).perform(click())
+ onView(withContentDescription("Stop Recording")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabRecordTrack_togglesBackToRecord_onSecondClick() {
+ onView(withContentDescription("Record Track")).perform(click())
+ onView(withContentDescription("Stop Recording")).perform(click())
+ onView(withContentDescription("Record Track")).check(matches(isDisplayed()))
+ }
}
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index e2e311d..7cbafc7 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -8,6 +8,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
+ <!-- Documents/ write access — not needed on API 29+ (MediaStore handles it) -->
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28" />
<application
android:name=".NavApplication"
@@ -22,7 +26,9 @@
android:foregroundServiceType="location" />
<activity
android:name=".MainActivity"
- android:exported="true">
+ android:exported="true"
+ android:screenOrientation="portrait"
+ android:configChanges="orientation|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
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/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/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/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
new file mode 100644
index 0000000..d427a5d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
new file mode 100644
index 0000000..d6f685a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.data.storage
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+interface GribFileManager {
+ fun saveMetadata(file: GribFile)
+ fun listFiles(region: GribRegion): List<GribFile>
+ fun latestFile(region: GribRegion): GribFile?
+ fun delete(file: GribFile): Boolean
+ fun purgeOlderThan(before: Instant): Int
+ fun totalSizeBytes(): Long
+}
+
+class InMemoryGribFileManager : GribFileManager {
+ private val files = mutableListOf<GribFile>()
+ override fun saveMetadata(file: GribFile) { files.add(file) }
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
+ override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
+ override fun delete(file: GribFile): Boolean = files.remove(file)
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
+ override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..70f36d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import com.example.androidapp.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..6e565b7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import com.example.androidapp.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
new file mode 100644
index 0000000..cbe5c84
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.gps
+
+data class GpsPosition(
+ val latitude: Double, // degrees, positive = North
+ val longitude: Double, // degrees, positive = East
+ val sog: Double, // Speed Over Ground in knots
+ val cog: Double, // Course Over Ground in degrees true (0-360)
+ val timestampMs: Long, // Unix millis UTC
+ val accuracyMeters: Double? = null // estimated horizontal accuracy (1-sigma); null = unknown
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
new file mode 100644
index 0000000..0a315d4
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
@@ -0,0 +1,216 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import com.example.androidapp.wind.TrueWindCalculator
+import com.example.androidapp.wind.ApparentWind
+import com.example.androidapp.wind.TrueWindData
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the safety subsystem (Section 4.6 of COMPONENT_DESIGN.md).
+ *
+ * ## GPS sensor fusion
+ * The service accepts fixes from two independent sources:
+ * - **NMEA GPS** — dedicated marine GPS received via [updateNmeaGps] (higher priority)
+ * - **Android GPS** — device built-in location via [updateAndroidGps] (fallback)
+ *
+ * Selection policy (evaluated on every new fix):
+ * 1. Prefer NMEA when its most recent fix is no older than [nmeaStalenessThresholdMs].
+ * 2. When NMEA is marginally stale (older than [nmeaStalenessThresholdMs] but within
+ * [nmeaExtendedThresholdMs]) **and** Android GPS is also available, compare
+ * [GpsPosition.accuracyMeters]: keep NMEA if its reported accuracy is strictly better
+ * (lower metres). Fall back to Android when accuracy is unavailable or Android wins.
+ * 3. Fall back to Android GPS when NMEA is very stale (beyond [nmeaExtendedThresholdMs]).
+ * 4. Use stale NMEA only when Android GPS has never provided a fix.
+ * 5. [bestPosition] is null until at least one source has reported.
+ *
+ * Call [updateSensorData] whenever new NMEA or Signal K sensor data arrives and
+ * [updateCurrentConditions] when a fresh marine-forecast response is received.
+ * Use [snapshot] to capture a point-in-time reading at safety-critical moments
+ * such as MOB activation.
+ *
+ * @param nmeaStalenessThresholdMs Maximum age (ms) of an NMEA fix before it enters the
+ * quality-comparison zone. Default: 5 000 ms.
+ * @param nmeaExtendedThresholdMs Maximum age (ms) up to which a marginally-stale NMEA fix
+ * can still win over Android if its [GpsPosition.accuracyMeters] is strictly better.
+ * Must be ≥ [nmeaStalenessThresholdMs]. Default: 10 000 ms.
+ * @param clockMs Injectable clock for unit-testable staleness checks.
+ */
+class LocationService(
+ private val windCalculator: TrueWindCalculator = TrueWindCalculator(),
+ private val nmeaStalenessThresholdMs: Long = 5_000L,
+ private val nmeaExtendedThresholdMs: Long = 10_000L,
+ private val clockMs: () -> Long = System::currentTimeMillis
+) {
+
+ private val _latestSensor = MutableStateFlow<SensorData?>(null)
+ /** The most recently received unified sensor reading. */
+ val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow()
+
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _currentSpeedKt = MutableStateFlow<Double?>(null)
+ private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
+
+ // ── GPS sensor fusion state ───────────────────────────────────────────────
+
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ /**
+ * The best available GPS fix, selected from NMEA and Android sources according
+ * to the fusion policy described in the class KDoc. Null until at least one
+ * source reports a fix.
+ */
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
+ /**
+ * Ingest a new sensor reading. If the reading carries apparent wind, boat speed,
+ * and heading, true wind is resolved immediately via [TrueWindCalculator] and
+ * stored in [latestTrueWind].
+ */
+ fun updateSensorData(data: SensorData) {
+ _latestSensor.value = data
+
+ val aws = data.apparentWindSpeedKt
+ val awa = data.apparentWindAngleDeg
+ val bsp = data.speedOverGroundKt // use SOG as proxy when BSP is absent
+ val hdg = data.headingTrueDeg
+
+ if (aws != null && awa != null && bsp != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = aws, angleDeg = awa),
+ bsp = bsp,
+ hdgDeg = hdg
+ )
+ }
+ }
+
+ // ── GPS source ingestion ──────────────────────────────────────────────────
+
+ /**
+ * Ingest a new GPS fix from the NMEA source (e.g. a marine chartplotter or
+ * NMEA multiplexer). Triggers a fusion re-evaluation.
+ */
+ fun updateNmeaGps(position: GpsPosition) {
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Ingest a new GPS fix from the Android system location provider.
+ * Triggers a fusion re-evaluation.
+ */
+ fun updateAndroidGps(position: GpsPosition) {
+ lastAndroidPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Selects the best GPS fix and updates [bestPosition] / [activeGpsSource].
+ *
+ * Priority tiers (in order):
+ * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) — always preferred.
+ * 2. Marginally-stale NMEA (age in (primary, extended] threshold) when Android is
+ * also available — keep NMEA only if its [GpsPosition.accuracyMeters] is strictly
+ * better than Android's; otherwise use Android.
+ * 3. Android GPS (any age) once NMEA is beyond the extended threshold.
+ * 4. Stale NMEA — used as last resort when Android has never reported.
+ */
+ private fun recomputeBestPosition() {
+ val now = clockMs()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ // Quality tie-break: NMEA wins only when it has a strictly better accuracy.
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA // only source, however stale
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ // ── private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Returns true when this fix carries an accuracy estimate that is numerically
+ * smaller (i.e. better) than [other]'s. Returns false when either estimate is
+ * absent — conservatively preferring the other source when quality is unknown.
+ */
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ /**
+ * Update the ocean current conditions from the latest marine-forecast response.
+ *
+ * @param speedKt Current speed in knots (null to clear)
+ * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear)
+ */
+ fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) {
+ _currentSpeedKt.value = speedKt
+ _currentDirectionDeg.value = directionDeg
+ }
+
+ /**
+ * Captures a snapshot of wind and current conditions at the current moment.
+ *
+ * All fields are nullable — only data that was available at snapshot time is
+ * populated. This snapshot is intended to be logged alongside a [MobEvent]
+ * at the instant of MOB activation.
+ */
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = _currentSpeedKt.value,
+ currentDirectionDeg = _currentDirectionDeg.value
+ )
+ }
+}
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ *
+ * @param windSpeedKt True Wind Speed in knots; null if sensors were unavailable.
+ * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable.
+ * @param currentSpeedKt Ocean current speed in knots; null if forecast was unavailable.
+ * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..d4cf50d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package com.example.androidapp.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..78ea834
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package com.example.androidapp.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
new file mode 100644
index 0000000..b1b186a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
@@ -0,0 +1,94 @@
+package com.example.androidapp.nmea
+
+import com.example.androidapp.gps.GpsPosition
+import java.util.Calendar
+import java.util.TimeZone
+
+class NmeaParser {
+
+ /**
+ * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the
+ * sentence is void (status=V), malformed, or cannot be parsed.
+ *
+ * Supported talker IDs: GP, GN, and any other standard prefix.
+ * SOG and COG default to 0.0 when the fields are absent.
+ */
+ fun parseRmc(sentence: String): GpsPosition? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null
+
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
+
+ val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
+ val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
+
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
+
+ val timestampMs = parseTimestamp(
+ timeStr = fields.getOrNull(1) ?: "",
+ dateStr = fields.getOrNull(9) ?: ""
+ )
+ if (timestampMs == 0L) return null
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ */
+ private fun parseNmeaDegrees(value: String): Double {
+ val raw = value.toDoubleOrNull() ?: return 0.0
+ val degrees = (raw / 100.0).toInt()
+ val minutes = raw - degrees * 100.0
+ return degrees + minutes / 60.0
+ }
+
+ /**
+ * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC.
+ * Returns 0 on any parse failure.
+ */
+ private fun parseTimestamp(timeStr: String, dateStr: String): Long {
+ return try {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.isLenient = false
+
+ if (dateStr.length >= 6) {
+ val day = dateStr.substring(0, 2).toInt()
+ val month = dateStr.substring(2, 4).toInt() - 1
+ val yy = dateStr.substring(4, 6).toInt()
+ val year = if (yy < 70) 2000 + yy else 1900 + yy
+ cal.set(Calendar.YEAR, year)
+ cal.set(Calendar.MONTH, month)
+ cal.set(Calendar.DAY_OF_MONTH, day)
+ }
+
+ if (timeStr.length >= 6) {
+ val hours = timeStr.substring(0, 2).toInt()
+ val minutes = timeStr.substring(2, 4).toInt()
+ val seconds = timeStr.substring(4, 6).toInt()
+ val millis = if (timeStr.length > 7) {
+ val fracStr = timeStr.substring(7)
+ (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ } else 0
+ cal.set(Calendar.HOUR_OF_DAY, hours)
+ cal.set(Calendar.MINUTE, minutes)
+ cal.set(Calendar.SECOND, seconds)
+ cal.set(Calendar.MILLISECOND, millis)
+ }
+
+ cal.timeInMillis
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
new file mode 100644
index 0000000..60a5918
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package com.example.androidapp.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..901fdbc
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
new file mode 100644
index 0000000..02988d1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package com.example.androidapp.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..f544f63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.safety
+
+import kotlin.math.sqrt
+
+/**
+ * Holds UI-facing state for the anchor watch setup screen and provides
+ * the suggested watch-circle radius derived from depth and rode out.
+ */
+class AnchorWatchState {
+
+ /**
+ * Returns the recommended watch-circle radius (metres) for the given depth
+ * and amount of rode deployed.
+ *
+ * Uses the Pythagorean formula sqrt(rode² - vertical²) when the geometry is
+ * valid (rode > depth + freeboard). Falls back to [rodeOutM] itself as the
+ * maximum possible swing radius when the rode is too short to form a catenary angle.
+ */
+ fun calculateRecommendedWatchCircleRadius(depthM: Double, rodeOutM: Double): Double {
+ val vertical = depthM + 2.0 // 2 m default freeboard
+ return if (rodeOutM > vertical) sqrt(rodeOutM * rodeOutM - vertical * vertical)
+ else rodeOutM
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
new file mode 100644
index 0000000..2bdbf6c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
@@ -0,0 +1,88 @@
+package com.example.androidapp.tide
+
+import com.example.androidapp.data.model.TidePrediction
+import com.example.androidapp.data.model.TideStation
+import kotlin.math.cos
+
+/**
+ * Computes harmonic tide predictions using the standard formula:
+ * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
+ *
+ * where:
+ * Z0 = datum offset (mean water level above chart datum, metres)
+ * Hi = amplitude of constituent i (metres)
+ * ωi = angular speed of constituent i (degrees / hour)
+ * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC)
+ * φi = phase lag (degrees)
+ */
+object HarmonicTideCalculator {
+
+ /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */
+ internal const val EPOCH_MS = 946_684_800_000L
+
+ /**
+ * Predict the tide height at a single moment.
+ *
+ * @param station Tide station with harmonic constituents.
+ * @param timestampMs Unix epoch milliseconds for the desired time.
+ * @return Predicted height in metres above chart datum.
+ */
+ fun predictHeight(station: TideStation, timestampMs: Long): Double {
+ val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0
+ var height = station.datumOffsetMeters
+ for (c in station.constituents) {
+ val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg
+ height += c.amplitudeMeters * cos(Math.toRadians(angleDeg))
+ }
+ return height
+ }
+
+ /**
+ * Predict tide heights over a time range at regular intervals.
+ *
+ * @param station Tide station.
+ * @param fromMs Start of range (Unix milliseconds, inclusive).
+ * @param toMs End of range (Unix milliseconds, inclusive).
+ * @param intervalMs Time step in milliseconds (must be positive).
+ * @return List of [TidePrediction] ordered by ascending timestamp.
+ */
+ fun predictRange(
+ station: TideStation,
+ fromMs: Long,
+ toMs: Long,
+ intervalMs: Long
+ ): List<TidePrediction> {
+ require(intervalMs > 0) { "intervalMs must be positive" }
+ require(fromMs <= toMs) { "fromMs must not exceed toMs" }
+ val predictions = mutableListOf<TidePrediction>()
+ var t = fromMs
+ while (t <= toMs) {
+ predictions += TidePrediction(t, predictHeight(station, t))
+ t += intervalMs
+ }
+ return predictions
+ }
+
+ /**
+ * Find high and low water events from a pre-computed prediction series.
+ *
+ * Detects local maxima (high water) and minima (low water) by comparing
+ * each interior sample with its immediate neighbours.
+ *
+ * @param predictions Ordered list of tide predictions (at least 3 points).
+ * @return Subset list containing only high/low turning points.
+ */
+ fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> {
+ if (predictions.size < 3) return emptyList()
+ val result = mutableListOf<TidePrediction>()
+ for (i in 1 until predictions.size - 1) {
+ val prev = predictions[i - 1].heightMeters
+ val curr = predictions[i].heightMeters
+ val next = predictions[i + 1].heightMeters
+ val isMax = curr >= prev && curr >= next
+ val isMin = curr <= prev && curr <= next
+ if (isMax || isMin) result += predictions[i]
+ }
+ return result
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..289a857
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package com.example.androidapp.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import com.example.androidapp.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
new file mode 100644
index 0000000..01656a3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..db32163
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package com.example.androidapp.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
new file mode 100644
index 0000000..78e9558
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
deleted file mode 100644
index 0c63662..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.terst.nav
-
-import android.location.Location
-import kotlin.math.*
-
-data class AnchorWatchState(
- val anchorLocation: Location? = null,
- val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
- val setTimeMillis: Long = 0L,
- val isActive: Boolean = false
-) {
- companion object {
- const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
-
- /**
- * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
- * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out))
- *
- * @param depthMeters Depth from surface to seabed in meters.
- * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters.
- * @param rodeOutMeters Length of chain/rode deployed in meters.
- * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid.
- */
- fun calculateRecommendedWatchCircleRadius(
- depthMeters: Double,
- freeboardMeters: Double,
- rodeOutMeters: Double
- ): Double {
- if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) {
- return 0.0 // Invalid inputs
- }
-
- val totalVerticalDistance = depthMeters + freeboardMeters
-
- // Ensure we don't take asin of a value > 1 or < -1
- if (totalVerticalDistance > rodeOutMeters) {
- // Rode is too short for the depth+freeboard, effectively boat is directly above anchor
- // In this case, the watch circle radius is 0, or very small.
- return 0.0
- }
-
- // angle = asin( (Depth + Freeboard) / Rode Out )
- val angle = asin(totalVerticalDistance / rodeOutMeters)
-
- // Watch circle radius = Rode Out * cos(angle)
- return rodeOutMeters * cos(angle)
- }
- }
-
- fun isDragging(currentLocation: Location): Boolean {
- anchorLocation ?: return false // Cannot drag if anchor not set
- if (!isActive) return false // Not active, so not dragging
-
- val distance = anchorLocation.distanceTo(currentLocation)
- return distance > watchCircleRadiusMeters
- }
-}
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 138fc6c..18e0907 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
@@ -20,12 +20,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
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
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.data.model.SensorData
+import org.terst.nav.safety.AnchorWatchState
+import org.terst.nav.wind.TrueWindCalculator
+import org.terst.nav.wind.ApparentWind
+import org.terst.nav.wind.TrueWindData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.CoroutineScope
@@ -34,22 +41,23 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
-data class GpsData(
- val latitude: Double,
- val longitude: Double,
- val speedOverGround: Float, // m/s
- val courseOverGround: Float // degrees
-) {
- fun toLocation(): Location {
- val location = Location("GpsData")
- location.latitude = latitude
- location.longitude = longitude
- location.speed = speedOverGround
- location.bearing = courseOverGround
- return location
- }
-}
-
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the navigation subsystem.
+ */
class LocationService : Service() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
@@ -60,22 +68,30 @@ class LocationService : Service() {
private lateinit var nmeaStreamManager: NmeaStreamManager
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val windCalculator = TrueWindCalculator()
+
+ // GPS sensor fusion state
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+ private val nmeaStalenessThresholdMs: Long = 5_000L
+ private val nmeaExtendedThresholdMs: Long = 10_000L
+
private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
private val NOTIFICATION_ID = 123
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
+ private var isAlarmTriggered = false
override fun onCreate() {
super.onCreate()
Log.d("LocationService", "Service created")
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
- anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ anchorAlarmManager = AnchorAlarmManager(this)
barometerSensorManager = BarometerSensorManager(this)
nmeaParser = NmeaParser()
nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
createNotificationChannel()
- // Observe barometer status and update our public state
+ // Observe barometer status
serviceScope.launch {
barometerSensorManager.barometerStatus.collect { status ->
_barometerStatus.value = status
@@ -84,14 +100,17 @@ class LocationService : Service() {
// Collect NMEA GPS positions
serviceScope.launch {
- nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
- _nmeaGpsPositionFlow.emit(gpsPosition)
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { position ->
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ _nmeaGpsPositionFlow.emit(position)
}
}
// Collect NMEA Wind Data
serviceScope.launch {
nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ updateTrueWindFromNmea(windData)
_nmeaWindDataFlow.emit(windData)
}
}
@@ -110,26 +129,29 @@ class LocationService : Service() {
}
}
- // Mock tidal current data generator
+ // Collect NMEA Boat Speed Data
serviceScope.launch {
- while (true) {
- val currents = MockTidalCurrentGenerator.generateMockCurrents()
- _tidalCurrentState.update { it.copy(currents = currents) }
- kotlinx.coroutines.delay(60000) // Update every minute
+ nmeaStreamManager.nmeaBoatSpeedData.collectLatest { bspData ->
+ _nmeaBoatSpeedDataFlow.emit(bspData)
}
}
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
+ val position = GpsPosition(
latitude = location.latitude,
longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
+ sog = location.speed * 1.94384, // m/s to knots
+ cog = location.bearing.toDouble(),
+ timestampMs = location.time,
+ accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null
)
+ lastAndroidPosition = position
+ recomputeBestPosition()
+
serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
+ _locationFlow.emit(position)
}
// Check for anchor drag if anchor watch is active
@@ -139,32 +161,71 @@ class LocationService : Service() {
}
}
- /**
- * Checks if the current location is outside the anchor watch circle.
- */
+ private fun recomputeBestPosition() {
+ val now = System.currentTimeMillis()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ private fun updateTrueWindFromNmea(wind: WindData) {
+ val sog = _bestPosition.value?.sog
+ val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue
+
+ if (sog != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle),
+ bsp = sog, // Use SOG as proxy for BSP if BSP is not available
+ hdgDeg = hdg
+ )
+ }
+ }
+
private fun checkAnchorDrag(location: Location) {
_anchorWatchState.update { currentState ->
if (currentState.isActive && currentState.anchorLocation != null) {
val isDragging = currentState.isDragging(location)
if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!")
if (!isAlarmTriggered) {
anchorAlarmManager.startAlarm()
isAlarmTriggered = true
}
} else {
- Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
if (isAlarmTriggered) {
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
}
- } else {
- // If anchor watch is not active, ensure alarm is stopped
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
+ } else if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
}
currentState
}
@@ -173,24 +234,24 @@ class LocationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Starting foreground service")
startForeground(NOTIFICATION_ID, createNotification())
serviceScope.launch {
- _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL
+ _currentPowerMode.emit(PowerMode.FULL)
startLocationUpdatesInternal(PowerMode.FULL)
}
barometerSensorManager.start()
- nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ val flags = (application as NavApplication).featureFlags
+ if (flags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) {
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
}
ACTION_STOP_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Stopping foreground service")
stopLocationUpdatesInternal()
barometerSensorManager.stop()
nmeaStreamManager.stop()
stopSelf()
}
ACTION_START_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
serviceScope.launch {
startAnchorWatch(radius)
@@ -198,12 +259,10 @@ class LocationService : Service() {
}
}
ACTION_STOP_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
stopAnchorWatch()
- setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch
+ setPowerMode(PowerMode.FULL)
}
ACTION_UPDATE_WATCH_RADIUS -> {
- Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
updateWatchCircleRadius(radius)
}
@@ -211,32 +270,35 @@ class LocationService : Service() {
val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
_tidalCurrentState.update { it.copy(isVisible = isVisible) }
}
+ ACTION_START_NMEA -> {
+ Log.d("LocationService", "Starting NMEA stream")
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
+ ACTION_STOP_NMEA -> {
+ Log.d("LocationService", "Stopping NMEA stream")
+ nmeaStreamManager.stop()
+ }
}
return START_NOT_STICKY
}
- override fun onBind(intent: Intent?): IBinder? {
- return null // Not a bound service
- }
+ override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
- Log.d("LocationService", "Service destroyed")
stopLocationUpdatesInternal()
anchorAlarmManager.stopAlarm()
barometerSensorManager.stop()
- nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
+ nmeaStreamManager.stop()
_anchorWatchState.value = AnchorWatchState(isActive = false)
- isAlarmTriggered = false // Reset alarm trigger state
- serviceScope.cancel() // Cancel the coroutine scope
+ isAlarmTriggered = false
+ serviceScope.cancel()
}
-
@SuppressLint("MissingPermission")
private fun startLocationUpdatesInternal(powerMode: PowerMode) {
- Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms")
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
- .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
@@ -246,22 +308,15 @@ class LocationService : Service() {
}
private fun stopLocationUpdatesInternal() {
- Log.d("LocationService", "Removing location updates")
fusedLocationClient.removeLocationUpdates(locationCallback)
}
fun setPowerMode(powerMode: PowerMode) {
serviceScope.launch {
if (_currentPowerMode.value != powerMode) {
- // Emit the new power mode first
_currentPowerMode.emit(powerMode)
- Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.")
- // Stop current updates if running
stopLocationUpdatesInternal()
- // Start new updates with the new power mode's interval
startLocationUpdatesInternal(powerMode)
- } else {
- Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.")
}
}
}
@@ -278,25 +333,15 @@ class LocationService : Service() {
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_IMMUTABLE
- )
-
+ val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Sailing Companion")
- .setContentText("Tracking your location in the background...")
+ .setContentText("Tracking your location...")
.setSmallIcon(R.drawable.ic_anchor)
.setContentIntent(pendingIntent)
.build()
}
- /**
- * Starts the anchor watch with the current location as the anchor point.
- * @param radiusMeters The watch circle radius in meters.
- */
@SuppressLint("MissingPermission")
suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
val lastLocation = fusedLocationClient.lastLocation.await()
@@ -307,29 +352,27 @@ class LocationService : Service() {
setTimeMillis = System.currentTimeMillis(),
isActive = true
) }
- Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
- } ?: run {
- Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
- // Handle error, e.g., show a toast to the user
}
}
- /**
- * Stops the anchor watch.
- */
fun stopAnchorWatch() {
_anchorWatchState.update { AnchorWatchState(isActive = false) }
- Log.i("AnchorWatch", "Anchor watch stopped.")
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
- /**
- * Updates the watch circle radius.
- */
fun updateWatchCircleRadius(radiusMeters: Double) {
_anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
- Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
+ }
+
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = null, // TODO: Pull from latest forecast
+ currentDirectionDeg = null
+ )
}
companion object {
@@ -339,55 +382,51 @@ class LocationService : Service() {
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
- const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
+ const val ACTION_START_NMEA = "ACTION_START_NMEA"
+ const val ACTION_STOP_NMEA = "ACTION_STOP_NMEA"
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
+ const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
+
+ private const val NMEA_GATEWAY_IP = "192.168.1.1"
+ private const val NMEA_GATEWAY_PORT = 10110
+
+ private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
- // NMEA Gateway configuration (example values - these should ideally be configurable by the user)
- private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address
- private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port
-
- // Publicly accessible flows
- val locationFlow: SharedFlow<GpsData>
- get() = _locationFlow
- val anchorWatchState: StateFlow<AnchorWatchState>
- get() = _anchorWatchState
- val tidalCurrentState: StateFlow<TidalCurrentState>
- get() = _tidalCurrentState
- val barometerStatus: StateFlow<BarometerStatus>
- get() = _barometerStatus
-
- // NMEA Data Flows
- val nmeaGpsPositionFlow: SharedFlow<GpsPosition>
- get() = _nmeaGpsPositionFlow
- val nmeaWindDataFlow: SharedFlow<WindData>
- get() = _nmeaWindDataFlow
- val nmeaDepthDataFlow: SharedFlow<DepthData>
- get() = _nmeaDepthDataFlow
- val nmeaHeadingDataFlow: SharedFlow<HeadingData>
- get() = _nmeaHeadingDataFlow
-
- private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState
+
private val _barometerStatus = MutableStateFlow(BarometerStatus())
+ val barometerStatus: StateFlow<BarometerStatus> get() = _barometerStatus
- // Private NMEA Data Flows
- private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow
+
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1)
+ val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow
+
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1)
+ val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow
+
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1)
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow
+
+ private val _nmeaBoatSpeedDataFlow = MutableSharedFlow<BoatSpeedData>(replay = 1)
+ val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> get() = _nmeaBoatSpeedDataFlow
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
- val currentPowerMode: StateFlow<PowerMode>
- get() = _currentPowerMode
+ val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode
+
+ private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val tidalCurrentState: StateFlow<TidalCurrentState> get() = _tidalCurrentState
}
}
-
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 d9dba73..eaab575 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
@@ -8,46 +8,96 @@ import android.graphics.Canvas
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
-import android.util.Log
+import android.view.HapticFeedbackConstants
import android.view.View
import android.widget.FrameLayout
+import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.button.MaterialButton
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import java.util.Locale
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
+import org.terst.nav.settings.HardwareSource
+import org.terst.nav.thermal.ThermalState
+import org.terst.nav.thermal.thermalFlow
import org.terst.nav.ui.*
import org.terst.nav.ui.doc.DocFragment
+import org.terst.nav.ui.map.ParticleWindView
import org.terst.nav.ui.safety.SafetyFragment
+import org.terst.nav.ui.vessel.VesselRegistryFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
+import org.terst.nav.data.model.MarineConditions
+import org.terst.nav.safety.AnchorWatchState
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
+ private val featureFlags get() = (application as NavApplication).featureFlags
+
private var mapView: MapView? = null
private var mobHandler: MobHandler? = null
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
- private var anchorWatchHandler: AnchorWatchHandler? = null
-
+ private val loadedStyleFlow = MutableStateFlow<Style?>(null)
+ private lateinit var layerManager: MapLayerManager
+ private var particleWindView: ParticleWindView? = null
+ private var weatherLoaded = false
+
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
+ private lateinit var fabRecordTrack: FloatingActionButton
+ private lateinit var fabRecenter: MaterialButton
+ private lateinit var bottomSheet: CardView
+ private lateinit var bottomNav: BottomNavigationView
+ private lateinit var mapCrosshair: View
+
+ // HUD value TextViews
+ private lateinit var hudSog: TextView
+ private lateinit var hudCog: TextView
+ private lateinit var hudBsp: TextView
+ private lateinit var hudDepth: TextView
+
+ // HUD unit label TextViews
+ private lateinit var unitHudSog: TextView
+ private lateinit var unitHudBsp: TextView
+ private lateinit var unitHudDepth: TextView
+
+ // Instrument sheet unit label TextViews
+ private lateinit var unitTws: TextView
+ private lateinit var unitTemp: TextView
+ private lateinit var unitBaro: TextView
+ private lateinit var unitCurrSpd: TextView
+ private lateinit var unitWaveHt: TextView
+ private lateinit var unitSwellHt: TextView
+
+ // Unit preferences
+ private lateinit var unitPrefs: UnitPrefs
+
+ // Cached raw values for unit-change refresh
+ private var lastConditions: MarineConditions? = null
+ private var lastBaroHpa: Float? = null
+ private var lastSogKnots: Double? = null
+ private var lastBspKnots: Double? = null
+ private var lastDepthMeters: Double? = null
+
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -55,6 +105,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onResume() {
super.onResume()
+ if (!NavApplication.isTesting) mapView?.onResume()
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
@@ -66,31 +117,173 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
+ viewModel.featureFlags = featureFlags
+ unitPrefs = UnitPrefs(this)
checkForegroundPermissions()
initializeUI()
}
private fun initializeUI() {
fragmentContainer = findViewById(R.id.fragment_container)
- setupMap()
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabRecenter = findViewById(R.id.fab_recenter)
+ bottomSheet = findViewById(R.id.instrument_bottom_sheet)
+ bottomNav = findViewById(R.id.bottom_navigation)
+ mapCrosshair = findViewById(R.id.map_crosshair)
+
+ // HUD value views
+ hudSog = findViewById(R.id.hud_sog)
+ hudCog = findViewById(R.id.hud_cog)
+ hudBsp = findViewById(R.id.hud_bsp)
+ hudDepth = findViewById(R.id.hud_depth)
+
+ // HUD unit label views
+ unitHudSog = findViewById(R.id.unit_hud_sog)
+ unitHudBsp = findViewById(R.id.unit_hud_bsp)
+ unitHudDepth = findViewById(R.id.unit_hud_depth)
+
+ // Instrument sheet unit label views
+ unitTws = findViewById(R.id.unit_tws)
+ unitTemp = findViewById(R.id.unit_temp)
+ unitBaro = findViewById(R.id.unit_baro)
+ unitCurrSpd = findViewById(R.id.unit_curr_spd)
+ unitWaveHt = findViewById(R.id.unit_wave_ht)
+ unitSwellHt = findViewById(R.id.unit_swell_ht)
+
+ // Init HUD with dashes; apply correct unit labels from stored prefs
+ hudSog.text = "—"
+ hudCog.text = "—"
+ hudBsp.text = "—"
+ hudDepth.text = "—"
+ applyUnitLabels()
+
+ NavLogger.i("app", "started")
setupBottomSheet()
setupBottomNavigation()
setupHandlers()
-
- findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
- onActivateMob()
+ setupMap()
+ // Hidden dev log — long-press the drag handle on the instrument sheet
+ findViewById<View>(R.id.drag_handle).setOnLongClickListener {
+ DevLogSheet().show(supportFragmentManager, "dev_log")
+ true
+ }
+
+ fabRecordTrack.setOnClickListener {
+ if (!viewModel.isRecording.value) viewModel.startTrack()
+ }
+ val tvTrackStats = findViewById<TextView>(R.id.tv_track_stats)
+
+ fabRecordTrack.setOnLongClickListener {
+ it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ if (viewModel.isRecording.value) {
+ viewModel.stopTrack()
+ } else {
+ viewModel.injectTestTrack()
+ }
+ true
+ }
+
+ fabRecenter.setOnClickListener { mapHandler?.recenter() }
+
+ // Observe immediately — pure UI state, not gated on GPS permission
+ lifecycleScope.launch {
+ viewModel.isRecording.collect { recording ->
+ val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
+ fabRecordTrack.setImageResource(icon)
+ fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
+ if (!recording) tvTrackStats.visibility = View.GONE
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.trackStats.collect { stats ->
+ if (stats != null) {
+ tvTrackStats.text = "● REC %.2fnm %s %.1fkt avg".format(
+ stats.distanceNm, stats.durationFormatted, stats.avgSogKnots
+ )
+ tvTrackStats.visibility = View.VISIBLE
+ } else {
+ tvTrackStats.visibility = View.GONE
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.trackSummary.collect { summary ->
+ org.terst.nav.track.TrackSummarySheet
+ .from(summary, viewModel.trackStartMs)
+ .show(supportFragmentManager, "track_summary")
+ }
}
}
+ /** Updates all static unit label TextViews to match current [unitPrefs]. */
+ private fun applyUnitLabels() {
+ unitHudSog.text = unitPrefs.speedUnitLabel()
+ unitHudBsp.text = unitPrefs.speedUnitLabel()
+ unitHudDepth.text = unitPrefs.depthUnitLabel()
+ unitTws.text = unitPrefs.speedUnitLabel()
+ unitTemp.text = unitPrefs.tempUnitLabel()
+ unitBaro.text = unitPrefs.pressureUnitLabel()
+ unitCurrSpd.text = unitPrefs.speedUnitLabel()
+ unitWaveHt.text = unitPrefs.depthUnitLabel()
+ unitSwellHt.text = unitPrefs.depthUnitLabel()
+ }
+
+ /**
+ * Re-formats every cached raw value with the current unit prefs.
+ * Called after the user changes a unit in the settings sheet.
+ */
+ private fun refreshUnits() {
+ applyUnitLabels()
+ lastSogKnots?.let { hudSog.text = unitPrefs.formatSpeed(it) }
+ lastBspKnots?.let { hudBsp.text = unitPrefs.formatSpeed(it) }
+ lastDepthMeters?.let { hudDepth.text = unitPrefs.formatDepth(it) }
+ lastConditions?.let { applyConditions(it) }
+ lastBaroHpa?.let { instrumentHandler?.updateConditions(baroHpa = it) }
+ }
+
+ private fun applyConditions(c: MarineConditions) {
+ instrumentHandler?.updateConditions(
+ twsKt = c.windSpeedKt,
+ twsBearingDeg = c.windDirDeg?.toFloat(),
+ tempC = c.tempC,
+ currSpdKt = c.currentSpeedKt,
+ currDirDeg = c.currentDirDeg?.toFloat(),
+ waveHeightM = c.waveHeightM,
+ waveDirDeg = c.waveDirDeg?.toFloat(),
+ swellHeightM = c.swellHeightM,
+ swellDirDeg = c.swellDirDeg?.toFloat(),
+ swellPeriodS = c.swellPeriodS
+ )
+ instrumentHandler?.updateWaveState(
+ swellHeightM = c.swellHeightM?.toFloat() ?: 0.9f,
+ swellPeriodSec = c.swellPeriodS?.toFloat() ?: 10f,
+ windWaveHeightM = c.waveHeightM?.toFloat() ?: 0.45f,
+ windSpeedKt = c.windSpeedKt?.toFloat() ?: 0f
+ )
+ }
+
private fun setupBottomSheet() {
- val sheet = findViewById<View>(R.id.instrument_bottom_sheet)
- bottomSheetBehavior = BottomSheetBehavior.from(sheet)
+ bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
+ private fun buildLayerPickerSheet(): LayerPickerSheet {
+ val currentStyle = loadedStyleFlow.value
+ return LayerPickerSheet(
+ manager = layerManager,
+ onBaseChanged = { preset -> currentStyle?.let { layerManager.setBasePreset(it, preset) } },
+ onWindChanged = { enabled -> currentStyle?.let { layerManager.setWindEnabled(it, enabled) } },
+ onParticlesChanged = { enabled ->
+ layerManager.setParticlesEnabled(enabled)
+ particleWindView?.visibility = if (enabled) View.VISIBLE else View.GONE
+ },
+ unitPrefs = unitPrefs,
+ onUnitsChanged = { refreshUnits() }
+ )
+ }
+
private fun setupBottomNavigation() {
- val nav = findViewById<BottomNavigationView>(R.id.bottom_navigation)
- nav.setOnItemSelectedListener { item ->
+ bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_map -> {
hideOverlays()
@@ -99,10 +292,9 @@ 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 -> {
+ buildLayerPickerSheet().show(supportFragmentManager, "layer_picker")
+ bottomNav.post { bottomNav.selectedItemId = R.id.nav_map }
true
}
R.id.nav_log -> {
@@ -117,6 +309,18 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
true
}
+ R.id.nav_vessel -> {
+ showOverlay(VesselRegistryFragment())
+ bottomSheetBehavior.isHideable = true
+ 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
}
}
@@ -124,6 +328,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()
@@ -131,60 +336,78 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun hideOverlays() {
fragmentContainer.visibility = View.GONE
- // Clear backstack if needed
+ fabRecordTrack.visibility = View.VISIBLE
+ // Re-apply cached conditions in case they arrived while the overlay was covering the sheet
+ lastConditions?.let { applyConditions(it) }
+ }
+
+ override fun onQuitRequested() {
+ if (viewModel.isRecording.value) {
+ androidx.appcompat.app.AlertDialog.Builder(this)
+ .setMessage("Recording in progress. Quit and discard the current track?")
+ .setPositiveButton("Quit") { _, _ -> exitApp() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ } else {
+ exitApp()
+ }
+ }
+
+ private fun exitApp() {
+ stopService(Intent(this, LocationService::class.java))
+ finishAffinity()
}
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm)
- // In a real redesign, we'd show a specialized MOB fragment
- // For now, keep existing handler logic but maybe toggle visibility
mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer)
- // Ensure MOB UI is visible - we might need to add it back to activity_main if removed
}
}
}
override fun onConfigureAnchor() {
- anchorWatchHandler?.toggleVisibility()
+ showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler())
+ }
+
+ override fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ viewModel.onHardwareSourceChanged(source, enabled)
+ if (source == HardwareSource.NMEA_INSTRUMENTS) {
+ val action = if (enabled) LocationService.ACTION_START_NMEA else LocationService.ACTION_STOP_NMEA
+ startService(Intent(this, LocationService::class.java).apply { this.action = action })
+ }
}
private fun setupHandlers() {
- // ... (Keep existing handler initialization, just update view IDs as needed)
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),
- valueVmg = findViewById(R.id.value_vmg),
- valueDepth = findViewById(R.id.value_depth),
- valuePolarPct = findViewById(R.id.value_polar_pct),
- valueBaro = findViewById(R.id.value_baro),
- labelTrend = null, // simplified
- barometerTrendView = null, // simplified
- polarDiagramView = findViewById(R.id.polar_diagram_view)
+ 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),
+ valueSwellPer = findViewById(R.id.value_swell_per),
+ arrowCurr = findViewById(R.id.arrow_curr),
+ arrowWaves = findViewById(R.id.arrow_waves),
+ arrowSwell = findViewById(R.id.arrow_swell),
+ bearingCurr = findViewById(R.id.bearing_curr),
+ bearingWaves = findViewById(R.id.bearing_waves),
+ bearingSwell = findViewById(R.id.bearing_swell),
+ waveView = findViewById(R.id.wave_divider),
+ unitPrefs = unitPrefs
)
-
- // anchorWatchHandler is initialized when the anchor config UI is available
-
- val mockPolarTable = createMockPolarTable()
- findViewById<PolarDiagramView>(R.id.polar_diagram_view).setPolarTable(mockPolarTable)
- startInstrumentSimulation(mockPolarTable)
}
- // Helper to convert dp to px
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
- // ... (Keep existing permission and service logic)
-
private fun checkForegroundPermissions() {
- val fineLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
- val coarseLocationPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
+ val fineLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
+ val coarseLocation = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
- if (fineLocationPermission == PackageManager.PERMISSION_GRANTED || coarseLocationPermission == PackageManager.PERMISSION_GRANTED) {
+ if (fineLocation == PackageManager.PERMISSION_GRANTED || coarseLocation == PackageManager.PERMISSION_GRANTED) {
startServices()
} else {
requestPermissionLauncher.launch(arrayOf(
@@ -214,30 +437,209 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun setupMap() {
+ layerManager = MapLayerManager(this)
mapView = findViewById(R.id.mapView)
+ particleWindView = findViewById(R.id.particle_wind_view)
+
+ // Always start hidden; revealed by the windArrow observer once data arrives
+ particleWindView?.visibility = View.GONE
+
+ if (NavApplication.isTesting) return
+
mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
mapHandler = MapHandler(maplibreMap)
- val style = Style.Builder()
- .fromUri("https://tiles.openfreemap.org/styles/liberty")
- .withSource(RasterSource("openseamap-source",
- TileSet("2.2.0", "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png").also {
- it.setMaxZoom(18f)
- }, 256))
- .withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
-
- maplibreMap.setStyle(style) { loadedStyle ->
- val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
- val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
- mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap)
+ particleWindView?.attachMap(maplibreMap)
+
+ lifecycleScope.launch {
+ mapHandler!!.isFollowing.collect { following ->
+ mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(bottomNav, fabRecordTrack)
+ } else {
+ fadeOut(bottomNav, fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
+ }
+ }
+
+ val styleBuilder = Style.Builder()
+ .fromUri("https://tiles.openfreemap.org/styles/bright")
+ layerManager.addToStyleBuilder(styleBuilder)
+
+ maplibreMap.setStyle(styleBuilder) { style ->
+ loadedStyleFlow.value = style
+ val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
+ val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
+ val userBitmap = rasterizeDrawable(R.drawable.ic_ship_arrow)
+ val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap, userBitmap)
+ mapHandler?.setupWindLayer(style, windArrowBitmap)
+ // Eagerly load conditions at style-ready time so the HUD is never blank
+ // on startup — the camera-idle may fire before GPS fixes, this is the backstop.
+ maplibreMap.cameraPosition.target?.let { center ->
+ if (center.latitude != 0.0 || center.longitude != 0.0) {
+ viewModel.loadConditions(center.latitude, center.longitude)
+ }
+ }
+ }
+
+ maplibreMap.addOnCameraIdleListener {
+ val corners = listOfNotNull(
+ maplibreMap.projection.visibleRegion.nearLeft,
+ maplibreMap.projection.visibleRegion.nearRight,
+ maplibreMap.projection.visibleRegion.farLeft,
+ maplibreMap.projection.visibleRegion.farRight
+ )
+ if (corners.size == 4) {
+ viewModel.loadWindGrid(
+ corners.minOf { it.latitude }, corners.maxOf { it.latitude },
+ corners.minOf { it.longitude }, corners.maxOf { it.longitude }
+ )
+ }
+ maplibreMap.cameraPosition.target?.let { center ->
+ // Only refresh conditions when the user has manually panned —
+ // in following mode the GPS handler already triggers the load.
+ if (mapHandler?.isFollowing?.value == false) {
+ viewModel.loadConditions(center.latitude, center.longitude)
+ }
+ }
+ }
+
+ maplibreMap.addOnMapLongClickListener { _ ->
+ buildLayerPickerSheet().show(supportFragmentManager, "layer_picker")
+ true
}
}
}
+ private var thermalAlarmPlayer: MediaPlayer? = null
+
+ private fun startThermalMonitoring() {
+ lifecycleScope.launch {
+ thermalFlow(applicationContext).collect { reading ->
+ viewModel.onThermalReading(reading)
+ when (reading.state) {
+ ThermalState.HOT -> {
+ Toast.makeText(
+ this@MainActivity,
+ "⚠ Phone hot: %.1f°C — reduce screen brightness".format(reading.batteryTempC),
+ Toast.LENGTH_LONG
+ ).show()
+ soundThermalAlarm()
+ }
+ ThermalState.CRITICAL -> {
+ Toast.makeText(
+ this@MainActivity,
+ "🔥 Phone overheating: %.1f°C — dock device immediately!".format(reading.batteryTempC),
+ Toast.LENGTH_LONG
+ ).show()
+ soundThermalAlarm()
+ }
+ else -> stopThermalAlarm()
+ }
+ }
+ }
+ }
+
+ private fun soundThermalAlarm() {
+ if (thermalAlarmPlayer?.isPlaying == true) return
+ thermalAlarmPlayer?.release()
+ thermalAlarmPlayer = MediaPlayer.create(this, R.raw.mob_alarm)?.also {
+ it.isLooping = false
+ it.start()
+ }
+ }
+
+ private fun stopThermalAlarm() {
+ thermalAlarmPlayer?.stop()
+ thermalAlarmPlayer?.release()
+ thermalAlarmPlayer = null
+ }
+
private fun observeDataSources() {
+ startThermalMonitoring()
+ var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
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)
+ // HUD — SOG and COG from GPS
+ lastSogKnots = gpsData.sog
+ hudSog.text = unitPrefs.formatSpeed(gpsData.sog)
+ hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog)
+ if (!conditionsLoaded) {
+ conditionsLoaded = true
+ NavLogger.i("gps", "first fix lat=%.4f lon=%.4f sog=%.1fkt cog=%.0f°"
+ .format(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog))
+ viewModel.loadConditions(gpsData.latitude, gpsData.longitude)
+ }
+ if (!weatherLoaded) {
+ weatherLoaded = true
+ viewModel.loadWeather(gpsData.latitude, gpsData.longitude)
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.windArrow.collect { arrow ->
+ if (arrow != null) {
+ mapHandler?.updateWindLayer(arrow)
+ particleWindView?.setWind(arrow.directionDeg, arrow.speedKt)
+ if (layerManager.particlesEnabled) {
+ particleWindView?.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.windGrid.collect { arrows ->
+ mapHandler?.updateWindGridLayer(arrows)
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.barometerStatus.collect { status ->
+ if (status.history.isNotEmpty()) {
+ lastBaroHpa = status.currentPressureHpa
+ instrumentHandler?.updateConditions(baroHpa = status.currentPressureHpa)
+ }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.marineConditions.collect { c ->
+ if (c == null) return@collect
+ lastConditions = c
+ applyConditions(c)
+ }
+ }
+ val conditionSpinners = listOf(
+ R.id.spinner_tws, R.id.spinner_temp, R.id.spinner_baro,
+ R.id.spinner_curr, R.id.spinner_waves, R.id.spinner_swell
+ ).mapNotNull { findViewById<android.view.View>(it) }
+ lifecycleScope.launch {
+ viewModel.conditionsLoading.collect { loading ->
+ val vis = if (loading) android.view.View.VISIBLE else android.view.View.INVISIBLE
+ conditionSpinners.forEach { it.visibility = vis }
+ }
+ }
+ lifecycleScope.launch {
+ viewModel.conditionsError.collect { msg ->
+ com.google.android.material.snackbar.Snackbar
+ .make(findViewById(android.R.id.content), msg, com.google.android.material.snackbar.Snackbar.LENGTH_LONG)
+ .show()
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.nmeaDepthDataFlow.collect { depthData ->
+ lastDepthMeters = depthData.depthMeters
+ hudDepth.text = unitPrefs.formatDepth(depthData.depthMeters)
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.nmeaBoatSpeedData.collect { bspData ->
+ lastBspKnots = bspData.bspKnots
+ hudBsp.text = unitPrefs.formatSpeed(bspData.bspKnots)
}
}
lifecycleScope.launch {
@@ -245,28 +647,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
}
- }
-
- private fun startInstrumentSimulation(polarTable: PolarTable) {
lifecycleScope.launch {
- var simulatedTws = 8.0
- var simulatedTwa = 40.0
- while (true) {
- val bsp = polarTable.interpolateBsp(simulatedTws, simulatedTwa)
- instrumentHandler?.updateDisplay(
- aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1),
- tws = "%.1f".format(Locale.getDefault(), simulatedTws),
- bsp = "%.1f".format(Locale.getDefault(), bsp),
- sog = "%.1f".format(Locale.getDefault(), bsp * 0.95),
- vmg = "%.1f".format(Locale.getDefault(), polarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, bsp) ?: 0.0),
- polarPct = "%.0f%%".format(Locale.getDefault(), polarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, bsp)),
- baro = "1013.2"
- )
- instrumentHandler?.updatePolarDiagram(simulatedTws, simulatedTwa, bsp)
- simulatedTwa = (simulatedTwa + 0.5).let { if (it > 170) 40.0 else it }
- delay(1000)
+ viewModel.cpaAlerts.collect { alerts ->
+ safetyFragment.updateCpaAlerts(alerts)
}
}
+ lifecycleScope.launch {
+ loadedStyleFlow.filterNotNull()
+ .combine(viewModel.trackPoints) { style, points -> style to points }
+ .combine(viewModel.pastTracks) { (style, active), past -> Triple(style, active, past) }
+ .collect { (style, active, past) -> mapHandler?.updateTrackLayer(style, active, past) }
+ }
}
private fun rasterizeDrawable(drawableId: Int): Bitmap {
@@ -278,19 +669,26 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
return bitmap
}
- private fun createMockPolarTable(): PolarTable {
- val curves = listOf(6.0, 8.0, 10.0).map { tws ->
- PolarCurve(tws, listOf(30.0, 45.0, 60.0, 90.0, 120.0, 150.0, 180.0).map { twa ->
- PolarPoint(twa, tws * (0.4 + twa / 200.0))
- })
+ private fun fadeOut(vararg views: View, gone: Boolean = false) {
+ views.forEach { v ->
+ v.animate().alpha(0f).setDuration(150).withEndAction {
+ v.visibility = if (gone) View.GONE else View.INVISIBLE
+ }.start()
+ }
+ }
+
+ private fun fadeIn(vararg views: View) {
+ views.forEach { v ->
+ if (v.visibility == View.VISIBLE && v.alpha == 1f) return@forEach
+ v.alpha = 0f
+ v.visibility = View.VISIBLE
+ v.animate().alpha(1f).setDuration(150).start()
}
- return PolarTable(curves)
}
- override fun onStart() { super.onStart(); mapView?.onStart() }
- override fun onResume() { super.onResume(); mapView?.onResume() }
- override fun onPause() { super.onPause(); mapView?.onPause() }
- override fun onStop() { super.onStop(); mapView?.onStop() }
- override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
- override fun onLowMemory() { super.onLowMemory(); mapView?.onLowMemory() }
+ override fun onStart() { super.onStart(); if (!NavApplication.isTesting) mapView?.onStart() }
+ override fun onPause() { super.onPause(); if (!NavApplication.isTesting) mapView?.onPause() }
+ override fun onStop() { super.onStop(); if (!NavApplication.isTesting) mapView?.onStop() }
+ override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy(); stopThermalAlarm() }
+ override fun onLowMemory() { super.onLowMemory(); if (!NavApplication.isTesting) mapView?.onLowMemory() }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0b507d2..cf3103d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -2,6 +2,7 @@ package org.terst.nav
import android.app.Application
import com.google.firebase.crashlytics.FirebaseCrashlytics
+import org.terst.nav.settings.FeatureFlags
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
@@ -11,12 +12,30 @@ import java.util.Locale
class NavApplication : Application() {
+ lateinit var featureFlags: FeatureFlags
+ private set
+
companion object {
+ val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ lateinit var trackRepository: org.terst.nav.track.TrackRepository
+ lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository
var isTesting: Boolean = false
+ get() {
+ if (field) return true
+ return try {
+ Class.forName("androidx.test.espresso.Espresso")
+ true
+ } catch (e: ClassNotFoundException) {
+ false
+ }
+ }
}
override fun onCreate() {
super.onCreate()
+ featureFlags = FeatureFlags(this)
+ trackRepository = org.terst.nav.track.TrackRepository(this)
+ boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt
new file mode 100644
index 0000000..8d60ae0
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt
@@ -0,0 +1,83 @@
+package org.terst.nav
+
+import android.content.Context
+import java.util.Locale
+
+enum class TempUnit { CELSIUS, FAHRENHEIT }
+enum class DepthUnit { FEET, METERS }
+enum class PressureUnit { HPA, INHG }
+enum class SpeedUnit { KNOTS, MPH, KPH }
+
+/**
+ * Persists and applies the user's preferred display units.
+ * Defaults: Fahrenheit, feet, hPa, knots.
+ */
+class UnitPrefs(context: Context) {
+
+ private val prefs = context.getSharedPreferences("unit_prefs", Context.MODE_PRIVATE)
+
+ var tempUnit: TempUnit
+ get() = TempUnit.valueOf(prefs.getString(KEY_TEMP, TempUnit.FAHRENHEIT.name)!!)
+ set(v) { prefs.edit().putString(KEY_TEMP, v.name).apply() }
+
+ var depthUnit: DepthUnit
+ get() = DepthUnit.valueOf(prefs.getString(KEY_DEPTH, DepthUnit.FEET.name)!!)
+ set(v) { prefs.edit().putString(KEY_DEPTH, v.name).apply() }
+
+ var pressureUnit: PressureUnit
+ get() = PressureUnit.valueOf(prefs.getString(KEY_PRESSURE, PressureUnit.HPA.name)!!)
+ set(v) { prefs.edit().putString(KEY_PRESSURE, v.name).apply() }
+
+ var speedUnit: SpeedUnit
+ get() = SpeedUnit.valueOf(prefs.getString(KEY_SPEED, SpeedUnit.KNOTS.name)!!)
+ set(v) { prefs.edit().putString(KEY_SPEED, v.name).apply() }
+
+ fun formatTemp(tempC: Double): String = when (tempUnit) {
+ TempUnit.CELSIUS -> "%.0f".format(Locale.getDefault(), tempC)
+ TempUnit.FAHRENHEIT -> "%.0f".format(Locale.getDefault(), tempC * 9.0 / 5.0 + 32.0)
+ }
+
+ fun tempUnitLabel(): String = when (tempUnit) {
+ TempUnit.CELSIUS -> "°C"
+ TempUnit.FAHRENHEIT -> "°F"
+ }
+
+ fun formatDepth(metres: Double): String = when (depthUnit) {
+ DepthUnit.FEET -> "%.1f".format(Locale.getDefault(), metres * 3.28084)
+ DepthUnit.METERS -> "%.1f".format(Locale.getDefault(), metres)
+ }
+
+ fun depthUnitLabel(): String = when (depthUnit) {
+ DepthUnit.FEET -> "ft"
+ DepthUnit.METERS -> "m"
+ }
+
+ fun formatPressure(hpa: Float): String = when (pressureUnit) {
+ PressureUnit.HPA -> "%.1f".format(Locale.getDefault(), hpa)
+ PressureUnit.INHG -> "%.2f".format(Locale.getDefault(), hpa * 0.02953f)
+ }
+
+ fun pressureUnitLabel(): String = when (pressureUnit) {
+ PressureUnit.HPA -> "hPa"
+ PressureUnit.INHG -> "inHg"
+ }
+
+ fun formatSpeed(knots: Double): String = when (speedUnit) {
+ SpeedUnit.KNOTS -> "%.1f".format(Locale.getDefault(), knots)
+ SpeedUnit.MPH -> "%.1f".format(Locale.getDefault(), knots * 1.15078)
+ SpeedUnit.KPH -> "%.1f".format(Locale.getDefault(), knots * 1.852)
+ }
+
+ fun speedUnitLabel(): String = when (speedUnit) {
+ SpeedUnit.KNOTS -> "kt"
+ SpeedUnit.MPH -> "mph"
+ SpeedUnit.KPH -> "kph"
+ }
+
+ companion object {
+ private const val KEY_TEMP = "temp_unit"
+ private const val KEY_DEPTH = "depth_unit"
+ private const val KEY_PRESSURE = "pressure_unit"
+ private const val KEY_SPEED = "speed_unit"
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
index 75b1feb..86bff97 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -58,4 +58,10 @@ class AisRepository(
val threshold = nowMs - staleTimeoutMs
targets.entries.removeIf { it.value.timestampMs < threshold }
}
+
+ /** Remove all tracked vessels — called when AIS source is disabled. */
+ fun clear() {
+ targets.clear()
+ pendingStatic.clear()
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
new file mode 100644
index 0000000..1d635c0
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.ais
+
+enum class CpaSeverity { CAUTION, WARNING, DANGER }
+
+data class CpaAlert(
+ val mmsi: Int,
+ val vesselName: String,
+ val cpaNm: Double,
+ val tcpaMin: Double,
+ val severity: CpaSeverity
+)
+
+object CpaThresholds {
+ const val DANGER_CPA_NM = 0.5
+ const val WARNING_CPA_NM = 1.0
+ const val CAUTION_CPA_NM = 2.0
+ const val MAX_TCPA_MIN = 30.0 // ignore contacts closing in > 30 min
+}
+
+fun CpaAlert.label(): String {
+ val name = if (vesselName.isNotBlank()) vesselName else "MMSI $mmsi"
+ return "%-12s CPA %.1fnm T+%.0fmin".format(name.take(12), cpaNm, tcpaMin)
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
index 67c14f8..5a7d2e2 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
@@ -11,7 +11,7 @@ interface MarineApiService {
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("hourly") hourly: String =
- "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction",
+ "wave_height,wave_direction,swell_wave_height,swell_wave_direction,swell_wave_period,ocean_current_velocity,ocean_current_direction",
@Query("forecast_days") forecastDays: Int = 7
): MarineResponse
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt
index 9713bcd..bce3534 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt
@@ -15,4 +15,14 @@ interface WeatherApiService {
@Query("forecast_days") forecastDays: Int = 7,
@Query("wind_speed_unit") windSpeedUnit: String = "kn"
): WeatherResponse
+
+ /** Batch endpoint: comma-separated lat/lon → JSON array of WeatherResponse. */
+ @GET("v1/forecast")
+ suspend fun getWeatherForecastBatch(
+ @Query("latitude") latitudes: String,
+ @Query("longitude") longitudes: String,
+ @Query("hourly") hourly: String = "windspeed_10m,winddirection_10m",
+ @Query("forecast_days") forecastDays: Int = 1,
+ @Query("wind_speed_unit") windSpeedUnit: String = "kn"
+ ): List<WeatherResponse>
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
new file mode 100644
index 0000000..0286ea8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.data.model
+
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/**
+ * Boat polar speed table: maps (TWA, TWS) → BSP (boat speed through water, knots).
+ *
+ * Interpolation is bilinear — linear on TWA within a given TWS, then linear on TWS.
+ * Port-tack mirror: TWA > 180° is folded to 360° - TWA before lookup.
+ */
+data class BoatPolars(
+ /** Outer key: TWS in knots. Inner key: TWA in degrees [0, 180]. Value: BSP in knots. */
+ val table: Map<Double, Map<Double, Double>>
+) {
+ /**
+ * Returns boat speed (knots) for the given True Wind Angle and True Wind Speed.
+ * TWA outside [0, 360] is clamped; port/starboard symmetry is applied (>180° mirrored).
+ */
+ fun bsp(twaDeg: Double, twsKt: Double): Double {
+ val twa = if (twaDeg > 180.0) 360.0 - twaDeg else twaDeg.coerceIn(0.0, 180.0)
+
+ val twsKeys = table.keys.sorted()
+ if (twsKeys.isEmpty()) return 0.0
+
+ val twsClamped = twsKt.coerceIn(twsKeys.first(), twsKeys.last())
+ val twsLow = twsKeys.lastOrNull { it <= twsClamped } ?: twsKeys.first()
+ val twsHigh = twsKeys.firstOrNull { it >= twsClamped } ?: twsKeys.last()
+
+ val bspLow = bspAtTws(twa, table[twsLow] ?: return 0.0)
+ val bspHigh = bspAtTws(twa, table[twsHigh] ?: return 0.0)
+
+ return if (twsHigh == twsLow) bspLow
+ else {
+ val t = (twsClamped - twsLow) / (twsHigh - twsLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ private fun bspAtTws(twaDeg: Double, twaMap: Map<Double, Double>): Double {
+ val twaKeys = twaMap.keys.sorted()
+ if (twaKeys.isEmpty()) return 0.0
+
+ val twaClamped = twaDeg.coerceIn(twaKeys.first(), twaKeys.last())
+ val twaLow = twaKeys.lastOrNull { it <= twaClamped } ?: twaKeys.first()
+ val twaHigh = twaKeys.firstOrNull { it >= twaClamped } ?: twaKeys.last()
+
+ val bspLow = twaMap[twaLow] ?: 0.0
+ val bspHigh = twaMap[twaHigh] ?: 0.0
+
+ return if (twaHigh == twaLow) bspLow
+ else {
+ val t = (twaClamped - twaLow) / (twaHigh - twaLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ companion object {
+ /** Default polar for a typical 35-foot cruising sloop. */
+ val DEFAULT: BoatPolars = BoatPolars(
+ mapOf(
+ 5.0 to mapOf(45.0 to 3.5, 60.0 to 4.2, 90.0 to 4.8, 120.0 to 5.0, 150.0 to 4.5, 180.0 to 4.0),
+ 10.0 to mapOf(45.0 to 5.5, 60.0 to 6.5, 90.0 to 7.0, 120.0 to 7.2, 150.0 to 6.8, 180.0 to 6.0),
+ 15.0 to mapOf(45.0 to 6.5, 60.0 to 7.5, 90.0 to 8.0, 120.0 to 8.5, 150.0 to 8.0, 180.0 to 7.0),
+ 20.0 to mapOf(45.0 to 7.0, 60.0 to 8.0, 90.0 to 8.5, 120.0 to 9.0, 150.0 to 8.5, 180.0 to 7.5)
+ )
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
index 9d284b5..715c1db 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
@@ -2,39 +2,8 @@ package org.terst.nav.data.model
import java.time.Instant
-/**
- * Metadata record for a locally-stored GRIB2 file.
- *
- * @param region The geographic region this file covers.
- * @param modelRunTime When the NWP model run that produced this file started (UTC).
- * @param forecastHours Number of forecast hours included in this file.
- * @param downloadedAt Wall-clock time when the file was saved locally.
- * @param filePath Absolute path to the GRIB2 file on the device filesystem.
- * @param sizeBytes File size in bytes.
- */
-data class GribFile(
- val region: GribRegion,
- val modelRunTime: Instant,
- val forecastHours: Int,
- val downloadedAt: Instant,
- val filePath: String,
- val sizeBytes: Long
-) {
- /**
- * The wall-clock time at which this GRIB file's forecast data expires.
- * Per design doc §6.3: valid until model run + forecast hours.
- */
+data class GribFile(val region: GribRegion, val modelRunTime: Instant, val forecastHours: Int, val downloadedAt: Instant, val filePath: String, val sizeBytes: Long) {
fun validUntil(): Instant = modelRunTime.plusSeconds(forecastHours.toLong() * 3600)
-
- /**
- * Returns true if the data has expired relative to [now].
- * Per design doc §6.3: stale after model run + forecast hour.
- */
fun isStale(now: Instant = Instant.now()): Boolean = now.isAfter(validUntil())
-
- /**
- * Age of the download in seconds.
- */
- fun ageSeconds(now: Instant = Instant.now()): Long =
- now.epochSecond - downloadedAt.epochSecond
+ fun ageSeconds(now: Instant = Instant.now()): Long = now.epochSecond - downloadedAt.epochSecond
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
new file mode 100644
index 0000000..a322ea9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
@@ -0,0 +1,24 @@
+package org.terst.nav.data.model
+
+/** GRIB meteorological/oceanographic parameters that can be requested for download. */
+enum class GribParameter {
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE,
+ TEMPERATURE_2M,
+ PRECIPITATION,
+ WAVE_HEIGHT,
+ WAVE_DIRECTION;
+
+ companion object {
+ /**
+ * Minimal parameter set for satellite (Iridium) links: wind speed, wind direction,
+ * and surface pressure only. Per §9.1: skip temperature/clouds to minimise bandwidth.
+ */
+ val SATELLITE_MINIMAL: Set<GribParameter> = setOf(
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
index 5e32d6c..f960bc3 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
@@ -1,20 +1,6 @@
package org.terst.nav.data.model
-/**
- * Geographic bounding box used to identify a GRIB download region.
- * @param name Human-readable region name (e.g. "North Atlantic", "English Channel")
- */
-data class GribRegion(
- val name: String,
- val latMin: Double,
- val latMax: Double,
- val lonMin: Double,
- val lonMax: Double
-) {
- /** True if [lat]/[lon] falls within this region's bounding box. */
- fun contains(lat: Double, lon: Double): Boolean =
- lat in latMin..latMax && lon in lonMin..lonMax
-
- /** Area in square degrees (rough proxy for download size estimate). */
+data class GribRegion(val name: String, val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) {
+ fun contains(lat: Double, lon: Double): Boolean = lat in latMin..latMax && lon in lonMin..lonMax
fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin)
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
new file mode 100644
index 0000000..f41e917
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
@@ -0,0 +1,19 @@
+package org.terst.nav.data.model
+
+/**
+ * A single entry in the electronic trip logbook.
+ * Matches the log format from Section 4.8 of COMPONENT_DESIGN.md.
+ */
+data class LogbookEntry(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDegrees: Double,
+ val windKnots: Double? = null,
+ val windDirectionDeg: Double? = null,
+ val baroHpa: Double? = null,
+ val depthMeters: Double? = null,
+ val event: String? = null,
+ val notes: String? = null
+)
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
new file mode 100644
index 0000000..557d6a2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.data.model
+
+/**
+ * Snapshot of current marine conditions derived from the first forecast hour.
+ * All fields are nullable — null means the model returned no data for that parameter.
+ */
+data class MarineConditions(
+ val windSpeedKt: Double?,
+ val windDirDeg: Double?,
+ val tempC: Double?,
+ val waveHeightM: Double?,
+ val waveDirDeg: Double?,
+ val swellHeightM: Double?,
+ val swellDirDeg: Double?,
+ val swellPeriodS: Double?,
+ val currentSpeedKt: Double?, // ocean_current_velocity converted from m/s to knots
+ val currentDirDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
index ab9799b..cf5216d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
@@ -15,6 +15,9 @@ data class MarineHourly(
@Json(name = "time") val time: List<String>,
@Json(name = "wave_height") val waveHeight: List<Double?>,
@Json(name = "wave_direction") val waveDirection: List<Double?>,
+ @Json(name = "swell_wave_height") val swellWaveHeight: List<Double?>,
+ @Json(name = "swell_wave_direction") val swellWaveDirection: List<Double?>,
+ @Json(name = "swell_wave_period") val swellWavePeriod: List<Double?>,
@Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List<Double?>,
@Json(name = "ocean_current_direction") val oceanCurrentDirection: List<Double?>
)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
new file mode 100644
index 0000000..d14c9da
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
@@ -0,0 +1,27 @@
+package org.terst.nav.data.model
+
+/**
+ * A bandwidth-optimised GRIB download request for satellite (Iridium) links.
+ *
+ * Per §9.1: crop to needed region and request only essential parameters
+ * (wind, pressure) to fit within the ~2.4 Kbps Iridium budget.
+ *
+ * @param region Geographic area to download (cropped to route corridor + 200 nm buffer).
+ * @param parameters GRIB parameters to include. Use [GribParameter.SATELLITE_MINIMAL]
+ * for satellite links.
+ * @param forecastHours Number of forecast hours to request (e.g. 24, 48, 120).
+ * @param resolutionDeg Grid spacing in degrees. Coarser grids produce smaller files;
+ * 1.0° is typical for satellite; 0.25° for WiFi/cellular.
+ */
+data class SatelliteDownloadRequest(
+ val region: GribRegion,
+ val parameters: Set<GribParameter>,
+ val forecastHours: Int,
+ val resolutionDeg: Double = 1.0
+) {
+ init {
+ require(forecastHours > 0) { "forecastHours must be positive" }
+ require(resolutionDeg > 0.0) { "resolutionDeg must be positive" }
+ require(parameters.isNotEmpty()) { "parameters must not be empty" }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
new file mode 100644
index 0000000..fc1d79d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package org.terst.nav.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt
new file mode 100644
index 0000000..deb73d6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt
@@ -0,0 +1,9 @@
+package com.example.androidapp.data.model
+
+/** A single harmonic tidal constituent used in harmonic tide prediction. */
+data class TideConstituent(
+ val name: String, // e.g. "M2", "S2", "K1"
+ val speedDegPerHour: Double, // angular speed in degrees per hour
+ val amplitudeMeters: Double, // amplitude in metres
+ val phaseDeg: Double // phase lag (kappa) in degrees
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt
new file mode 100644
index 0000000..51eea44
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt
@@ -0,0 +1,7 @@
+package com.example.androidapp.data.model
+
+/** A predicted tide height at a specific point in time. */
+data class TidePrediction(
+ val timestampMs: Long, // Unix epoch milliseconds
+ val heightMeters: Double // predicted water height above chart datum in metres
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt
new file mode 100644
index 0000000..c9f96a6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt
@@ -0,0 +1,11 @@
+package com.example.androidapp.data.model
+
+/** A tide station with harmonic constituents for offline tide prediction. */
+data class TideStation(
+ val id: String,
+ val name: String,
+ val lat: Double,
+ val lon: Double,
+ val datumOffsetMeters: Double, // mean water level above chart datum (Z0)
+ val constituents: List<TideConstituent>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
new file mode 100644
index 0000000..f009da8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.data.model
+
+/**
+ * Wind conditions at a specific location and time.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds.
+ * @param twsKt True Wind Speed in knots.
+ * @param twdDeg True Wind Direction in degrees (the direction FROM which the wind blows, 0–360).
+ */
+data class WindForecast(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val twsKt: Double,
+ val twdDeg: 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 ee586a5..a716051 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
@@ -1,9 +1,13 @@
package org.terst.nav.data.repository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
+import org.terst.nav.ui.NavLogger
class WeatherRepository(
private val weatherApi: WeatherApiService,
@@ -12,11 +16,10 @@ class WeatherRepository(
/**
* Fetch 7-day hourly forecast items for the given position.
- * Both weather and marine data are requested; only weather fields are needed for ForecastItem,
- * but marine is fetched here to prime the cache for wind-arrow use.
*/
- suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> =
- runCatching {
+ suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> {
+ NavLogger.i("forecast", "lat=%.4f lon=%.4f".format(lat, lon))
+ return runCatching {
val weather = weatherApi.getWeatherForecast(lat, lon)
val h = weather.hourly
h.time.indices.map { i ->
@@ -29,7 +32,15 @@ class WeatherRepository(
weatherCode = h.weathercode[i]
)
}
+ }.also { result ->
+ result.onSuccess { items ->
+ val first = items.firstOrNull()
+ NavLogger.i("forecast", "OK ${items.size} items wind[0]=${first?.windKt?.let { "%.1f".format(it) } ?: "—"}kt@${first?.windDirDeg?.toInt() ?: "—"}°")
+ }.onFailure { e ->
+ NavLogger.e("forecast", "${e.javaClass.simpleName}: ${e.message}")
+ }
}
+ }
/**
* Fetch a single WindArrow representing the current conditions at [lat]/[lon].
@@ -47,6 +58,68 @@ class WeatherRepository(
)
}
+ /**
+ * Fetches current marine conditions (first forecast hour) for [lat]/[lon].
+ * Ocean current velocity is converted from m/s to knots.
+ */
+ suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result<MarineConditions> {
+ NavLogger.i("conditions", "lat=%.4f lon=%.4f".format(lat, lon))
+ return runCatching {
+ coroutineScope {
+ val weatherDeferred = async { weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) }
+ val marineDeferred = async { marineApi.getMarineForecast(lat, lon) }
+ val weather = weatherDeferred.await()
+ val marine = marineDeferred.await()
+ val w = weather.hourly
+ val m = marine.hourly
+ 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(),
+ swellDirDeg = m.swellWaveDirection.firstOrNull(),
+ swellPeriodS = m.swellWavePeriod.firstOrNull(),
+ currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 },
+ currentDirDeg = m.oceanCurrentDirection.firstOrNull()
+ )
+ }
+ }.also { result ->
+ result.onSuccess { c ->
+ NavLogger.i("conditions", "OK" +
+ " wind=${c.windSpeedKt?.let { "%.1f".format(it) } ?: "—"}kt@${c.windDirDeg?.toInt() ?: "—"}°" +
+ " temp=${c.tempC?.let { "%.1f".format(it) } ?: "—"}°C" +
+ " waves=${c.waveHeightM?.let { "%.2f".format(it) } ?: "—"}m" +
+ " swell=${c.swellHeightM?.let { "%.2f".format(it) } ?: "—"}m@${c.swellDirDeg?.toInt() ?: "—"}°/${c.swellPeriodS?.toInt() ?: "—"}s" +
+ " curr=${c.currentSpeedKt?.let { "%.2f".format(it) } ?: "—"}kt@${c.currentDirDeg?.toInt() ?: "—"}°"
+ )
+ }.onFailure { e ->
+ NavLogger.e("conditions", "${e.javaClass.simpleName}: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Fetch a current wind arrow for each point in [points] using Open-Meteo's batch endpoint.
+ * Points are split into chunks of 10 (API limit per call).
+ */
+ suspend fun fetchWindGrid(points: List<Pair<Double, Double>>): Result<List<WindArrow>> =
+ runCatching {
+ points.chunked(10).flatMap { chunk ->
+ val lats = chunk.joinToString(",") { "%.4f".format(it.first) }
+ val lons = chunk.joinToString(",") { "%.4f".format(it.second) }
+ weatherApi.getWeatherForecastBatch(lats, lons).map { r ->
+ WindArrow(
+ lat = r.latitude,
+ lon = r.longitude,
+ speedKt = r.hourly.windspeed10m.firstOrNull() ?: 0.0,
+ directionDeg = r.hourly.winddirection10m.firstOrNull() ?: 0.0
+ )
+ }
+ }
+ }
+
companion object {
/** Factory using the shared ApiClient singletons. */
fun create(): WeatherRepository {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
index e17e5ca..6a976f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion
import java.time.Instant
interface GribFileManager {
- /** Save metadata for a newly-downloaded GRIB file. */
fun saveMetadata(file: GribFile)
- /** Return all stored GRIB files for [region], newest first. */
fun listFiles(region: GribRegion): List<GribFile>
- /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
fun latestFile(region: GribRegion): GribFile?
- /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
fun delete(file: GribFile): Boolean
- /** Delete all GRIB files older than [before]. Returns count of deleted files. */
fun purgeOlderThan(before: Instant): Int
- /** Total size in bytes of all stored GRIB files. */
fun totalSizeBytes(): Long
}
class InMemoryGribFileManager : GribFileManager {
private val files = mutableListOf<GribFile>()
-
override fun saveMetadata(file: GribFile) { files.add(file) }
-
- override fun listFiles(region: GribRegion): List<GribFile> =
- files.filter { it.region.name == region.name }
- .sortedByDescending { it.downloadedAt }
-
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
-
override fun delete(file: GribFile): Boolean = files.remove(file)
-
- override fun purgeOlderThan(before: Instant): Int {
- val toRemove = files.filter { it.downloadedAt.isBefore(before) }
- files.removeAll(toRemove)
- return toRemove.size
- }
-
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..f39957b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..875d971
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package org.terst.nav.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import org.terst.nav.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
index 5faf30c..99cef2d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -1,9 +1,20 @@
package org.terst.nav.gps
+/**
+ * Represents a single GPS fix.
+ *
+ * @param latitude Degrees, positive = North, negative = South.
+ * @param longitude Degrees, positive = East, negative = West.
+ * @param sog Speed Over Ground in knots.
+ * @param cog Course Over Ground in degrees true (0-360).
+ * @param timestampMs Unix epoch milliseconds UTC.
+ * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown.
+ */
data class GpsPosition(
val latitude: Double,
val longitude: Double,
- val sog: Double, // knots
- val cog: Double, // degrees true
- val timestampMs: Long
+ val sog: Double,
+ val cog: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Double? = null
)
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/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..67cfcce
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package org.terst.nav.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..6417db9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package org.terst.nav.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
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/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
index 453c758..6a470b8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -273,8 +273,9 @@ class NmeaParser {
val hours = timeStr.substring(0, 2).toInt()
val minutes = timeStr.substring(2, 4).toInt()
val seconds = timeStr.substring(4, 6).toInt()
- val millis = if (timeStr.length > 7) {
- (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ val millis = if (timeStr.contains('.')) {
+ val fracStr = timeStr.substringAfter('.')
+ ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
} else 0
cal.set(Calendar.HOUR_OF_DAY, hours)
cal.set(Calendar.MINUTE, minutes)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
new file mode 100644
index 0000000..13fb132
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..8ac73cf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package org.terst.nav.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
new file mode 100644
index 0000000..a6562d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package org.terst.nav.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..9121ce6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.safety
+
+import android.location.Location
+import kotlin.math.*
+
+/**
+ * Holds state for the anchor watch and provides the suggested watch-circle radius.
+ */
+data class AnchorWatchState(
+ val anchorLocation: Location? = null,
+ val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
+ val setTimeMillis: Long = 0L,
+ val isActive: Boolean = false
+) {
+ companion object {
+ const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0
+
+ /**
+ * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
+ */
+ fun calculateRecommendedWatchCircleRadius(
+ depthMeters: Double,
+ freeboardMeters: Double,
+ rodeOutMeters: Double
+ ): Double {
+ if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0
+ val totalVerticalDistance = depthMeters + freeboardMeters
+ if (totalVerticalDistance > rodeOutMeters) return 0.0
+ val angle = asin(totalVerticalDistance / rodeOutMeters)
+ return rodeOutMeters * cos(angle)
+ }
+ }
+
+ fun isDragging(currentLocation: Location): Boolean {
+ anchorLocation ?: return false
+ if (!isActive) return false
+ val distance = anchorLocation.distanceTo(currentLocation)
+ return distance > watchCircleRadiusMeters
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
new file mode 100644
index 0000000..179fa6f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
@@ -0,0 +1,44 @@
+package org.terst.nav.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * Hardware sources that require external devices beyond what a phone provides natively.
+ * When disabled, all UI and processing for that source is suppressed.
+ */
+enum class HardwareSource(val prefKey: String, val label: String, val description: String) {
+ AIS_RECEIVER(
+ "hw_ais_receiver",
+ "AIS Receiver",
+ "Vessel tracking, CPA/TCPA collision alerts"
+ ),
+ NMEA_INSTRUMENTS(
+ "hw_nmea_instruments",
+ "NMEA Instruments",
+ "Wind, depth, boat speed, heading from onboard sensors"
+ )
+}
+
+/**
+ * Persists hardware source on/off state to SharedPreferences.
+ * All sources default to disabled so the app works out-of-the-box on a phone only.
+ */
+class FeatureFlags(context: Context) {
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
+
+ fun isEnabled(source: HardwareSource): Boolean =
+ prefs.getBoolean(source.prefKey, false)
+
+ fun setEnabled(source: HardwareSource, enabled: Boolean) {
+ prefs.edit().putBoolean(source.prefKey, enabled).apply()
+ }
+
+ fun toggle(source: HardwareSource): Boolean {
+ val next = !isEnabled(source)
+ setEnabled(source, next)
+ return next
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt b/android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt
new file mode 100644
index 0000000..62d3557
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/thermal/ThermalMonitor.kt
@@ -0,0 +1,79 @@
+package org.terst.nav.thermal
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.BatteryManager
+import android.os.Build
+import android.os.PowerManager
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+enum class ThermalState {
+ OK, // < 40 °C
+ WARM, // 40–45 °C
+ HOT, // 45–50 °C → alarm
+ CRITICAL // > 50 °C → urgent alarm
+}
+
+data class ThermalReading(
+ val batteryTempC: Float,
+ val state: ThermalState,
+ val powerManagerState: Int = -1 // PowerManager.THERMAL_STATUS_* on API 29+
+)
+
+object ThermalThresholds {
+ const val WARM_C = 40f
+ const val HOT_C = 45f
+ const val CRITICAL_C = 50f
+}
+
+fun batteryTempToState(tempC: Float): ThermalState = when {
+ tempC >= ThermalThresholds.CRITICAL_C -> ThermalState.CRITICAL
+ tempC >= ThermalThresholds.HOT_C -> ThermalState.HOT
+ tempC >= ThermalThresholds.WARM_C -> ThermalState.WARM
+ else -> ThermalState.OK
+}
+
+/**
+ * Emits [ThermalReading] whenever the battery temperature changes.
+ * On API 29+ also incorporates PowerManager thermal status.
+ * The flow is distinct — only emits when state changes.
+ */
+fun thermalFlow(context: Context): Flow<ThermalReading> = callbackFlow {
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
+
+ // PowerManager thermal status listener (API 29+)
+ var pmStatus = -1
+ val pmListener: Any? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val listener = PowerManager.OnThermalStatusChangedListener { status ->
+ pmStatus = status
+ }
+ powerManager?.addThermalStatusListener(listener)
+ listener
+ } else null
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ val rawTemp = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1)
+ if (rawTemp < 0) return
+ val tempC = rawTemp / 10f
+ val state = batteryTempToState(tempC)
+ trySend(ThermalReading(tempC, state, pmStatus))
+ }
+ }
+
+ context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+
+ awaitClose {
+ context.unregisterReceiver(receiver)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && pmListener != null) {
+ powerManager?.removeThermalStatusListener(
+ pmListener as PowerManager.OnThermalStatusChangedListener
+ )
+ }
+ }
+}.distinctUntilChanged { a, b -> a.state == b.state }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
new file mode 100644
index 0000000..b1e5652
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
@@ -0,0 +1,88 @@
+package org.terst.nav.tide
+
+import com.example.androidapp.data.model.TidePrediction
+import com.example.androidapp.data.model.TideStation
+import kotlin.math.cos
+
+/**
+ * Computes harmonic tide predictions using the standard formula:
+ * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
+ *
+ * where:
+ * Z0 = datum offset (mean water level above chart datum, metres)
+ * Hi = amplitude of constituent i (metres)
+ * ωi = angular speed of constituent i (degrees / hour)
+ * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC)
+ * φi = phase lag (degrees)
+ */
+object HarmonicTideCalculator {
+
+ /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */
+ internal const val EPOCH_MS = 946_684_800_000L
+
+ /**
+ * Predict the tide height at a single moment.
+ *
+ * @param station Tide station with harmonic constituents.
+ * @param timestampMs Unix epoch milliseconds for the desired time.
+ * @return Predicted height in metres above chart datum.
+ */
+ fun predictHeight(station: TideStation, timestampMs: Long): Double {
+ val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0
+ var height = station.datumOffsetMeters
+ for (c in station.constituents) {
+ val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg
+ height += c.amplitudeMeters * cos(Math.toRadians(angleDeg))
+ }
+ return height
+ }
+
+ /**
+ * Predict tide heights over a time range at regular intervals.
+ *
+ * @param station Tide station.
+ * @param fromMs Start of range (Unix milliseconds, inclusive).
+ * @param toMs End of range (Unix milliseconds, inclusive).
+ * @param intervalMs Time step in milliseconds (must be positive).
+ * @return List of [TidePrediction] ordered by ascending timestamp.
+ */
+ fun predictRange(
+ station: TideStation,
+ fromMs: Long,
+ toMs: Long,
+ intervalMs: Long
+ ): List<TidePrediction> {
+ require(intervalMs > 0) { "intervalMs must be positive" }
+ require(fromMs <= toMs) { "fromMs must not exceed toMs" }
+ val predictions = mutableListOf<TidePrediction>()
+ var t = fromMs
+ while (t <= toMs) {
+ predictions += TidePrediction(t, predictHeight(station, t))
+ t += intervalMs
+ }
+ return predictions
+ }
+
+ /**
+ * Find high and low water events from a pre-computed prediction series.
+ *
+ * Detects local maxima (high water) and minima (low water) by comparing
+ * each interior sample with its immediate neighbours.
+ *
+ * @param predictions Ordered list of tide predictions (at least 3 points).
+ * @return Subset list containing only high/low turning points.
+ */
+ fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> {
+ if (predictions.size < 3) return emptyList()
+ val result = mutableListOf<TidePrediction>()
+ for (i in 1 until predictions.size - 1) {
+ val prev = predictions[i - 1].heightMeters
+ val curr = predictions[i].heightMeters
+ val next = predictions[i + 1].heightMeters
+ val isMax = curr >= prev && curr >= next
+ val isMin = curr <= prev && curr <= next
+ if (isMax || isMin) result += predictions[i]
+ }
+ return result
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
new file mode 100644
index 0000000..3287f1d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt
@@ -0,0 +1,96 @@
+package org.terst.nav.track
+
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.time.Instant
+
+/**
+ * Parses a GPX 1.1 file produced by [GpxSerializer] back into a list of [TrackPoint]s.
+ *
+ * Uses XmlPullParser (built into Android, also available on JVM via kxml2) so
+ * no additional XML dependencies are required.
+ */
+object GpxParser {
+
+ fun parse(input: InputStream): List<TrackPoint> {
+ val points = mutableListOf<TrackPoint>()
+ val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware = true }
+ val xpp = factory.newPullParser()
+ xpp.setInput(input, "UTF-8")
+
+ var lat = 0.0; var lon = 0.0; var timeMs = 0L
+ var sog = 0.0; var cog = 0.0
+ var hdg: Double? = null; var bsp: Double? = null
+ var depth: Double? = null; var baro: Double? = null
+ var windSpd: Double? = null; var windAng: Double? = null
+ var trueWind = false; var airTemp: Double? = null
+ var waveHt: Double? = null; var currSpd: Double? = null; var currDir: Double? = null
+ var inExtensions = false; var currentTag = ""
+
+ var event = xpp.eventType
+ while (event != XmlPullParser.END_DOCUMENT) {
+ when (event) {
+ XmlPullParser.START_TAG -> {
+ currentTag = xpp.name ?: ""
+ when (currentTag) {
+ "trkpt" -> {
+ lat = xpp.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: 0.0
+ lon = xpp.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: 0.0
+ // reset fields for this point
+ timeMs = 0L; sog = 0.0; cog = 0.0
+ hdg = null; bsp = null; depth = null; baro = null
+ windSpd = null; windAng = null; trueWind = false
+ airTemp = null; waveHt = null; currSpd = null; currDir = null
+ }
+ "extensions" -> inExtensions = true
+ }
+ }
+ XmlPullParser.TEXT -> {
+ val text = xpp.text?.trim() ?: ""
+ if (text.isEmpty()) { event = xpp.next(); continue }
+ when {
+ currentTag == "time" -> timeMs = runCatching {
+ Instant.parse(text).toEpochMilli()
+ }.getOrDefault(0L)
+ inExtensions -> when (currentTag) {
+ "sog" -> sog = text.toDoubleOrNull() ?: sog
+ "cog" -> cog = text.toDoubleOrNull() ?: cog
+ "hdg" -> hdg = text.toDoubleOrNull()
+ "bsp" -> bsp = text.toDoubleOrNull()
+ "depth" -> depth = text.toDoubleOrNull()
+ "baro" -> baro = text.toDoubleOrNull()
+ "windSpd" -> windSpd = text.toDoubleOrNull()
+ "windAng" -> windAng = text.toDoubleOrNull()
+ "trueWind" -> trueWind = text == "true"
+ "airTemp" -> airTemp = text.toDoubleOrNull()
+ "waveHt" -> waveHt = text.toDoubleOrNull()
+ "currSpd" -> currSpd = text.toDoubleOrNull()
+ "currDir" -> currDir = text.toDoubleOrNull()
+ }
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ val tag = xpp.name ?: ""
+ when (tag) {
+ "trkpt" -> points.add(TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sog, cogDeg = cog,
+ headingDeg = hdg, waterSpeedKnots = bsp,
+ depthMeters = depth, baroHpa = baro,
+ windSpeedKnots = windSpd, windAngleDeg = windAng,
+ isTrueWind = trueWind, airTempC = airTemp,
+ waveHeightM = waveHt, currentSpeedKts = currSpd,
+ currentDirDeg = currDir,
+ timestampMs = if (timeMs > 0) timeMs else System.currentTimeMillis()
+ ))
+ "extensions" -> inExtensions = false
+ }
+ currentTag = ""
+ }
+ }
+ event = xpp.next()
+ }
+ return points
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
new file mode 100644
index 0000000..e4b9448
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt
@@ -0,0 +1,62 @@
+package org.terst.nav.track
+
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+/**
+ * Serializes a list of [TrackPoint]s to a GPX 1.1 XML string.
+ *
+ * Nav-specific fields (SOG, COG, depth, baro, wind) are stored in a
+ * `<extensions>` block under the `nav:` namespace so the file remains
+ * valid GPX while preserving full fidelity for round-trip reload.
+ */
+object GpxSerializer {
+
+ private val ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
+ .withZone(ZoneOffset.UTC)
+
+ fun serialize(points: List<TrackPoint>, trackName: String): String = buildString {
+ appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
+ appendLine(
+ """<gpx version="1.1" creator="Nav" """ +
+ """xmlns="http://www.topografix.com/GPX/1/1" """ +
+ """xmlns:nav="https://terst.org/nav/gpx/1">"""
+ )
+ appendLine(" <trk>")
+ appendLine(" <name>${escapeXml(trackName)}</name>")
+ appendLine(" <trkseg>")
+
+ for (pt in points) {
+ appendLine(""" <trkpt lat="${pt.lat}" lon="${pt.lon}">""")
+ appendLine(" <ele>0</ele>")
+ appendLine(" <time>${ISO8601.format(Instant.ofEpochMilli(pt.timestampMs))}</time>")
+ appendLine(" <extensions>")
+ appendLine(" <nav:sog>${pt.sogKnots}</nav:sog>")
+ appendLine(" <nav:cog>${pt.cogDeg}</nav:cog>")
+ pt.headingDeg?.let { appendLine(" <nav:hdg>$it</nav:hdg>") }
+ pt.waterSpeedKnots?.let { appendLine(" <nav:bsp>$it</nav:bsp>") }
+ pt.depthMeters?.let { appendLine(" <nav:depth>$it</nav:depth>") }
+ pt.baroHpa?.let { appendLine(" <nav:baro>$it</nav:baro>") }
+ pt.windSpeedKnots?.let { appendLine(" <nav:windSpd>$it</nav:windSpd>") }
+ pt.windAngleDeg?.let { appendLine(" <nav:windAng>$it</nav:windAng>") }
+ if (pt.isTrueWind) { appendLine(" <nav:trueWind>true</nav:trueWind>") }
+ pt.airTempC?.let { appendLine(" <nav:airTemp>$it</nav:airTemp>") }
+ pt.waveHeightM?.let { appendLine(" <nav:waveHt>$it</nav:waveHt>") }
+ pt.currentSpeedKts?.let { appendLine(" <nav:currSpd>$it</nav:currSpd>") }
+ pt.currentDirDeg?.let { appendLine(" <nav:currDir>$it</nav:currDir>") }
+ appendLine(" </extensions>")
+ appendLine(" </trkpt>")
+ }
+
+ appendLine(" </trkseg>")
+ appendLine(" </trk>")
+ append("</gpx>")
+ }
+
+ private fun escapeXml(s: String) = s
+ .replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;")
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
new file mode 100644
index 0000000..ed38e5e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.track
+
+data class TrackPoint(
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDeg: Double,
+ val headingDeg: Double? = null,
+ val waterSpeedKnots: Double? = null,
+ val depthMeters: Double? = null,
+ val baroHpa: Double? = null,
+ val windSpeedKnots: Double? = null,
+ val windAngleDeg: Double? = null,
+ val isTrueWind: Boolean = false,
+ val airTempC: Double? = null,
+ val waveHeightM: Double? = null,
+ val currentSpeedKts: Double? = null,
+ val currentDirDeg: Double? = null,
+ val timestampMs: Long = System.currentTimeMillis()
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
new file mode 100644
index 0000000..4a28bb4
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,110 @@
+package org.terst.nav.track
+
+import android.content.Context
+import android.util.Log
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.terst.nav.ui.NavLogger
+
+class TrackRepository(context: Context) {
+
+ private val storage = TrackStorage(context)
+
+ var isRecording: Boolean = false
+ private set
+
+ private val activePoints = mutableListOf<TrackPoint>()
+
+ /** Epoch-ms when the current (or last) track started. Exposed for the summary sheet title. */
+ var trackStartMs: Long = 0L
+ private set
+
+ // Loaded lazily from Documents/Nav/ on first access
+ private val _pastTracks = mutableListOf<List<TrackPoint>>()
+ private var pastTracksLoaded = false
+
+ fun startTrack() {
+ activePoints.clear()
+ trackStartMs = System.currentTimeMillis()
+ isRecording = true
+ }
+
+ /**
+ * Stops the active track, computes a [TrackSummary], persists the track
+ * to shared storage, and returns the summary. Returns null if the track
+ * was too short (< 2 minutes) or had no points.
+ */
+
+ suspend fun stopTrack(): TrackSummary? = withContext(Dispatchers.IO) {
+ if (!isRecording) return@withContext null
+ isRecording = false
+ val points = activePoints.toList()
+ activePoints.clear()
+ if (points.size < 2) return@withContext null
+
+ val summary = summarise(points)
+ // Discard tracks shorter than 2 minutes — likely accidental taps
+ if (summary.durationMs < 2 * 60_000L) return@withContext null
+
+ NavLogger.i("track", "stopTrack: ${points.size} pts dur=${summary.durationMs / 60_000}min dist=%.2fnm".format(summary.distanceNm))
+ _pastTracks.add(0, points)
+ val saved = storage.saveTrack(points, trackStartMs)
+ if (!saved) {
+ Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart")
+ NavLogger.e("track", "saveTrack returned false — ${points.size} pts lost on restart")
+ }
+ summary
+ }
+
+ fun addPoint(point: TrackPoint): Boolean {
+ if (!isRecording) return false
+ activePoints.add(point)
+ return true
+ }
+
+ fun getPoints(): List<TrackPoint> = activePoints.toList()
+
+ fun computeStats(): TrackStats? {
+ if (activePoints.size < 2) return null
+ var distNm = 0.0
+ for (i in 1 until activePoints.size) {
+ distNm += haversineNm(activePoints[i - 1].lat, activePoints[i - 1].lon, activePoints[i].lat, activePoints[i].lon)
+ }
+ val durationMs = activePoints.last().timestampMs - activePoints.first().timestampMs
+ val avgSog = if (durationMs > 0) {
+ activePoints.map { it.sogKnots }.average()
+ } else 0.0
+ return TrackStats(distNm, durationMs, avgSog)
+ }
+
+ /**
+ * Returns all completed tracks, loading from Documents/Nav/ on first call.
+ * Subsequent calls return the in-memory list (storage is source of truth
+ * only at startup).
+ */
+ suspend fun getPastTracks(): List<List<TrackPoint>> = withContext(Dispatchers.IO) {
+ if (!pastTracksLoaded) {
+ pastTracksLoaded = true
+ val stored = storage.loadAllTracks()
+ // Merge: put stored tracks behind any in-memory tracks from this session
+ _pastTracks.addAll(stored)
+ }
+ _pastTracks.toList()
+ }
+
+ companion object {
+ fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ return r * 2 * atan2(sqrt(a), sqrt(1 - a))
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt
new file mode 100644
index 0000000..39153c6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.track
+
+data class TrackStats(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val avgSogKnots: Double
+) {
+ val durationFormatted: String get() {
+ val totalSec = durationMs / 1000
+ val h = totalSec / 3600
+ val m = (totalSec % 3600) / 60
+ val s = totalSec % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
new file mode 100644
index 0000000..3a04688
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
@@ -0,0 +1,163 @@
+package org.terst.nav.track
+
+import android.content.ContentValues
+import android.content.Context
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Log
+import org.terst.nav.ui.NavLogger
+import java.io.File
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+private const val TAG = "TrackStorage"
+
+/**
+ * Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
+ *
+ * Files written here survive app uninstall because they live in user-owned
+ * shared storage rather than app-private storage.
+ *
+ * API 29+: uses MediaStore (no permission required for Documents/).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
+ * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ */
+class TrackStorage(private val context: Context) {
+
+ private val fileTimestamp = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd_HH-mm-ss")
+ .withZone(ZoneOffset.UTC)
+
+ private val trackName = DateTimeFormatter
+ .ofPattern("yyyy-MM-dd HH:mm")
+ .withZone(ZoneOffset.UTC)
+
+ /** Write a completed track to Documents/Nav/. Returns true on success. */
+ fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
+ if (points.isEmpty()) return false
+ val name = trackName.format(Instant.ofEpochMilli(startMs))
+ val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
+ val gpx = GpxSerializer.serialize(points, name)
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveViaMediaStore(fileName, gpx)
+ } else {
+ saveViaFile(fileName, gpx)
+ }
+ }
+
+ /** Load all tracks previously saved to Documents/Nav/. */
+ fun loadAllTracks(): List<List<TrackPoint>> {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ loadViaMediaStore()
+ } else {
+ loadViaFile()
+ }
+ }
+
+ // ── API 29+ ──────────────────────────────────────────────────────────────
+
+ private fun saveViaMediaStore(fileName: String, gpx: String): Boolean {
+ // Guard: external storage must be mounted before touching MediaStore
+ val storageState = Environment.getExternalStorageState()
+ if (storageState != Environment.MEDIA_MOUNTED) {
+ val msg = "storage not mounted (state=$storageState) — cannot save $fileName"
+ Log.e(TAG, msg); NavLogger.e("track", msg)
+ return false
+ }
+
+ // IS_PENDING marks the entry as in-progress, preventing a race condition on
+ // Android 10-11 where insert() succeeds but openOutputStream() returns null
+ // because the file hasn't been physically created on disk yet.
+ val values = ContentValues().apply {
+ put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml")
+ put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/")
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ NavLogger.i("track", "MediaStore insert → Documents/Nav/$fileName")
+ val uri = context.contentResolver.insert(
+ MediaStore.Files.getContentUri("external"), values
+ ) ?: run {
+ val msg = "MediaStore insert returned null for $fileName"
+ Log.e(TAG, msg); NavLogger.e("track", msg)
+ return false
+ }
+
+ return runCatching {
+ val stream = context.contentResolver.openOutputStream(uri)
+ if (stream == null) {
+ context.contentResolver.delete(uri, null, null)
+ val msg = "openOutputStream null for $fileName — deleted orphan entry"
+ Log.e(TAG, msg); NavLogger.e("track", msg)
+ return@runCatching false
+ }
+ stream.use { it.write(gpx.toByteArray()) }
+
+ // Clear IS_PENDING so the file is visible to other apps and file managers
+ val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
+ context.contentResolver.update(uri, update, null, null)
+
+ val msg = "saved $fileName (${gpx.length} bytes) → $uri"
+ Log.d(TAG, msg); NavLogger.i("track", msg)
+ true
+ }.getOrElse { e ->
+ val msg = "write failed for $fileName: ${e.javaClass.simpleName}: ${e.message}"
+ Log.e(TAG, msg); NavLogger.e("track", msg)
+ runCatching { context.contentResolver.delete(uri, null, null) }
+ false
+ }
+ }
+
+ private fun loadViaMediaStore(): List<List<TrackPoint>> {
+ val tracks = mutableListOf<List<TrackPoint>>()
+ val uri = MediaStore.Files.getContentUri("external")
+ val projection = arrayOf(MediaStore.Files.FileColumns._ID)
+ val selection = "${MediaStore.Files.FileColumns.RELATIVE_PATH} = ? " +
+ "AND ${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?"
+ val args = arrayOf("Documents/Nav/", "nav_%.gpx")
+
+ context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor ->
+ val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ while (cursor.moveToNext()) {
+ val fileUri = android.net.Uri.withAppendedPath(uri, cursor.getLong(idCol).toString())
+ runCatching {
+ context.contentResolver.openInputStream(fileUri)?.use { stream ->
+ val points = GpxParser.parse(stream)
+ if (points.isNotEmpty()) tracks.add(points)
+ }
+ }.onFailure { e ->
+ NavLogger.e("track", "load parse error: ${e.javaClass.simpleName}: ${e.message}")
+ }
+ }
+ }
+ NavLogger.i("track", "loadViaMediaStore: ${tracks.size} tracks found")
+ return tracks
+ }
+
+ // ── API < 29 ─────────────────────────────────────────────────────────────
+
+ private fun navDir(): File {
+ val docs = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
+ return File(docs, "Nav").also { it.mkdirs() }
+ }
+
+ private fun saveViaFile(fileName: String, gpx: String): Boolean = runCatching {
+ File(navDir(), fileName).writeText(gpx)
+ true
+ }.getOrDefault(false)
+
+ private fun loadViaFile(): List<List<TrackPoint>> {
+ val dir = navDir()
+ if (!dir.exists()) return emptyList()
+ return dir.listFiles { f -> f.name.startsWith("nav_") && f.name.endsWith(".gpx") }
+ ?.sortedBy { it.name }
+ ?.mapNotNull { file ->
+ runCatching { GpxParser.parse(file.inputStream()) }
+ .getOrNull()
+ ?.takeIf { it.isNotEmpty() }
+ } ?: emptyList()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
new file mode 100644
index 0000000..3f2f3be
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.track
+
+import kotlin.math.*
+
+data class TrackSummary(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val maxSogKt: Double,
+ val avgSogKt: Double,
+ val avgWindKt: Double?, // null if no wind data in track
+ val avgWaveHeightM: Double? // null if no wave data in track
+) {
+ val durationMinutes: Long get() = durationMs / 60_000
+}
+
+/** Computes a [TrackSummary] from a completed list of [TrackPoint]s. */
+fun summarise(points: List<TrackPoint>): TrackSummary {
+ require(points.isNotEmpty())
+
+ var distanceNm = 0.0
+ for (i in 1 until points.size) {
+ distanceNm += haversineNm(points[i - 1], points[i])
+ }
+
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val windReadings = points.mapNotNull { it.windSpeedKnots }
+ val avgWind = if (windReadings.isNotEmpty()) windReadings.average() else null
+
+ val waveReadings = points.mapNotNull { it.waveHeightM }
+ val avgWave = if (waveReadings.isNotEmpty()) waveReadings.average() else null
+
+ return TrackSummary(
+ distanceNm = distanceNm,
+ durationMs = durationMs,
+ maxSogKt = maxSog,
+ avgSogKt = avgSog,
+ avgWindKt = avgWind,
+ avgWaveHeightM = avgWave
+ )
+}
+
+private fun haversineNm(a: TrackPoint, b: TrackPoint): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(b.lat - a.lat)
+ val dLon = Math.toRadians(b.lon - a.lon)
+ val sinDLat = sin(dLat / 2)
+ val sinDLon = sin(dLon / 2)
+ val h = sinDLat * sinDLat +
+ cos(Math.toRadians(a.lat)) * cos(Math.toRadians(b.lat)) * sinDLon * sinDLon
+ return 2 * r * asin(sqrt(h))
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
new file mode 100644
index 0000000..8d9d7c7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummarySheet.kt
@@ -0,0 +1,95 @@
+package org.terst.nav.track
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.terst.nav.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+class TrackSummarySheet : BottomSheetDialogFragment() {
+
+ companion object {
+ private const val ARG_DISTANCE = "distance_nm"
+ private const val ARG_DURATION = "duration_ms"
+ private const val ARG_MAX_SOG = "max_sog"
+ private const val ARG_AVG_SOG = "avg_sog"
+ private const val ARG_AVG_WIND = "avg_wind" // -1 if absent
+ private const val ARG_AVG_WAVE = "avg_wave_m" // -1 if absent
+ private const val ARG_START_MS = "start_ms"
+
+ fun from(summary: TrackSummary, startMs: Long) = TrackSummarySheet().apply {
+ arguments = Bundle().apply {
+ putDouble(ARG_DISTANCE, summary.distanceNm)
+ putLong(ARG_DURATION, summary.durationMs)
+ putDouble(ARG_MAX_SOG, summary.maxSogKt)
+ putDouble(ARG_AVG_SOG, summary.avgSogKt)
+ putDouble(ARG_AVG_WIND, summary.avgWindKt ?: -1.0)
+ putDouble(ARG_AVG_WAVE, summary.avgWaveHeightM ?: -1.0)
+ putLong(ARG_START_MS, startMs)
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_track_summary_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val args = requireArguments()
+ val distanceNm = args.getDouble(ARG_DISTANCE)
+ val durationMs = args.getLong(ARG_DURATION)
+ val maxSog = args.getDouble(ARG_MAX_SOG)
+ val avgSog = args.getDouble(ARG_AVG_SOG)
+ val avgWind = args.getDouble(ARG_AVG_WIND).takeIf { it >= 0 }
+ val avgWaveM = args.getDouble(ARG_AVG_WAVE).takeIf { it >= 0 }
+ val startMs = args.getLong(ARG_START_MS)
+
+ val titleFmt = DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ view.findViewById<TextView>(R.id.summary_title).text =
+ "Track · ${titleFmt.format(Instant.ofEpochMilli(startMs))}"
+
+ view.findViewById<TextView>(R.id.summary_distance).text =
+ "%.1f".format(Locale.getDefault(), distanceNm)
+
+ view.findViewById<TextView>(R.id.summary_duration).text = formatDuration(durationMs)
+
+ view.findViewById<TextView>(R.id.summary_max_sog).text =
+ "%.1f".format(Locale.getDefault(), maxSog)
+
+ view.findViewById<TextView>(R.id.summary_avg_sog).text =
+ "%.1f".format(Locale.getDefault(), avgSog)
+
+ val conditionsRow = view.findViewById<LinearLayout>(R.id.summary_conditions_row)
+ val windCell = view.findViewById<LinearLayout>(R.id.summary_wind_cell)
+ val waveCell = view.findViewById<LinearLayout>(R.id.summary_wave_cell)
+
+ if (avgWind != null) {
+ windCell.visibility = View.VISIBLE
+ view.findViewById<TextView>(R.id.summary_avg_wind).text =
+ "%.0f".format(Locale.getDefault(), avgWind)
+ }
+ if (avgWaveM != null) {
+ waveCell.visibility = View.VISIBLE
+ val waveFt = avgWaveM * 3.28084
+ view.findViewById<TextView>(R.id.summary_avg_wave).text =
+ "%.1f".format(Locale.getDefault(), waveFt)
+ }
+ if (avgWind != null || avgWaveM != null) {
+ conditionsRow.visibility = View.VISIBLE
+ }
+ }
+
+ private fun formatDuration(ms: Long): String {
+ val totalMinutes = ms / 60_000
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+ return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt
new file mode 100644
index 0000000..090e928
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/BoatProfileRepository.kt
@@ -0,0 +1,137 @@
+package org.terst.nav.tripreport
+
+import android.content.Context
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+
+/**
+ * Persists boat profiles to SharedPreferences as a JSON array.
+ * Pre-seeds Erickson 23 and Cal 20 on first launch.
+ */
+class BoatProfileRepository(context: Context) {
+
+ private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
+ private val listType = Types.newParameterizedType(List::class.java, ProfileJson::class.java)
+ private val adapter = moshi.adapter<List<ProfileJson>>(listType)
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ fun loadProfiles(): List<BoatProfile> {
+ val json = prefs.getString(KEY_PROFILES, null)
+ val list = if (json != null) runCatching { adapter.fromJson(json) }.getOrNull() else null
+ return if (list.isNullOrEmpty()) {
+ val defaults = defaultProfiles()
+ saveProfiles(defaults)
+ defaults
+ } else {
+ list.map { it.toDomain() }
+ }
+ }
+
+ fun saveProfiles(profiles: List<BoatProfile>) {
+ prefs.edit().putString(KEY_PROFILES, adapter.toJson(profiles.map { ProfileJson.from(it) })).apply()
+ }
+
+ fun selectedId(): String? = prefs.getString(KEY_SELECTED, null)
+
+ fun setSelectedId(id: String) = prefs.edit().putString(KEY_SELECTED, id).apply()
+
+ fun selectedProfile(): BoatProfile? {
+ val id = selectedId() ?: return loadProfiles().firstOrNull()
+ return loadProfiles().firstOrNull { it.id == id }
+ }
+
+ // ── JSON transfer object (Moshi needs simple primitives) ─────────────────
+
+ @JsonClass(generateAdapter = true)
+ data class SailJson(
+ val name: String,
+ val minWindKt: Int,
+ val maxWindKt: Int,
+ val isHeadsail: Boolean
+ ) {
+ fun toDomain() = SailConfig(name, minWindKt, maxWindKt, isHeadsail)
+ companion object {
+ fun from(s: SailConfig) = SailJson(s.name, s.minWindKt, s.maxWindKt, s.isHeadsail)
+ }
+ }
+
+ @JsonClass(generateAdapter = true)
+ data class ProfileJson(
+ val id: String,
+ val name: String,
+ val lengthFt: Double,
+ val type: String, // BoatType.name
+ val rig: String, // RigType.name
+ val headsails: List<SailJson>,
+ val mainReefs: Int,
+ val hasSpinnaker: Boolean,
+ val hasGennaker: Boolean,
+ val notes: String
+ ) {
+ fun toDomain() = BoatProfile(
+ id = id,
+ name = name,
+ lengthFt = lengthFt,
+ type = runCatching { BoatType.valueOf(type) }.getOrDefault(BoatType.MONOHULL),
+ rig = runCatching { RigType.valueOf(rig) }.getOrDefault(RigType.SLOOP),
+ headsails = headsails.map { it.toDomain() },
+ mainReefs = mainReefs,
+ hasSpinnaker = hasSpinnaker,
+ hasGennaker = hasGennaker,
+ notes = notes
+ )
+ companion object {
+ fun from(p: BoatProfile) = ProfileJson(
+ id = p.id,
+ name = p.name,
+ lengthFt = p.lengthFt,
+ type = p.type.name,
+ rig = p.rig.name,
+ headsails = p.headsails.map { SailJson.from(it) },
+ mainReefs = p.mainReefs,
+ hasSpinnaker = p.hasSpinnaker,
+ hasGennaker = p.hasGennaker,
+ notes = p.notes
+ )
+ }
+ }
+
+ companion object {
+ private const val PREF_NAME = "boat_profiles"
+ private const val KEY_PROFILES = "profiles"
+ private const val KEY_SELECTED = "selected_id"
+
+ fun defaultProfiles() = listOf(
+ BoatProfile(
+ id = "e23",
+ name = "Erickson 23",
+ lengthFt = 23.0,
+ type = BoatType.MONOHULL,
+ rig = RigType.SLOOP,
+ headsails = listOf(
+ SailConfig("155% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true),
+ SailConfig("100% Jib", minWindKt = 10, maxWindKt = 21, isHeadsail = true),
+ SailConfig("65% Blade", minWindKt = 18, maxWindKt = 40, isHeadsail = true)
+ ),
+ mainReefs = 2
+ ),
+ BoatProfile(
+ id = "cal20",
+ name = "Cal 20",
+ lengthFt = 20.0,
+ type = BoatType.MONOHULL,
+ rig = RigType.SLOOP,
+ headsails = listOf(
+ SailConfig("150% Genoa", minWindKt = 0, maxWindKt = 13, isHeadsail = true),
+ SailConfig("100% Jib", minWindKt = 10, maxWindKt = 22, isHeadsail = true),
+ SailConfig("65% Blade", minWindKt = 19, maxWindKt = 40, isHeadsail = true)
+ ),
+ mainReefs = 1
+ )
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
new file mode 100644
index 0000000..c1bc6e7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt
@@ -0,0 +1,91 @@
+package org.terst.nav.tripreport
+
+enum class BoatType { MONOHULL, MULTIHULL }
+enum class RigType { SLOOP, CUTTER, KETCH }
+
+/**
+ * A single headsail (or main-sail configuration) in the boat's inventory.
+ * Wind ranges are approximate apparent-wind speeds at which the sail is appropriate.
+ */
+data class SailConfig(
+ val name: String, // e.g. "155% Genoa", "100% Jib", "65% Blade"
+ val minWindKt: Int = 0,
+ val maxWindKt: Int,
+ val isHeadsail: Boolean
+)
+
+data class BoatProfile(
+ val id: String,
+ val name: String,
+ val lengthFt: Double,
+ val type: BoatType,
+ val rig: RigType,
+ val headsails: List<SailConfig> = emptyList(),
+ val mainReefs: Int = 0,
+ val hasSpinnaker: Boolean = false,
+ val hasGennaker: Boolean = false,
+ val notes: String = ""
+)
+
+// ── Snapshot used to build the report ────────────────────────────────────────
+
+data class PreTripSummary(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val windSpeedKt: Double,
+ val windDirDeg: Double,
+ val waveHeightM: Double?,
+ val weatherDescription: String,
+ val boatProfile: BoatProfile
+)
+
+// ── Output models ─────────────────────────────────────────────────────────────
+
+/** One hourly step in the trip-window forecast table. */
+data class ConditionSlice(
+ val label: String, // "Now", "+1 h", "+2 h", "+3 h"
+ val windKt: Double,
+ val windDirDeg: Double,
+ val waveHeightM: Double?,
+ val weatherDescription: String
+)
+
+/** Suggested heading and distance for a round-trip sail. */
+data class RouteProjection(
+ val departureHeadingDeg: Double,
+ val outboundLegNm: Double, // nm before recommended turn-around
+ val returnHeadingDeg: Double,
+ val outboundPointOfSail: String, // "Beam Reach", "Close Hauled", etc.
+ val returnPointOfSail: String,
+ val rationale: String // human-readable description
+)
+
+/** A single hazard or advisory notice. */
+data class WatchItem(
+ val severity: String, // "⚠️" warning or "ℹ️" info
+ val message: String
+)
+
+/** Aggregated stats from past trips that had similar wind conditions. */
+data class SimilarTripSummary(
+ val count: Int,
+ val avgDistanceNm: Double,
+ val avgDurationHrs: Double,
+ val avgMaxSogKt: Double,
+ val conditionDescription: String // e.g. "10–15 kt NE"
+)
+
+data class SailSuggestion(
+ val sailName: String,
+ val action: String
+)
+
+data class PreTripReport(
+ val summary: PreTripSummary,
+ val conditionWindow: List<ConditionSlice>,
+ val routeProjection: RouteProjection,
+ val sailPlan: List<SailSuggestion>,
+ val watchItems: List<WatchItem>,
+ val similarTrips: SimilarTripSummary?
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
new file mode 100644
index 0000000..ac1350d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt
@@ -0,0 +1,297 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import 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() {
+
+ private val mainViewModel: MainViewModel by activityViewModels()
+ private lateinit var viewModel: PreTripReportViewModel
+
+ private lateinit var chipGroupBoats: ChipGroup
+ 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
+ private lateinit var cardRoute: MaterialCardView
+ private lateinit var tvRoute: TextView
+ private lateinit var cardSailPlan: MaterialCardView
+ private lateinit var tvSailPlan: TextView
+ private lateinit var cardWatchlist: MaterialCardView
+ private lateinit var tvWatchlist: TextView
+ private lateinit var cardSimilar: MaterialCardView
+ private lateinit var tvSimilar: TextView
+ private lateinit var tvError: TextView
+
+ 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)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProvider(
+ requireActivity(),
+ PreTripReportViewModel.Factory(NavApplication.boatProfileRepository)
+ )[PreTripReportViewModel::class.java]
+
+ bindViews(view)
+ observeViewModel()
+
+ btnGenerate.setOnClickListener { triggerGenerate() }
+ btnPickDeparture.setOnClickListener { showDeparturePicker() }
+
+ if (mainViewModel.forecast.value.isNotEmpty()) {
+ triggerGenerate()
+ }
+ }
+
+ private fun bindViews(view: View) {
+ 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() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.profiles.collect { profiles -> rebuildBoatChips(profiles) }
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.selectedProfile.collect { profile ->
+ if (profile != null) syncChipSelection(profile.id)
+ }
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.departureMs.collect { ms -> renderDepartureLabel(ms) }
+ }
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { renderState(it) }
+ }
+ }
+
+ // ── Departure picker ──────────────────────────────────────────────────────
+
+ private fun showDeparturePicker() {
+ val currentMs = viewModel.departureMs.value
+
+ // Constrain to today or future
+ val constraints = CalendarConstraints.Builder()
+ .setValidator(DateValidatorPointForward.now())
+ .build()
+
+ val datePicker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText("Departure date")
+ .setSelection(currentMs)
+ .setCalendarConstraints(constraints)
+ .build()
+
+ datePicker.addOnPositiveButtonClickListener { dateMs ->
+ // dateMs is midnight UTC of the selected date; add current time-of-day offset
+ val cal = Calendar.getInstance().apply { timeInMillis = dateMs }
+ // Default to current hour of day, rounded to nearest half-hour
+ val now = Calendar.getInstance()
+ cal.set(Calendar.HOUR_OF_DAY, now.get(Calendar.HOUR_OF_DAY))
+ cal.set(Calendar.MINUTE, 0)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+
+ val timePicker = MaterialTimePicker.Builder()
+ .setTitleText("Departure time")
+ .setTimeFormat(TimeFormat.CLOCK_12H)
+ .setHour(cal.get(Calendar.HOUR_OF_DAY))
+ .setMinute(0)
+ .build()
+
+ timePicker.addOnPositiveButtonClickListener {
+ cal.set(Calendar.HOUR_OF_DAY, timePicker.hour)
+ cal.set(Calendar.MINUTE, timePicker.minute)
+ viewModel.setDeparture(cal.timeInMillis)
+ // Re-generate with new departure time
+ if (mainViewModel.forecast.value.isNotEmpty()) triggerGenerate()
+ }
+
+ timePicker.show(childFragmentManager, "time_picker")
+ }
+
+ datePicker.show(childFragmentManager, "date_picker")
+ }
+
+ private fun renderDepartureLabel(ms: Long) {
+ val now = System.currentTimeMillis()
+ tvDepartureTime.text = if (abs(ms - now) < 3_600_000L) {
+ "Now"
+ } else {
+ departureSdf.format(ms)
+ }
+ }
+
+ // ── Boat chips ────────────────────────────────────────────────────────────
+
+ private fun rebuildBoatChips(profiles: List<BoatProfile>) {
+ chipGroupBoats.removeAllViews()
+ val selectedId = viewModel.selectedProfile.value?.id
+ profiles.forEach { profile ->
+ val chip = Chip(requireContext()).apply {
+ text = profile.name
+ isCheckable = true
+ isChecked = (profile.id == selectedId)
+ tag = profile.id
+ }
+ chip.setOnCheckedChangeListener { _, checked ->
+ if (checked) {
+ viewModel.selectProfile(profile.id)
+ triggerGenerate()
+ }
+ }
+ chipGroupBoats.addView(chip)
+ }
+ }
+
+ private fun syncChipSelection(selectedId: String) {
+ for (i in 0 until chipGroupBoats.childCount) {
+ val chip = chipGroupBoats.getChildAt(i) as? Chip ?: continue
+ chip.isChecked = (chip.tag == selectedId)
+ }
+ }
+
+ private fun triggerGenerate() {
+ val forecast = mainViewModel.forecast.value
+ val conditions = mainViewModel.marineConditions.value
+ viewModel.generate(forecast, conditions)
+ }
+
+ // ── Rendering ─────────────────────────────────────────────────────────────
+
+ private fun renderState(state: PreTripState) {
+ tvError.visibility = View.GONE
+ when (state) {
+ is PreTripState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnGenerate.isEnabled = false
+ }
+ is PreTripState.Success -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ renderReport(state.report)
+ }
+ is PreTripState.Error -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ tvError.text = state.message
+ tvError.visibility = View.VISIBLE
+ }
+ else -> {
+ progress.visibility = View.GONE
+ btnGenerate.isEnabled = true
+ }
+ }
+ }
+
+ private fun renderReport(r: PreTripReport) {
+ renderConditionsWindow(r.conditionWindow)
+ renderRoute(r.routeProjection)
+ renderSailPlan(r.sailPlan)
+ renderWatchlist(r.watchItems)
+ renderSimilarTrips(r.similarTrips)
+ }
+
+ private fun renderConditionsWindow(slices: List<ConditionSlice>) {
+ conditionsTable.removeAllViews()
+ slices.forEach { slice ->
+ val col = LayoutInflater.from(requireContext())
+ .inflate(R.layout.item_condition_column, conditionsTable, false)
+ col.findViewById<TextView>(R.id.col_label).text = slice.label
+ col.findViewById<TextView>(R.id.col_wind).text = "%.0f kt".format(slice.windKt)
+ col.findViewById<TextView>(R.id.col_wind_dir).text = cardinalDir(slice.windDirDeg)
+ col.findViewById<TextView>(R.id.col_sky).text = slice.weatherDescription.take(12)
+ conditionsTable.addView(col)
+ }
+ cardConditions.visibility = if (slices.isNotEmpty()) View.VISIBLE else View.GONE
+ }
+
+ private fun renderRoute(proj: RouteProjection) {
+ tvRoute.text = proj.rationale
+ cardRoute.visibility = View.VISIBLE
+ }
+
+ private fun renderSailPlan(plan: List<SailSuggestion>) {
+ tvSailPlan.text = plan.joinToString("\n") { "• ${it.sailName}: ${it.action}" }
+ cardSailPlan.visibility = View.VISIBLE
+ }
+
+ private fun renderWatchlist(items: List<WatchItem>) {
+ if (items.isEmpty()) {
+ cardWatchlist.visibility = View.GONE
+ return
+ }
+ tvWatchlist.text = items.joinToString("\n") { "${it.severity} ${it.message}" }
+ cardWatchlist.visibility = View.VISIBLE
+ }
+
+ private fun renderSimilarTrips(similar: SimilarTripSummary?) {
+ if (similar == null) {
+ cardSimilar.visibility = View.GONE
+ return
+ }
+ tvSimilar.text = "%d trip%s in %s\n• Avg distance %.1f nm\n• Avg duration %.1f h\n• Avg max SOG %.1f kt".format(
+ Locale.getDefault(),
+ similar.count,
+ if (similar.count == 1) "" else "s",
+ similar.conditionDescription,
+ similar.avgDistanceNm,
+ similar.avgDurationHrs,
+ similar.avgMaxSogKt
+ )
+ cardSimilar.visibility = View.VISIBLE
+ }
+
+ private fun cardinalDir(deg: Double): String {
+ val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE",
+ "S","SSW","SW","WSW","W","WNW","NW","NNW")
+ return dirs[((deg + 11.25) / 22.5).toInt() % 16]
+ }
+
+ 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
new file mode 100644
index 0000000..2840d76
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt
@@ -0,0 +1,408 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.summarise
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.math.*
+
+class PreTripReportGenerator {
+
+ /**
+ * Builds a full pre-trip briefing.
+ *
+ * @param lat Current position latitude
+ * @param lon Current position longitude
+ * @param forecastItems Hourly forecast list (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,
+ lon: Double,
+ forecastItems: List<ForecastItem>,
+ conditions: MarineConditions?,
+ boatProfile: BoatProfile,
+ pastTracks: List<List<TrackPoint>> = emptyList(),
+ durationHrs: Double = 3.0,
+ departureDateTimeMs: Long = System.currentTimeMillis()
+ ): PreTripReport {
+ // 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 = departureDateTimeMs,
+ lat = lat,
+ lon = lon,
+ windSpeedKt = windKt,
+ windDirDeg = windDir,
+ waveHeightM = conditions?.waveHeightM,
+ weatherDescription = current?.weatherDescription() ?: "Unknown",
+ boatProfile = boatProfile
+ )
+
+ return PreTripReport(
+ summary = summary,
+ conditionWindow = buildConditionWindow(window, departureDateTimeMs),
+ routeProjection = projectRoute(windDir, windKt,
+ conditions?.currentSpeedKt ?: 0.0,
+ conditions?.currentDirDeg ?: 0.0,
+ durationHrs, boatProfile),
+ sailPlan = suggestSailPlan(windKt, window, boatProfile),
+ watchItems = buildWatchList(conditions, window, boatProfile, departureDateTimeMs),
+ similarTrips = findSimilarTrips(pastTracks, windKt, windDir)
+ )
+ }
+
+ // ── Departure slot lookup ─────────────────────────────────────────────────
+
+ /**
+ * Finds the index of the forecast item closest to [departureMs].
+ * Open-Meteo returns times as UTC ISO strings ("yyyy-MM-dd'T'HH:mm").
+ */
+ private fun findDepartureSlot(items: List<ForecastItem>, departureMs: Long): Int {
+ if (items.isEmpty()) return 0
+ val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+
+ var bestIdx = 0
+ var bestDiff = Long.MAX_VALUE
+ items.forEachIndexed { idx, item ->
+ try {
+ val itemMs = sdf.parse(item.timeIso)?.time ?: return@forEachIndexed
+ val diff = abs(itemMs - departureMs)
+ if (diff < bestDiff) {
+ bestDiff = diff
+ bestIdx = idx
+ }
+ } catch (_: Exception) {}
+ }
+ // Leave at least 4 items for the condition window
+ return bestIdx.coerceAtMost((items.size - 4).coerceAtLeast(0))
+ }
+
+ // ── Condition window ──────────────────────────────────────────────────────
+
+ private fun buildConditionWindow(
+ items: List<ForecastItem>,
+ departureDateTimeMs: Long
+ ): List<ConditionSlice> {
+ val isNearNow = abs(departureDateTimeMs - System.currentTimeMillis()) < 3_600_000L
+ val utcSdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm", Locale.US).also {
+ it.timeZone = TimeZone.getTimeZone("UTC")
+ }
+ // Display times in device local time (e.g. "2 PM")
+ val displaySdf = SimpleDateFormat("h a", Locale.getDefault())
+
+ return items.take(4).mapIndexed { i, item ->
+ val label = when {
+ isNearNow && i == 0 -> "Now"
+ isNearNow -> "+${i}h"
+ else -> try {
+ val t = utcSdf.parse(item.timeIso)
+ if (t != null) displaySdf.format(t) else "+${i}h"
+ } catch (_: Exception) { "+${i}h" }
+ }
+ ConditionSlice(
+ label = label,
+ windKt = item.windKt,
+ windDirDeg = item.windDirDeg,
+ waveHeightM = null,
+ weatherDescription = item.weatherDescription()
+ )
+ }
+ }
+
+ // ── Route projection ──────────────────────────────────────────────────────
+
+ /**
+ * Candidate headings from Honokohau — each carries local context.
+ * Format: (headingDeg, label, localNote)
+ */
+ private val candidates = listOf(
+ Triple(350.0, "N", "Kohala coast — upwind grind out on NE trades, fast run home"),
+ Triple(305.0, "NW", "Offshore — watch for trade acceleration past Upolu Point"),
+ Triple(270.0, "W", "Open water — comfortable in most trade conditions"),
+ Triple(225.0, "SW", "Toward Keauhou — broad reach out, may be a beat home if trades build"),
+ Triple(180.0, "S", "Keauhou Bay — note Alenuihāhā Channel shipping traffic")
+ )
+
+ private fun projectRoute(
+ windDirDeg: Double,
+ twsKt: Double,
+ currentSpeedKt: Double,
+ currentDirDeg: Double,
+ durationHrs: Double,
+ boatProfile: BoatProfile
+ ): RouteProjection {
+ data class Candidate(
+ val heading: Double, val label: String, val note: String,
+ val twa: Double, val sog: Double, val score: Double
+ )
+
+ val scored = candidates.map { (hdg, lbl, note) ->
+ val twa = twa(windDirDeg, hdg)
+ val sog = estimatedSogKt(boatProfile.lengthFt, twsKt, twa)
+ val score = headingScore(twa)
+ Candidate(hdg, lbl, note, twa, sog, score)
+ }
+
+ val best = scored.maxByOrNull { it.score }!!
+
+ val returnHdg = (best.heading + 180.0) % 360.0
+ val returnTwa = twa(windDirDeg, returnHdg)
+ val returnSog = estimatedSogKt(boatProfile.lengthFt, twsKt, returnTwa)
+
+ val outboundHrs = durationHrs / 2.0
+ val outboundNm = best.sog * outboundHrs
+
+ val currentComponent = currentSpeedKt *
+ cos(Math.toRadians(currentDirDeg - best.heading))
+ val currentNote = when {
+ currentComponent > 0.15 -> "Current adds %.1f kt outbound.".format(currentComponent)
+ currentComponent < -0.15 -> "Current opposes %.1f kt outbound.".format(-currentComponent)
+ else -> ""
+ }
+
+ val outboundPos = pointOfSailName(best.twa)
+ val returnPos = pointOfSailName(returnTwa)
+
+ val rationale = buildString {
+ append("Head ${best.label} (${best.heading.toInt()}°) — $outboundPos ")
+ append("at ~%.1f kt. ".format(best.sog))
+ append("Est. turn-around %.1f nm out".format(outboundNm))
+ append(", ~%.0f min. ".format(outboundHrs * 60))
+ append("Return (${returnHdg.toInt()}°): $returnPos at ~%.1f kt.".format(returnSog))
+ if (currentNote.isNotEmpty()) append(" $currentNote")
+ append("\n${best.note}")
+ }
+
+ return RouteProjection(
+ departureHeadingDeg = best.heading,
+ outboundLegNm = outboundNm,
+ returnHeadingDeg = returnHdg,
+ outboundPointOfSail = outboundPos,
+ returnPointOfSail = returnPos,
+ rationale = rationale
+ )
+ }
+
+ /** True wind angle in degrees (0–180) for a given course relative to wind direction. */
+ private fun twa(windDirFrom: Double, boatHdg: Double): Double {
+ var angle = abs(windDirFrom - boatHdg) % 360.0
+ if (angle > 180.0) angle = 360.0 - angle
+ return angle
+ }
+
+ /** Simplified polar SOG estimate based on hull speed limit and point of sail. */
+ fun estimatedSogKt(lengthFt: Double, twsKt: Double, twaDegs: Double): Double {
+ val hullSpeed = 1.34 * sqrt(lengthFt)
+ val efficiency = when {
+ twaDegs < 40 -> 0.45
+ twaDegs < 70 -> 0.70
+ twaDegs < 120 -> 0.85 // beam reach — optimum
+ twaDegs < 150 -> 0.78
+ else -> 0.60 // dead run
+ }
+ val windFactor = when {
+ twsKt < 5 -> 0.30
+ twsKt < 10 -> 0.65
+ twsKt < 15 -> 0.85
+ twsKt < 22 -> 1.00
+ else -> 0.88 // reefed, slightly slower
+ }
+ return (hullSpeed * efficiency * windFactor).coerceAtMost(hullSpeed)
+ }
+
+ /** Higher = better heading; prefer beam reach, penalise hard beat or dead run. */
+ private fun headingScore(twa: Double): Double = when {
+ twa < 35 -> 0.2
+ twa < 55 -> 0.5
+ twa < 125 -> 1.0 // broad beam reach window — ideal
+ twa < 150 -> 0.75
+ else -> 0.4
+ }
+
+ private fun pointOfSailName(twa: Double): String = when {
+ twa < 45 -> "Close Hauled"
+ twa < 70 -> "Close Reach"
+ twa < 115 -> "Beam Reach"
+ twa < 150 -> "Broad Reach"
+ else -> "Run"
+ }
+
+ // ── Sail plan ─────────────────────────────────────────────────────────────
+
+ private fun suggestSailPlan(
+ windKt: Double,
+ forecastItems: List<ForecastItem>,
+ boatProfile: BoatProfile
+ ): List<SailSuggestion> {
+ val suggestions = mutableListOf<SailSuggestion>()
+
+ val headsail = boatProfile.headsails
+ .sortedBy { it.maxWindKt }
+ .firstOrNull { windKt <= it.maxWindKt }
+ ?: boatProfile.headsails.minByOrNull { it.maxWindKt }
+
+ if (headsail != null) {
+ val note = headsailNote(headsail, windKt, forecastItems, boatProfile)
+ suggestions.add(SailSuggestion(headsail.name, note))
+ }
+
+ val mainAction = when {
+ windKt > 21 && boatProfile.mainReefs >= 2 -> "2nd Reef"
+ windKt > 15 && boatProfile.mainReefs >= 1 -> "1st Reef"
+ windKt > 22 && boatProfile.mainReefs == 1 -> "Full Reef"
+ else -> "Full Main"
+ }
+ suggestions.add(SailSuggestion("Main", mainAction))
+
+ val maxForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
+ if (maxForecastWind > windKt + 4) {
+ val nextSail = boatProfile.headsails
+ .sortedBy { it.maxWindKt }
+ .firstOrNull { maxForecastWind <= it.maxWindKt && it != headsail }
+ if (nextSail != null) {
+ suggestions.add(SailSuggestion(
+ "On deck",
+ "${nextSail.name} — winds forecast to reach %.0f kt".format(maxForecastWind)
+ ))
+ }
+ }
+
+ return suggestions
+ }
+
+ private fun headsailNote(
+ sail: SailConfig,
+ windKt: Double,
+ forecastItems: List<ForecastItem>,
+ profile: BoatProfile
+ ): String {
+ val maxForecast = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
+
+ if (sail.name.contains("155%") && windKt > 10) {
+ return if (maxForecast > sail.maxWindKt)
+ "Flying now, but winds forecast to ${maxForecast.toInt()} kt — swap to 100% before departure"
+ else
+ "Good in current conditions; 155% sheets outside shrouds, plan tacks early"
+ }
+
+ return when {
+ windKt <= sail.maxWindKt * 0.6 -> "Light for this sail — ${sail.name} is correct choice"
+ windKt <= sail.maxWindKt -> "In the sweet spot for ${sail.name}"
+ else -> "Approaching upper limit — consider smaller headsail"
+ }
+ }
+
+ // ── Watch list ────────────────────────────────────────────────────────────
+
+ private fun buildWatchList(
+ conditions: MarineConditions?,
+ forecastItems: List<ForecastItem>,
+ boatProfile: BoatProfile,
+ departureDateTimeMs: Long = System.currentTimeMillis()
+ ): List<WatchItem> {
+ val items = mutableListOf<WatchItem>()
+ val windKt = forecastItems.firstOrNull()?.windKt ?: 0.0
+
+ conditions?.swellHeightM?.let { swell ->
+ if (swell > 2.0)
+ items += WatchItem("⚠️", "Significant swell %.1fm — expect motion".format(swell))
+ }
+ conditions?.swellPeriodS?.let { period ->
+ if (period < 8.0)
+ items += WatchItem("⚠️", "Short swell period %.0fs — choppy, uncomfortable".format(period))
+ }
+
+ val peakForecastWind = forecastItems.take(4).maxOfOrNull { it.windKt } ?: windKt
+ val peakHour = forecastItems.take(4).indexOfFirst { it.windKt == peakForecastWind }
+ if (peakForecastWind > windKt + 5) {
+ items += WatchItem("⚠️",
+ "Wind building to ${peakForecastWind.toInt()} kt in ~${peakHour} h — plan to be back before then")
+ }
+
+ val maxPrecip = forecastItems.take(4).maxOfOrNull { it.precipProbabilityPct } ?: 0
+ if (maxPrecip > 30)
+ items += WatchItem("⚠️", "Rain likely ($maxPrecip% chance) — visibility may reduce")
+
+ // 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 (departureHour >= 15)
+ items += WatchItem("ℹ️", "Afternoon departure — trades may be at their strongest; build extra time margin")
+
+ 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")
+ }
+
+ if (windKt > 30)
+ items += WatchItem("⚠️", "Winds ${windKt.toInt()} kt — consider staying in port")
+ else if (windKt > 22)
+ items += WatchItem("⚠️", "Strong winds — reef before casting off")
+
+ return items
+ }
+
+ // ── Similar trips ─────────────────────────────────────────────────────────
+
+ private fun findSimilarTrips(
+ pastTracks: List<List<TrackPoint>>,
+ windKt: Double,
+ windDirDeg: Double
+ ): SimilarTripSummary? {
+ if (pastTracks.isEmpty()) return null
+
+ val matched = pastTracks.filter { points ->
+ if (points.size < 5) return@filter false
+ val winds = points.mapNotNull { it.windSpeedKnots }
+ if (winds.isEmpty()) return@filter false
+ val avgWind = winds.average()
+ val avgDir = points.mapNotNull { it.windAngleDeg }.average().takeIf { !it.isNaN() } ?: return@filter false
+ abs(avgWind - windKt) <= 5.0 && angleDiff(avgDir, windDirDeg) <= 30.0
+ }
+
+ if (matched.isEmpty()) return null
+
+ val summaries = matched.map { summarise(it) }
+ val condLo = (windKt - 5).coerceAtLeast(0.0).toInt()
+ val condHi = (windKt + 5).toInt()
+ val dirLabel = cardinalDir(windDirDeg)
+
+ return SimilarTripSummary(
+ count = matched.size,
+ avgDistanceNm = summaries.map { it.distanceNm }.average(),
+ avgDurationHrs = summaries.map { it.durationMs / 3_600_000.0 }.average(),
+ avgMaxSogKt = summaries.map { it.maxSogKt }.average(),
+ conditionDescription = "$condLo–$condHi kt $dirLabel"
+ )
+ }
+
+ private fun angleDiff(a: Double, b: Double): Double {
+ var d = abs(a - b) % 360.0
+ if (d > 180.0) d = 360.0 - d
+ return d
+ }
+
+ private fun cardinalDir(deg: Double): String {
+ val dirs = listOf("N","NNE","NE","ENE","E","ESE","SE","SSE",
+ "S","SSW","SW","WSW","W","WNW","NW","NNW")
+ return dirs[((deg + 11.25) / 22.5).toInt() % 16]
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
new file mode 100644
index 0000000..64a739a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportViewModel.kt
@@ -0,0 +1,85 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.LocationService
+import org.terst.nav.NavApplication
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+
+sealed class PreTripState {
+ object Idle : PreTripState()
+ object Loading : PreTripState()
+ data class Success(val report: PreTripReport) : PreTripState()
+ data class Error(val message: String) : PreTripState()
+}
+
+class PreTripReportViewModel(
+ private val boatRepo: BoatProfileRepository,
+ private val generator: PreTripReportGenerator = PreTripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<PreTripState>(PreTripState.Idle)
+ val state: StateFlow<PreTripState> = _state.asStateFlow()
+
+ private val _profiles = MutableStateFlow<List<BoatProfile>>(emptyList())
+ val profiles: StateFlow<List<BoatProfile>> = _profiles.asStateFlow()
+
+ private val _selectedProfile = MutableStateFlow<BoatProfile?>(null)
+ val selectedProfile: StateFlow<BoatProfile?> = _selectedProfile.asStateFlow()
+
+ private val _departureMs = MutableStateFlow(System.currentTimeMillis())
+ val departureMs: StateFlow<Long> = _departureMs.asStateFlow()
+
+ init {
+ val profiles = boatRepo.loadProfiles()
+ _profiles.value = profiles
+ _selectedProfile.value = boatRepo.selectedProfile() ?: profiles.firstOrNull()
+ }
+
+ fun selectProfile(id: String) {
+ boatRepo.setSelectedId(id)
+ _selectedProfile.value = _profiles.value.firstOrNull { it.id == id }
+ }
+
+ fun setDeparture(ms: Long) {
+ _departureMs.value = ms
+ }
+
+ fun generate(forecastItems: List<ForecastItem>, conditions: MarineConditions?) {
+ val profile = _selectedProfile.value ?: return
+ val pos = LocationService.bestPosition.value
+
+ viewModelScope.launch {
+ _state.value = PreTripState.Loading
+ 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,
+ departureDateTimeMs = _departureMs.value
+ )
+ _state.value = PreTripState.Success(report)
+ } catch (e: Exception) {
+ _state.value = PreTripState.Error(e.message ?: "Failed to generate report")
+ }
+ }
+ }
+
+ // ── Factory ───────────────────────────────────────────────────────────────
+
+ class Factory(private val boatRepo: BoatProfileRepository) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun <T : ViewModel> create(modelClass: Class<T>): T =
+ PreTripReportViewModel(boatRepo) as T
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
new file mode 100644
index 0000000..e7a425f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -0,0 +1,86 @@
+package org.terst.nav.tripreport
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.chip.ChipGroup
+import kotlinx.coroutines.launch
+import org.terst.nav.NavApplication
+import org.terst.nav.R
+
+class TripReportFragment : Fragment() {
+
+ private val viewModel by lazy {
+ TripReportViewModel(
+ trackRepository = NavApplication.trackRepository,
+ logbookRepository = NavApplication.logbookRepository
+ )
+ }
+
+ private lateinit var tvNarrativeContent: TextView
+ private lateinit var btnRefresh: MaterialButton
+ private lateinit var chipGroup: ChipGroup
+ private lateinit var progress: ProgressBar
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_trip_report, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvNarrativeContent = view.findViewById(R.id.tv_narrative_content)
+ btnRefresh = view.findViewById(R.id.btn_refresh_report)
+ chipGroup = view.findViewById(R.id.chip_group_styles)
+ progress = view.findViewById(R.id.progress_report)
+
+ btnRefresh.setOnClickListener { viewModel.generateReport() }
+
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val style = when (checkedIds.firstOrNull()) {
+ R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS
+ R.id.chip_journal -> NarrativeStyle.JOURNAL
+ R.id.chip_pirate -> NarrativeStyle.PIRATE
+ else -> NarrativeStyle.PROFESSIONAL
+ }
+ viewModel.setStyle(style)
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.state.collect { state -> renderState(state) }
+ }
+
+ // Initial generation
+ viewModel.generateReport()
+ }
+
+ private fun renderState(state: TripReportState) {
+ when (state) {
+ is TripReportState.Idle -> {
+ progress.visibility = View.GONE
+ }
+ is TripReportState.Loading -> {
+ progress.visibility = View.VISIBLE
+ btnRefresh.isEnabled = false
+ }
+ is TripReportState.Success -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = state.narrative
+ }
+ is TripReportState.Error -> {
+ progress.visibility = View.GONE
+ btnRefresh.isEnabled = true
+ tvNarrativeContent.text = "Error: ${state.message}"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
new file mode 100644
index 0000000..bbf00b1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -0,0 +1,117 @@
+package org.terst.nav.tripreport
+
+import org.terst.nav.logbook.LogEntry
+import org.terst.nav.track.TrackPoint
+
+enum class NarrativeStyle {
+ PROFESSIONAL,
+ ADVENTUROUS,
+ JOURNAL,
+ PIRATE
+}
+
+data class TripSummary(
+ val startTimeMs: Long,
+ val endTimeMs: Long,
+ val distanceNm: Double,
+ val maxSogKts: Double,
+ val avgSogKts: Double,
+ val minAirTempC: Double?,
+ val maxAirTempC: Double?,
+ val maxWaveHeightM: Double?,
+ val logEntries: List<LogEntry>,
+ val points: List<TrackPoint>
+)
+
+class TripReportGenerator {
+
+ fun generateSummary(points: List<TrackPoint>, logEntries: List<LogEntry>): TripSummary {
+ if (points.isEmpty()) {
+ return TripSummary(0, 0, 0.0, 0.0, 0.0, null, null, null, logEntries, points)
+ }
+
+ val startTime = points.first().timestampMs
+ val endTime = points.last().timestampMs
+
+ var totalDist = 0.0
+ for (i in 0 until points.size - 1) {
+ totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon)
+ }
+ val distanceNm = totalDist / 1852.0 // meters to nautical miles
+
+ val maxSog = points.maxOf { it.sogKnots }
+ val avgSog = points.map { it.sogKnots }.average()
+
+ val airTemps = points.mapNotNull { it.airTempC }
+ val minTemp = airTemps.minOrNull()
+ val maxTemp = airTemps.maxOrNull()
+ val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull()
+
+ return TripSummary(
+ startTimeMs = startTime,
+ endTimeMs = endTime,
+ distanceNm = distanceNm,
+ maxSogKts = maxSog,
+ avgSogKts = avgSog,
+ minAirTempC = minTemp,
+ maxAirTempC = maxTemp,
+ maxWaveHeightM = maxWave,
+ logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
+ points = points
+ )
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 6371e3 // Earth radius in meters
+ val phi1 = Math.toRadians(lat1)
+ val phi2 = Math.toRadians(lat2)
+ val deltaPhi = Math.toRadians(lat2 - lat1)
+ val deltaLambda = Math.toRadians(lon2 - lon1)
+
+ val a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
+ Math.cos(phi1) * Math.cos(phi2) *
+ Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
+ val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return r * c
+ }
+
+ fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String {
+ val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0
+ val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " +
+ "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) +
+ (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") +
+ "Events: ${summary.logEntries.joinToString { it.text }}"
+
+ return when (style) {
+ NarrativeStyle.PROFESSIONAL -> {
+ "VOYAGE SUMMARY\n" +
+ "Duration: %.1f hours\n".format(durationHrs) +
+ "Total Distance: %.1f NM\n".format(summary.distanceNm) +
+ "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) +
+ "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" +
+ "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" }
+ }
+ NarrativeStyle.ADVENTUROUS -> {
+ "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) +
+ "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) +
+ (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" +
+ "During our journey, we logged some great moments:\n" +
+ summary.logEntries.joinToString("\n") { "🔥 ${it.text}" }
+ }
+ NarrativeStyle.JOURNAL -> {
+ "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) +
+ "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) +
+ "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " +
+ "It was a meaningful passage."
+ }
+ NarrativeStyle.PIRATE -> {
+ "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) +
+ "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) +
+ "Listen to the tales from the log:\n" +
+ summary.logEntries.joinToString("\n") { "🏴‍☠️ ${it.text}" } + "\n" +
+ "Arr, it was a fine voyage indeed!"
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
new file mode 100644
index 0000000..e474cd2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.tripreport
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.track.TrackRepository
+
+sealed class TripReportState {
+ object Idle : TripReportState()
+ object Loading : TripReportState()
+ data class Success(val summary: TripSummary, val narrative: String) : TripReportState()
+ data class Error(val message: String) : TripReportState()
+}
+
+class TripReportViewModel(
+ private val trackRepository: TrackRepository,
+ private val logbookRepository: InMemoryLogbookRepository,
+ private val generator: TripReportGenerator = TripReportGenerator()
+) : ViewModel() {
+
+ private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle)
+ val state: StateFlow<TripReportState> = _state.asStateFlow()
+
+ private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL)
+ val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow()
+
+ fun setStyle(style: NarrativeStyle) {
+ _selectedStyle.value = style
+ generateReport()
+ }
+
+ fun generateReport() {
+ viewModelScope.launch {
+ _state.value = TripReportState.Loading
+ try {
+ val points = trackRepository.getPoints()
+ if (points.isEmpty()) {
+ _state.value = TripReportState.Error("No track data available. Start recording a track first.")
+ return@launch
+ }
+ val logEntries = logbookRepository.getAll()
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ _state.value = TripReportState.Success(summary, narrative)
+ } catch (e: Exception) {
+ _state.value = TripReportState.Error(e.message ?: "Unknown error generating report")
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
deleted file mode 100644
index d55de90..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.terst.nav.ui
-
-import android.content.Context
-import android.content.Intent
-import android.view.View
-import android.widget.Button
-import android.widget.TextView
-import android.widget.Toast
-import androidx.constraintlayout.widget.ConstraintLayout
-import org.terst.nav.AnchorWatchState
-import org.terst.nav.LocationService
-import java.util.Locale
-
-/**
- * Handles the Anchor Watch UI interactions and state updates.
- */
-class AnchorWatchHandler(
- private val context: Context,
- private val container: ConstraintLayout,
- private val statusText: TextView,
- private val radiusText: TextView,
- private val buttonDecrease: Button,
- private val buttonIncrease: Button,
- private val buttonSet: Button,
- private val buttonStop: Button
-) {
- private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
-
- init {
- updateRadiusDisplay()
-
- buttonDecrease.setOnClickListener {
- updateRadius((currentRadius - 5).coerceAtLeast(10.0))
- }
-
- buttonIncrease.setOnClickListener {
- updateRadius((currentRadius + 5).coerceAtMost(200.0))
- }
-
- buttonSet.setOnClickListener {
- startWatch()
- }
-
- buttonStop.setOnClickListener {
- stopWatch()
- }
- }
-
- private fun updateRadius(newRadius: Double) {
- currentRadius = newRadius
- updateRadiusDisplay()
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- }
-
- private fun updateRadiusDisplay() {
- radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius)
- }
-
- private fun startWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_START_ANCHOR_WATCH
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
-
- private fun stopWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_ANCHOR_WATCH
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
- }
-
- /**
- * Updates the UI based on the current anchor watch state.
- */
- fun updateUI(state: AnchorWatchState) {
- statusText.text = if (state.isActive) {
- "STATUS: ACTIVE" // Simple status for UI
- } else {
- "STATUS: INACTIVE"
- }
- currentRadius = state.watchCircleRadiusMeters
- updateRadiusDisplay()
- }
-
- /**
- * Toggles the visibility of the anchor configuration container.
- */
- fun toggleVisibility() {
- container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE
- }
-}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt
new file mode 100644
index 0000000..99e6d5f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt
@@ -0,0 +1,58 @@
+package org.terst.nav.ui
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ScrollView
+import android.widget.TextView
+import android.widget.Toast
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.button.MaterialButton
+import org.terst.nav.R
+
+class DevLogSheet : BottomSheetDialogFragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = inflater.inflate(R.layout.sheet_dev_log, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // Expand to full height
+ (dialog as? BottomSheetDialog)?.behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ skipCollapsed = true
+ }
+
+ val logText = view.findViewById<TextView>(R.id.log_text)
+ val scrollView = view.findViewById<ScrollView>(R.id.scroll_view)
+
+ fun refresh() {
+ val text = NavLogger.getLog()
+ logText.text = text.ifEmpty { "(no log entries yet)" }
+ scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) }
+ }
+
+ refresh()
+
+ view.findViewById<MaterialButton>(R.id.btn_copy).setOnClickListener {
+ val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cm.setPrimaryClip(ClipData.newPlainText("nav_dev_log", NavLogger.getLog()))
+ Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show()
+ }
+
+ view.findViewById<MaterialButton>(R.id.btn_clear).setOnClickListener {
+ NavLogger.clear()
+ refresh()
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
new file mode 100644
index 0000000..fa68b63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.View
+
+/** Normalises a bearing in degrees to [0, 360). */
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
+
+/**
+ * Small circular direction indicator — notched chevron pointing in [bearing] degrees
+ * (0 = north/up, clockwise). Two palettes: SKY (grey) and OCEAN (blue).
+ */
+class DirectionArrowView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ enum class ArrowStyle { SKY, OCEAN }
+
+ var bearing: Float = 0f
+ set(value) { field = normalizeBearing(value); invalidate() }
+
+ var arrowStyle: ArrowStyle = ArrowStyle.SKY
+ set(value) {
+ field = value
+ circlePaint.color = circleColor()
+ arrowPaint.color = arrowColor()
+ invalidate()
+ }
+
+ private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE; strokeWidth = 1.5f; color = circleColor()
+ }
+ private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL; color = arrowColor()
+ }
+ private val arrowPath = Path()
+
+ private fun circleColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#3A3640")
+ ArrowStyle.OCEAN -> Color.parseColor("#1E4A6E")
+ }
+ private fun arrowColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#9A94A0")
+ ArrowStyle.OCEAN -> Color.parseColor("#6FC3E8")
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f; val cy = height / 2f
+ val r = (minOf(width, height) / 2f) - circlePaint.strokeWidth
+ canvas.drawCircle(cx, cy, r, circlePaint)
+ val tipY = cy - r * 0.72f; val baseY = cy + r * 0.50f
+ val notchY = cy + r * 0.22f; val halfW = r * 0.42f
+ arrowPath.reset()
+ arrowPath.moveTo(cx, tipY)
+ arrowPath.lineTo(cx - halfW, baseY)
+ arrowPath.lineTo(cx, notchY)
+ arrowPath.lineTo(cx + halfW, baseY)
+ arrowPath.close()
+ canvas.save(); canvas.rotate(bearing, cx, cy)
+ canvas.drawPath(arrowPath, arrowPaint); canvas.restore()
+ }
+}
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 2f72153..ccb3a91 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
@@ -1,70 +1,138 @@
package org.terst.nav.ui
import android.widget.TextView
-import org.terst.nav.BarometerReading
-import org.terst.nav.BarometerTrendView
-import org.terst.nav.PolarDiagramView
-import org.terst.nav.R
+import org.terst.nav.UnitPrefs
import java.util.Locale
+// ── Pure formatting helpers (top-level for testability) ──────────────────────
+
+/** Formats a bearing to zero decimal places with a degree symbol. */
+fun formatBearing(deg: Double, locale: Locale = Locale.getDefault()): String =
+ "%.0f°".format(locale, deg)
+
+/** Formats a swell period with leading dot separator. */
+fun formatPeriod(sec: Double, locale: Locale = Locale.getDefault()): String =
+ "· %.0fs".format(locale, sec)
+
+// ── InstrumentHandler ────────────────────────────────────────────────────────
+
/**
- * Handles the display of instrument data in the UI.
+ * Drives the area-conditions bottom sheet: wind header row, wave view,
+ * and the forecast section (current / waves / swell).
+ *
+ * All boat-instrument data (SOG, COG, BSP, Depth) is handled directly
+ * in MainActivity via the HUD strip.
+ *
+ * All numeric values are accepted in SI / nautical base units:
+ * speeds in knots, heights in metres, pressure in hPa, temperature in °C.
+ * [unitPrefs] is consulted on each update to produce the correct formatted string.
*/
class InstrumentHandler(
- 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 valueVmg: TextView,
- private val valueDepth: TextView,
- private val valuePolarPct: TextView,
- private val valueBaro: TextView,
- private val labelTrend: TextView?,
- private val barometerTrendView: BarometerTrendView?,
- private val polarDiagramView: PolarDiagramView
+ // ── 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 ─────────────────────────────
+ private val arrowCurr: DirectionArrowView,
+ private val arrowWaves: DirectionArrowView,
+ private val arrowSwell: DirectionArrowView,
+ // ── Forecast section bearing TextViews ───────────────────────────────
+ private val bearingCurr: TextView,
+ private val bearingWaves: TextView,
+ private val bearingSwell: TextView,
+ // ── Wave view ────────────────────────────────────────────────────────
+ private val waveView: WaveView,
+ // ── Unit preferences ─────────────────────────────────────────────────
+ private val unitPrefs: UnitPrefs
) {
- /**
- * Updates the text displays for various instruments.
- */
- fun updateDisplay(
- aws: String? = null,
- tws: String? = null,
- hdg: String? = null,
- cog: String? = null,
- bsp: String? = null,
- sog: String? = null,
- vmg: String? = null,
- depth: String? = null,
- polarPct: String? = null,
- baro: String? = null,
- trend: 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 }
- vmg?.let { valueVmg.text = it }
- depth?.let { valueDepth.text = it }
- polarPct?.let { valuePolarPct.text = it }
- baro?.let { valueBaro.text = it }
- trend?.let { labelTrend?.text = it }
+ init {
+ arrowCurr.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowWaves.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ arrowSwell.arrowStyle = DirectionArrowView.ArrowStyle.OCEAN
+ valueTws.text = "—"
+ valueTemp.text = "—"
+ valueBaro.text = "—"
+ valueCurrSpd.text = "—"
+ valueWaveHt.text = "—"
+ valueSwellHt.text = "—"
}
/**
- * Updates the polar diagram view.
+ * Updates all area-conditions fields. Null arguments leave the current value unchanged.
+ *
+ * @param twsKt True wind speed in knots
+ * @param tempC Air temperature in degrees Celsius
+ * @param baroHpa Barometric pressure in hPa
+ * @param currSpdKt Ocean current speed in knots
+ * @param waveHeightM Wind-wave height in metres
+ * @param swellHeightM Swell height in metres
*/
- fun updatePolarDiagram(tws: Double, twa: Double, bsp: Double) {
- polarDiagramView.setCurrentPerformance(tws, twa, bsp)
+ fun updateConditions(
+ twsKt: Double? = null, twsBearingDeg: Float? = null,
+ tempC: Double? = null,
+ baroHpa: Float? = null,
+ currSpdKt: Double? = null, currDirDeg: Float? = null,
+ waveHeightM: Double? = null, waveDirDeg: Float? = null,
+ swellHeightM: Double? = null, swellDirDeg: Float? = null,
+ swellPeriodS: Double? = null
+ ) {
+ twsKt?.let { valueTws.text = unitPrefs.formatSpeed(it) }
+ tempC?.let { valueTemp.text = unitPrefs.formatTemp(it) }
+ baroHpa?.let { valueBaro.text = unitPrefs.formatPressure(it) }
+ twsBearingDeg?.let {
+ arrowTws.bearing = it
+ bearingTws.text = formatBearing(it.toDouble())
+ }
+
+ currSpdKt?.let { valueCurrSpd.text = unitPrefs.formatSpeed(it) }
+ currDirDeg?.let {
+ arrowCurr.bearing = it
+ bearingCurr.text = formatBearing(it.toDouble())
+ }
+ waveHeightM?.let { valueWaveHt.text = unitPrefs.formatDepth(it) }
+ waveDirDeg?.let {
+ arrowWaves.bearing = it
+ bearingWaves.text = formatBearing(it.toDouble())
+ }
+ swellHeightM?.let { valueSwellHt.text = unitPrefs.formatDepth(it) }
+ swellDirDeg?.let {
+ arrowSwell.bearing = it
+ bearingSwell.text = formatBearing(it.toDouble())
+ }
+ swellPeriodS?.let { valueSwellPer.text = formatPeriod(it) }
}
/**
- * Updates the barometer trend chart.
+ * Updates the WaveView with current sea state. Call once when conditions load.
+ * Heights are passed in metres; the WaveView scale is computed internally.
+ * [windSpeedKt] gates whitecap rendering (Beaufort 4 threshold = 12 kt).
*/
- fun updateBarometerTrend(history: List<BarometerReading>) {
- barometerTrendView?.setHistory(history)
+ fun updateWaveState(
+ swellHeightM: Float,
+ swellPeriodSec: Float,
+ windWaveHeightM: Float,
+ windSpeedKt: Float = 0f
+ ) {
+ val swellHtFt = swellHeightM * 3.28084f
+ val windWaveHtFt = windWaveHeightM * 3.28084f
+
+ waveView.swellHeightFt = swellHtFt
+ waveView.swellPeriodSec = swellPeriodSec
+ waveView.windWaveHeightFt = windWaveHtFt
+ waveView.windSpeedKt = windSpeedKt
+
+ // Size the view to the swell — bigger swell = taller window
+ val density = waveView.resources.displayMetrics.density
+ val heightDp = (32f + swellHtFt * 16f).coerceIn(56f, 160f)
+ val lp = waveView.layoutParams
+ lp.height = (heightDp * density).toInt()
+ waveView.layoutParams = lp
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
new file mode 100644
index 0000000..78fd70f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
@@ -0,0 +1,119 @@
+package org.terst.nav.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.switchmaterial.SwitchMaterial
+import org.terst.nav.DepthUnit
+import org.terst.nav.PressureUnit
+import org.terst.nav.R
+import org.terst.nav.SpeedUnit
+import org.terst.nav.TempUnit
+import org.terst.nav.UnitPrefs
+
+class LayerPickerSheet(
+ private val manager: MapLayerManager,
+ private val onBaseChanged: (MapBasePreset) -> Unit,
+ private val onWindChanged: (Boolean) -> Unit,
+ private val onParticlesChanged: (Boolean) -> Unit,
+ private val unitPrefs: UnitPrefs,
+ private val onUnitsChanged: () -> Unit
+) : BottomSheetDialogFragment() {
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
+ inflater.inflate(R.layout.layout_layer_picker_sheet, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ // ── Base layer chips ──────────────────────────────────────────────────
+ val chipGroup = view.findViewById<ChipGroup>(R.id.chip_group_base)
+ chipGroup.check(when (manager.basePreset) {
+ MapBasePreset.SATELLITE -> R.id.chip_satellite
+ MapBasePreset.CHARTS -> R.id.chip_charts
+ MapBasePreset.HYBRID -> R.id.chip_hybrid
+ })
+ chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ val preset = when (checkedIds.firstOrNull()) {
+ R.id.chip_satellite -> MapBasePreset.SATELLITE
+ R.id.chip_charts -> MapBasePreset.CHARTS
+ R.id.chip_hybrid -> MapBasePreset.HYBRID
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onBaseChanged(preset)
+ }
+
+ // ── Wind overlay switch ───────────────────────────────────────────────
+ val windSwitch = view.findViewById<SwitchMaterial>(R.id.switch_wind)
+ windSwitch.isChecked = manager.windEnabled
+ windSwitch.setOnCheckedChangeListener { _, checked -> onWindChanged(checked) }
+
+ // ── Particles switch ──────────────────────────────────────────────────
+ val particlesSwitch = view.findViewById<SwitchMaterial>(R.id.switch_particles)
+ particlesSwitch.isChecked = manager.particlesEnabled
+ particlesSwitch.setOnCheckedChangeListener { _, checked -> onParticlesChanged(checked) }
+
+ // ── Temperature chips ─────────────────────────────────────────────────
+ val chipTemp = view.findViewById<ChipGroup>(R.id.chip_group_temp)
+ chipTemp.check(when (unitPrefs.tempUnit) {
+ TempUnit.FAHRENHEIT -> R.id.chip_fahrenheit
+ TempUnit.CELSIUS -> R.id.chip_celsius
+ })
+ chipTemp.setOnCheckedStateChangeListener { _, ids ->
+ unitPrefs.tempUnit = when (ids.firstOrNull()) {
+ R.id.chip_fahrenheit -> TempUnit.FAHRENHEIT
+ R.id.chip_celsius -> TempUnit.CELSIUS
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onUnitsChanged()
+ }
+
+ // ── Depth chips ───────────────────────────────────────────────────────
+ val chipDepth = view.findViewById<ChipGroup>(R.id.chip_group_depth)
+ chipDepth.check(when (unitPrefs.depthUnit) {
+ DepthUnit.FEET -> R.id.chip_feet
+ DepthUnit.METERS -> R.id.chip_meters
+ })
+ chipDepth.setOnCheckedStateChangeListener { _, ids ->
+ unitPrefs.depthUnit = when (ids.firstOrNull()) {
+ R.id.chip_feet -> DepthUnit.FEET
+ R.id.chip_meters -> DepthUnit.METERS
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onUnitsChanged()
+ }
+
+ // ── Pressure chips ────────────────────────────────────────────────────
+ val chipPressure = view.findViewById<ChipGroup>(R.id.chip_group_pressure)
+ chipPressure.check(when (unitPrefs.pressureUnit) {
+ PressureUnit.HPA -> R.id.chip_hpa
+ PressureUnit.INHG -> R.id.chip_inhg
+ })
+ chipPressure.setOnCheckedStateChangeListener { _, ids ->
+ unitPrefs.pressureUnit = when (ids.firstOrNull()) {
+ R.id.chip_hpa -> PressureUnit.HPA
+ R.id.chip_inhg -> PressureUnit.INHG
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onUnitsChanged()
+ }
+
+ // ── Speed chips ───────────────────────────────────────────────────────
+ val chipSpeed = view.findViewById<ChipGroup>(R.id.chip_group_speed)
+ chipSpeed.check(when (unitPrefs.speedUnit) {
+ SpeedUnit.KNOTS -> R.id.chip_knots
+ SpeedUnit.MPH -> R.id.chip_mph
+ SpeedUnit.KPH -> R.id.chip_kph
+ })
+ chipSpeed.setOnCheckedStateChangeListener { _, ids ->
+ unitPrefs.speedUnit = when (ids.firstOrNull()) {
+ R.id.chip_knots -> SpeedUnit.KNOTS
+ R.id.chip_mph -> SpeedUnit.MPH
+ R.id.chip_kph -> SpeedUnit.KPH
+ else -> return@setOnCheckedStateChangeListener
+ }
+ onUnitsChanged()
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 8e84e1e..f05da21 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -5,19 +5,41 @@ import androidx.lifecycle.viewModelScope
import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
+import org.terst.nav.ais.CpaAlert
+import org.terst.nav.ais.CpaCalculator
+import org.terst.nav.ais.CpaSeverity
+import org.terst.nav.ais.CpaThresholds
import org.terst.nav.data.api.AisHubApiService
+import org.terst.nav.thermal.ThermalReading
+import org.terst.nav.thermal.ThermalState
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.TrackRepository
+import org.terst.nav.track.TrackStats
+import org.terst.nav.settings.FeatureFlags
+import org.terst.nav.settings.HardwareSource
+import org.terst.nav.vessel.CrewMember
+import org.terst.nav.vessel.Vessel
+import org.terst.nav.vessel.VesselRepository
import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import org.terst.nav.track.TrackSummary
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
+import kotlin.math.cos
+import kotlin.math.sin
sealed class UiState {
object Loading : UiState()
@@ -26,7 +48,8 @@ sealed class UiState {
}
class MainViewModel(
- private val repository: WeatherRepository = WeatherRepository.create()
+ private val repository: WeatherRepository = WeatherRepository.create(),
+ var featureFlags: FeatureFlags? = null
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
@@ -35,14 +58,169 @@ class MainViewModel(
private val _windArrow = MutableStateFlow<WindArrow?>(null)
val windArrow: StateFlow<WindArrow?> = _windArrow
+ private val _windGrid = MutableStateFlow<List<WindArrow>>(emptyList())
+ val windGrid: StateFlow<List<WindArrow>> = _windGrid.asStateFlow()
+
private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
val forecast: StateFlow<List<ForecastItem>> = _forecast
+ private val _marineConditions = MutableStateFlow<MarineConditions?>(null)
+ val marineConditions: StateFlow<MarineConditions?> = _marineConditions.asStateFlow()
+
+ private val _conditionsLoading = MutableStateFlow(false)
+ val conditionsLoading: StateFlow<Boolean> = _conditionsLoading.asStateFlow()
+
+ private val _conditionsError = MutableSharedFlow<String>(replay = 0)
+ val conditionsError: SharedFlow<String> = _conditionsError.asSharedFlow()
+
+ private var conditionsJob: Job? = null
+
private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
+ private val _cpaAlerts = MutableStateFlow<List<CpaAlert>>(emptyList())
+ val cpaAlerts: StateFlow<List<CpaAlert>> = _cpaAlerts.asStateFlow()
+
+ private var lastLat: Double = 0.0
+ private var lastLon: Double = 0.0
+ private var lastSog: Double = 0.0
+ private var lastCog: Double = 0.0
+
private val aisRepository = AisRepository()
+ private val trackRepository = org.terst.nav.NavApplication.trackRepository
+
+ val vesselRepository = VesselRepository()
+
+ private val _thermalState = MutableStateFlow(ThermalState.OK)
+ val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
+
+ private val _thermalReading = MutableStateFlow<ThermalReading?>(null)
+ val thermalReading: StateFlow<ThermalReading?> = _thermalReading.asStateFlow()
+
+ fun onThermalReading(reading: ThermalReading) {
+ _thermalReading.value = reading
+ _thermalState.value = reading.state
+ }
+
+ private val _isRecording = MutableStateFlow(false)
+ val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
+
+ private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
+ val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+
+ private val _trackStats = MutableStateFlow<TrackStats?>(null)
+ val trackStats: StateFlow<TrackStats?> = _trackStats.asStateFlow()
+
+ private val _pastTracks = MutableStateFlow<List<List<TrackPoint>>>(emptyList())
+ val pastTracks: StateFlow<List<List<TrackPoint>>> = _pastTracks.asStateFlow()
+
+ private val _trackSummary = MutableSharedFlow<TrackSummary>(replay = 0)
+ val trackSummary: SharedFlow<TrackSummary> = _trackSummary.asSharedFlow()
+
+ fun startTrack() {
+ trackRepository.startTrack()
+ _trackPoints.value = emptyList()
+ _isRecording.value = true
+ }
+
+ /** Epoch-ms when the current track started — for the summary sheet title. */
+ val trackStartMs: Long get() = trackRepository.trackStartMs
+
+ fun stopTrack() {
+ viewModelScope.launch {
+ val summary = trackRepository.stopTrack()
+ _pastTracks.value = trackRepository.getPastTracks()
+ _trackPoints.value = emptyList()
+ _isRecording.value = false
+ _trackStats.value = null
+ summary?.let { _trackSummary.emit(it) }
+ }
+ }
+
+ /** Dev shortcut: saves a synthetic 30-min Honokohau circuit without requiring GPS movement. */
+ fun injectTestTrack() {
+ viewModelScope.launch {
+ val startMs = System.currentTimeMillis() - 30 * 60_000L
+ val points = buildTestTrack(startMs)
+ trackRepository.startTrack()
+ points.forEach { trackRepository.addPoint(it) }
+ val summary = trackRepository.stopTrack()
+ _pastTracks.value = trackRepository.getPastTracks()
+ _trackPoints.value = emptyList()
+ _isRecording.value = false
+ _trackStats.value = null
+ summary?.let { _trackSummary.emit(it) }
+ }
+ }
+
+ private fun buildTestTrack(startMs: Long): List<TrackPoint> {
+ // Honokohau Harbor, Kona, HI: 19.669°N, 156.027°W
+ // Heads west (~270°) for first half, returns east (~90°) for second half
+ val baseLat = 19.669
+ val baseLon = -156.027
+ val nmPerDegreeLon = 60.0 * kotlin.math.cos(kotlin.math.PI / 180.0 * baseLat)
+ val sogKt = 4.5
+ val count = 16
+ return (0 until count).map { i ->
+ val tMs = startMs + i * 2 * 60_000L
+ val outbound = i < count / 2
+ val legIndex = if (outbound) i else (count - 1 - i)
+ val distNm = sogKt * (legIndex * 2.0 / 60.0)
+ val lonOffset = distNm / nmPerDegreeLon
+ val lat = baseLat + if (!outbound) 0.003 else 0.0 // slight northing on return
+ val lon = baseLon - lonOffset
+ val cogDeg = if (outbound) 270.0 else 90.0
+ val sog = sogKt + (i % 3 - 1) * 0.4 // mild variation
+ TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sog, cogDeg = cogDeg,
+ waveHeightM = 0.6,
+ timestampMs = tMs
+ )
+ }
+ }
+
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg
+ val conditions = _marineConditions.value
+ val forecast = _forecast.value.firstOrNull()
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ airTempC = forecast?.tempC,
+ waveHeightM = conditions?.waveHeightM,
+ currentSpeedKts = conditions?.currentSpeedKt,
+ currentDirDeg = conditions?.currentDirDeg,
+ timestampMs = System.currentTimeMillis()
+ )
+ if (trackRepository.addPoint(point)) {
+ _trackPoints.value = trackRepository.getPoints()
+ _trackStats.value = trackRepository.computeStats()
+ }
+ updateCpaAlerts()
+ }
+
+ private fun updateCpaAlerts() {
+ if (lastSog < 0.1) return // stationary — skip
+ val alerts = aisRepository.getTargets().mapNotNull { target ->
+ if (target.sog < 0.1 && target.cog == 0.0) return@mapNotNull null
+ val (cpa, tcpa) = CpaCalculator.compute(
+ lastLat, lastLon, lastSog, lastCog,
+ target.lat, target.lon, target.sog, target.cog.toDouble()
+ )
+ if (tcpa < 0 || tcpa > CpaThresholds.MAX_TCPA_MIN) return@mapNotNull null
+ val severity = when {
+ cpa <= CpaThresholds.DANGER_CPA_NM -> CpaSeverity.DANGER
+ cpa <= CpaThresholds.WARNING_CPA_NM -> CpaSeverity.WARNING
+ cpa <= CpaThresholds.CAUTION_CPA_NM -> CpaSeverity.CAUTION
+ else -> return@mapNotNull null
+ }
+ CpaAlert(target.mmsi, target.name, cpa, tcpa, severity)
+ }.sortedBy { it.tcpaMin }
+ _cpaAlerts.value = alerts
+ }
+
private val aisHubApi: AisHubApiService by lazy {
Retrofit.Builder()
.baseUrl("https://data.aishub.net")
@@ -55,9 +233,55 @@ class MainViewModel(
.create(AisHubApiService::class.java)
}
+ private var lastConditionsLat = Double.NaN
+ private var lastConditionsLon = Double.NaN
+ private var lastConditionsMs = 0L
+
+ /**
+ * Fetches current conditions snapshot for [lat]/[lon] and exposes it via [marineConditions].
+ * Throttled: skips if conditions loaded < 5 min ago and position < 2 nm from last load.
+ */
+ fun loadConditions(lat: Double, lon: Double) {
+ val nowMs = System.currentTimeMillis()
+ val ageMs = nowMs - lastConditionsMs
+ val distNm = if (lastConditionsLat.isNaN()) Double.MAX_VALUE
+ else distanceNm(lastConditionsLat, lastConditionsLon, lat, lon)
+ if (ageMs < 5 * 60_000L && distNm < 2.0) {
+ NavLogger.i("conditions", "skip age=${ageMs / 1000}s dist=%.1fnm".format(distNm))
+ return
+ }
+ lastConditionsLat = lat
+ lastConditionsLon = lon
+ lastConditionsMs = nowMs
+
+ conditionsJob?.cancel()
+ conditionsJob = viewModelScope.launch {
+ _conditionsLoading.value = true
+ try {
+ repository.fetchCurrentConditions(lat, lon)
+ .onSuccess { _marineConditions.value = it }
+ .onFailure {
+ val msg = "${it.javaClass.simpleName}: ${it.message}"
+ android.util.Log.e("Nav", "fetchCurrentConditions failed: $msg", it)
+ _conditionsError.emit(msg)
+ }
+ } finally {
+ _conditionsLoading.value = false
+ }
+ }
+ }
+
+ private fun distanceNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ return 3440.065 * 2 * kotlin.math.asin(kotlin.math.sqrt(a))
+ }
+
/**
- * Fetch weather and marine data for [lat]/[lon] in parallel.
- * Called once the device location is known.
+ * Fetch weather and wind arrow for [lat]/[lon]. Called once on first GPS fix.
*/
fun loadWeather(lat: Double, lon: Double) {
viewModelScope.launch {
@@ -88,23 +312,64 @@ class MainViewModel(
}
/**
+ * Fetch wind arrows for a 4×5 grid covering the visible map bounds.
+ * Fires-and-forgets; silently ignores network failures (grid is decorative).
+ */
+ fun loadWindGrid(latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
+ viewModelScope.launch {
+ val points = buildGrid(latMin, latMax, lonMin, lonMax, cols = 4, rows = 5)
+ repository.fetchWindGrid(points).getOrNull()?.let { _windGrid.value = it }
+ }
+ }
+
+ private fun buildGrid(
+ latMin: Double, latMax: Double,
+ lonMin: Double, lonMax: Double,
+ cols: Int, rows: Int
+ ): List<Pair<Double, Double>> {
+ val latStep = (latMax - latMin) / (rows - 1).coerceAtLeast(1)
+ val lonStep = (lonMax - lonMin) / (cols - 1).coerceAtLeast(1)
+ return (0 until rows).flatMap { row ->
+ (0 until cols).map { col ->
+ Pair(latMin + row * latStep, lonMin + col * lonStep)
+ }
+ }
+ }
+
+ /**
* Process a single NMEA sentence from the hardware AIS receiver.
- * Call this from MainActivity when bytes arrive from the TCP socket.
*/
fun processAisSentence(sentence: String) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
aisRepository.processSentence(sentence)
aisRepository.evictStale()
_aisTargets.value = aisRepository.getTargets()
+ updateCpaAlerts()
+ }
+
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ when (source) {
+ HardwareSource.AIS_RECEIVER -> {
+ if (!enabled) {
+ aisRepository.clear()
+ _aisTargets.value = emptyList()
+ _cpaAlerts.value = emptyList()
+ }
+ }
+ HardwareSource.NMEA_INSTRUMENTS -> {
+ // NMEA instrument state is ephemeral — clearing handled by InstrumentHandler
+ }
+ }
}
/**
* Refresh AIS targets from AISHub for the given bounding box.
- * When username is empty, skips silently — hardware feed is primary.
*/
fun refreshAisFromInternet(
latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
username: String = "", password: String = ""
) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
if (username.isEmpty()) return
viewModelScope.launch {
try {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 91569cf..a7d746f 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -6,7 +6,9 @@ import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
+import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.layers.SymbolLayer
@@ -15,18 +17,45 @@ import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
-import org.terst.nav.AnchorWatchState
+import org.terst.nav.safety.AnchorWatchState
import org.terst.nav.TidalCurrentState
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
+import kotlin.math.sqrt
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
/**
* Handles MapLibre initialization, layers, and updates.
*/
class MapHandler(private val maplibreMap: MapLibreMap) {
+ init {
+ maplibreMap.addOnCameraMoveStartedListener { reason ->
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
+ _isFollowing.value = false
+ }
+ }
+ maplibreMap.addOnCameraIdleListener {
+ if (!_isFollowing.value) {
+ lastZoom = maplibreMap.cameraPosition.zoom
+ }
+ }
+ }
+
+ private val _isFollowing = MutableStateFlow(true)
+ val isFollowing: StateFlow<Boolean> = _isFollowing.asStateFlow()
+
+ private var lastLat: Double = 0.0
+ private var lastLon: Double = 0.0
+ private var lastZoom: Double = 14.0
+
private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
@@ -37,14 +66,34 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
+ private val USER_POS_SOURCE_ID = "user-pos-source"
+ private val USER_POS_LAYER_ID = "user-pos-layer"
+ private val USER_ICON_ID = "user-icon"
+
+ private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
+ private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
+ private val TRACK_PAST_SOURCE_ID = "track-past-source"
+ private val TRACK_PAST_LAYER_ID = "track-line-past"
+
+ private val WIND_SOURCE_ID = "wind-arrows-source"
+ private val WIND_LAYER_ID = "wind-arrows"
+ private val WIND_ARROW_ICON_ID = "wind-arrow"
+ private val WIND_GRID_SOURCE_ID = "wind-grid-source"
+ private val WIND_GRID_LAYER_ID = "wind-grid-arrows"
+
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
+ private var userPosSource: GeoJsonSource? = null
+ private var trackActiveSource: GeoJsonSource? = null
+ private var trackPastSource: GeoJsonSource? = null
+ private var windSource: GeoJsonSource? = null
+ private var windGridSource: GeoJsonSource? = null
/**
- * Initializes map layers for anchor watch and tidal currents.
+ * Initializes map layers for anchor watch, tidal currents, and user position.
*/
- fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap) {
+ fun setupLayers(style: Style, anchorBitmap: Bitmap, arrowBitmap: Bitmap, userBitmap: Bitmap) {
// Anchor layers
style.addImage(ANCHOR_ICON_ID, anchorBitmap)
anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
@@ -82,17 +131,137 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
PropertyFactory.iconSize(0.8f)
)
})
+
+ // User Position Layer
+ style.addImage(USER_ICON_ID, userBitmap)
+ userPosSource = GeoJsonSource(USER_POS_SOURCE_ID)
+ style.addSource(userPosSource!!)
+ style.addLayer(SymbolLayer(USER_POS_LAYER_ID, USER_POS_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(USER_ICON_ID),
+ PropertyFactory.iconRotate(org.maplibre.android.style.expressions.Expression.get("rotation")),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true),
+ PropertyFactory.iconSize(1.0f)
+ )
+ })
+ }
+
+ /**
+ * Registers the wind-arrow bitmap and creates the single-arrow + grid layers.
+ * Call once after style loads, after setupLayers.
+ */
+ fun setupWindLayer(style: Style, arrowBitmap: Bitmap) {
+ style.addImage(WIND_ARROW_ICON_ID, arrowBitmap)
+
+ windSource = GeoJsonSource(WIND_SOURCE_ID)
+ style.addSource(windSource!!)
+ style.addLayer(SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(WIND_ARROW_ICON_ID),
+ PropertyFactory.iconRotate(Expression.get("direction")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(
+ Expression.interpolate(
+ Expression.linear(),
+ Expression.get("speed_kt"),
+ Expression.stop(0, 0.6f),
+ Expression.stop(30, 1.4f)
+ )
+ )
+ )
+ })
+
+ windGridSource = GeoJsonSource(WIND_GRID_SOURCE_ID)
+ style.addSource(windGridSource!!)
+ style.addLayer(SymbolLayer(WIND_GRID_LAYER_ID, WIND_GRID_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(WIND_ARROW_ICON_ID),
+ PropertyFactory.iconRotate(Expression.get("direction")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(
+ Expression.interpolate(
+ Expression.linear(),
+ Expression.get("speed_kt"),
+ Expression.stop(0, 0.5f),
+ Expression.stop(30, 1.2f)
+ )
+ )
+ )
+ })
+ }
+
+ /** Places or moves the single wind arrow at the GPS position. */
+ fun updateWindLayer(arrow: WindArrow) {
+ val feature = Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
+ addNumberProperty("direction", arrow.directionDeg)
+ addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ windSource?.setGeoJson(FeatureCollection.fromFeature(feature))
+ }
+
+ /** Replaces all wind-grid arrows with the fetched list. */
+ fun updateWindGridLayer(arrows: List<WindArrow>) {
+ val features = arrows.map { arrow ->
+ Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply {
+ addNumberProperty("direction", arrow.directionDeg)
+ addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ }
+ windGridSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ }
+
+ /**
+ * Updates the user's position and orientation on the map.
+ */
+ fun updateUserPosition(lat: Double, lon: Double, headingDeg: Float) {
+ userPosSource?.setGeoJson(Feature.fromGeometry(Point.fromLngLat(lon, lat)).apply {
+ addNumberProperty("rotation", headingDeg)
+ })
}
/**
* Centers the map on the specified location.
*/
fun centerOnLocation(lat: Double, lon: Double, zoom: Double = 14.0) {
+ val prevLat = lastLat
+ val prevLon = lastLon
+ lastLat = lat
+ lastLon = lon
+ if (!_isFollowing.value) return
+
val position = CameraPosition.Builder()
.target(LatLng(lat, lon))
.zoom(zoom)
.build()
- maplibreMap.animateCamera(CameraUpdateFactory.newCameraPosition(position), 1000)
+
+ // Skip animation for tiny GPS jitter (< 3 m) to avoid repeated symbol
+ // collision recalculations that cause labels to flash on/off.
+ val deltaMeters = approximateMeters(prevLat, prevLon, lat, lon)
+ if (deltaMeters < 3.0) {
+ maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(position))
+ } else {
+ maplibreMap.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400)
+ }
+ }
+
+ /** Fast flat-earth distance approximation (accurate to ~0.1% within a few km). */
+ private fun approximateMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = (lat2 - lat1) * 111_320.0
+ val dLon = (lon2 - lon1) * 111_320.0 * cos(Math.toRadians((lat1 + lat2) / 2.0))
+ return sqrt(dLat * dLat + dLon * dLon)
+ }
+
+ /**
+ * Restores auto-follow mode and animates the camera back to the last known GPS position.
+ * No-op if no GPS fix has been received yet.
+ */
+ fun recenter() {
+ if (lastLat == 0.0 && lastLon == 0.0) return
+ _isFollowing.value = true
+ centerOnLocation(lastLat, lastLon, lastZoom)
}
/**
@@ -127,6 +296,52 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
}
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layers on first call.
+ */
+ fun updateTrackLayer(style: Style, activePoints: List<TrackPoint>, pastTracks: List<List<TrackPoint>>) {
+ if (trackActiveSource == null) {
+ trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
+ style.addSource(trackActiveSource!!)
+ style.addLayer(LineLayer(TRACK_ACTIVE_LAYER_ID, TRACK_ACTIVE_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round")
+ )
+ })
+ }
+
+ if (trackPastSource == null) {
+ trackPastSource = GeoJsonSource(TRACK_PAST_SOURCE_ID)
+ style.addSource(trackPastSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_LAYER_ID, TRACK_PAST_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(3f),
+ PropertyFactory.lineDasharray(arrayOf(1f, 2f)),
+ PropertyFactory.lineCap("round")
+ )
+ })
+ }
+
+ if (activePoints.size >= 2) {
+ val coords = activePoints.map { Point.fromLngLat(it.lon, it.lat) }
+ trackActiveSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackActiveSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+
+ if (pastTracks.isNotEmpty()) {
+ val features = pastTracks.map { track ->
+ Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
+ }
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ } else {
+ trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
val points = mutableListOf<Point>()
val degreesBetweenPoints = 8
@@ -138,7 +353,7 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
val lonOffset = radiusMeters / (111320.0 * cos(Math.toRadians(lat))) * sin(Math.toRadians(angle))
points.add(Point.fromLngLat(lon + lonOffset, lat + latOffset))
}
- points.add(points[0]) // Close the polygon
+ points.add(points[0])
return Polygon.fromLngLats(listOf(points))
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
new file mode 100644
index 0000000..855233f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
@@ -0,0 +1,142 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.RasterLayer
+import org.maplibre.android.style.sources.RasterSource
+import org.maplibre.android.style.sources.TileSet
+
+enum class MapBasePreset { SATELLITE, CHARTS, HYBRID }
+
+/**
+ * Manages the raster layer stack: base imagery (satellite / NOAA charts / hybrid)
+ * and the wind overlay. Persists selections across sessions.
+ *
+ * All sources are registered at style-build time; this class only toggles
+ * layer visibility — no runtime source swapping required.
+ */
+class MapLayerManager(context: Context) {
+
+ private val prefs = context.getSharedPreferences("map_layers", Context.MODE_PRIVATE)
+
+ var basePreset: MapBasePreset = loadBasePreset()
+ private set
+
+ var windEnabled: Boolean = prefs.getBoolean(KEY_WIND, true)
+ private set
+
+ var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, true)
+ private set
+
+ // ── Source / Layer IDs ────────────────────────────────────────────────────
+
+ companion object {
+ const val SOURCE_SATELLITE = "satellite-source"
+ const val SOURCE_CHARTS = "charts-source"
+ const val SOURCE_WIND = "wind-source"
+ const val SOURCE_SEAMARKS = "openseamap-source"
+
+ const val LAYER_SATELLITE = "satellite-layer"
+ const val LAYER_CHARTS = "charts-layer"
+ const val LAYER_WIND = "wind-layer"
+ const val LAYER_SEAMARKS = "openseamap-layer"
+
+ private const val KEY_BASE = "base_preset"
+ private const val KEY_WIND = "wind_enabled"
+ private const val KEY_PARTICLES = "particles_enabled"
+
+ // Tile URLs
+ private const val URL_SATELLITE =
+ "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
+ private const val URL_CHARTS =
+ "https://tileservice.charts.noaa.gov/tiles/50000_1/{z}/{x}/{y}.png"
+ private const val URL_WIND =
+ "https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=ae2a038149aa0900d1bc74160aa2a37e"
+ private const val URL_SEAMARKS =
+ "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
+ }
+
+ /**
+ * Registers all raster sources and layers into the [Style.Builder].
+ * Call this before [Style.Builder.build]. Layers are added in the correct
+ * order; MapHandler's vector layers go on top after style loads.
+ */
+ fun addToStyleBuilder(builder: Style.Builder) {
+ // ── Sources ───────────────────────────────────────────────────────────
+ builder.withSource(RasterSource(SOURCE_SATELLITE,
+ TileSet("2.2.0", URL_SATELLITE), 256))
+
+ builder.withSource(RasterSource(SOURCE_CHARTS,
+ TileSet("2.2.0", URL_CHARTS), 256))
+
+ builder.withSource(RasterSource(SOURCE_WIND,
+ TileSet("2.2.0", URL_WIND), 256))
+
+ builder.withSource(RasterSource(SOURCE_SEAMARKS,
+ TileSet("2.2.0", URL_SEAMARKS).also { it.setMaxZoom(18f) }, 256))
+
+ // ── Layers (bottom → top within raster stack) ─────────────────────────
+ builder.withLayer(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply {
+ setProperties(PropertyFactory.rasterOpacity(1f))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply {
+ setProperties(PropertyFactory.rasterOpacity(opacityFor(basePreset)))
+ setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_CHARTS)))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_WIND, SOURCE_WIND).apply {
+ setProperties(PropertyFactory.rasterOpacity(0.85f))
+ setProperties(PropertyFactory.visibility(if (windEnabled) "visible" else "none"))
+ })
+
+ builder.withLayer(RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS).apply {
+ setProperties(PropertyFactory.visibility("visible"))
+ })
+ }
+
+ /** Apply a new base preset to a live style. */
+ fun setBasePreset(style: Style, preset: MapBasePreset) {
+ basePreset = preset
+ prefs.edit().putString(KEY_BASE, preset.name).apply()
+
+ style.getLayer(LAYER_SATELLITE)?.setProperties(
+ PropertyFactory.visibility(visibilityFor(preset, LAYER_SATELLITE)))
+ style.getLayer(LAYER_CHARTS)?.let {
+ it.setProperties(PropertyFactory.visibility(visibilityFor(preset, LAYER_CHARTS)))
+ (it as? RasterLayer)?.setProperties(PropertyFactory.rasterOpacity(opacityFor(preset)))
+ }
+ }
+
+ /** Toggle wind particles (Canvas overlay — no style interaction needed). */
+ fun setParticlesEnabled(enabled: Boolean) {
+ particlesEnabled = enabled
+ prefs.edit().putBoolean(KEY_PARTICLES, enabled).apply()
+ }
+
+ /** Toggle wind overlay on a live style. */
+ fun setWindEnabled(style: Style, enabled: Boolean) {
+ windEnabled = enabled
+ prefs.edit().putBoolean(KEY_WIND, enabled).apply()
+ style.getLayer(LAYER_WIND)?.setProperties(
+ PropertyFactory.visibility(if (enabled) "visible" else "none"))
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private fun visibilityFor(preset: MapBasePreset, layerId: String): String = when (layerId) {
+ LAYER_SATELLITE -> if (preset == MapBasePreset.SATELLITE || preset == MapBasePreset.HYBRID) "visible" else "none"
+ LAYER_CHARTS -> if (preset == MapBasePreset.CHARTS || preset == MapBasePreset.HYBRID) "visible" else "none"
+ else -> "visible"
+ }
+
+ private fun opacityFor(preset: MapBasePreset): Float =
+ if (preset == MapBasePreset.HYBRID) 0.75f else 1f
+
+ private fun loadBasePreset(): MapBasePreset =
+ prefs.getString(KEY_BASE, null)?.let {
+ runCatching { MapBasePreset.valueOf(it) }.getOrNull()
+ } ?: MapBasePreset.CHARTS
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt
new file mode 100644
index 0000000..c638113
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt
@@ -0,0 +1,42 @@
+package org.terst.nav.ui
+
+import java.util.Calendar
+
+/**
+ * Lightweight in-memory log ring-buffer.
+ *
+ * Format: `HH:mm:ss.SSS LEVEL tag: message`
+ *
+ * Designed so both humans and Claude can read the output at a glance.
+ * Entries are captured from API calls, GPS fixes, and key state changes.
+ * Retrieve with [getLog] and copy to clipboard via DevLogSheet (long-press
+ * the drag handle on the instrument sheet).
+ */
+object NavLogger {
+
+ private const val MAX_ENTRIES = 200
+ private val entries = ArrayDeque<String>()
+
+ fun i(tag: String, msg: String) = append("INFO ", tag, msg)
+ fun w(tag: String, msg: String) = append("WARN ", tag, msg)
+ fun e(tag: String, msg: String) = append("ERROR", tag, msg)
+
+ private fun append(level: String, tag: String, msg: String) {
+ val cal = Calendar.getInstance()
+ val time = "%02d:%02d:%02d.%03d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE),
+ cal.get(Calendar.SECOND),
+ cal.get(Calendar.MILLISECOND)
+ )
+ val entry = "$time $level $tag: $msg"
+ synchronized(entries) {
+ entries.addLast(entry)
+ while (entries.size > MAX_ENTRIES) entries.removeFirst()
+ }
+ }
+
+ fun getLog(): String = synchronized(entries) { entries.joinToString("\n") }
+
+ fun clear() = synchronized(entries) { entries.clear() }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
new file mode 100644
index 0000000..ba7dee8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
@@ -0,0 +1,158 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Shader
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.view.View
+import org.terst.nav.R
+import kotlin.math.sin
+
+/**
+ * Draws an animated ocean-horizon scene used as the divider between the
+ * instrument section and the forecast section.
+ *
+ * Animation speed is driven by [swellPeriodSec] — a 20s period swell animates
+ * at half the speed of a 10s period swell. View height should be set by the
+ * caller to reflect actual swell height (see [InstrumentHandler.updateWaveState]).
+ *
+ * Whitecaps are only drawn when [windSpeedKt] >= 12 (Beaufort 4).
+ */
+class WaveView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ var swellHeightFt: Float = 3f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ var swellPeriodSec: Float = 10f
+ set(value) { field = value.coerceAtLeast(1f); invalidate() }
+
+ var windWaveHeightFt: Float = 1.5f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ /** Wind speed in knots. Whitecaps are suppressed below 12 kt (Beaufort 4). */
+ var windSpeedKt: Float = 0f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ private var startTimeMs = -1L
+
+ private val wavePath = Path()
+ private val skyPaint = Paint()
+ private val seaPaint = Paint()
+
+ private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = context.getColor(R.color.wave_shimmer)
+ }
+
+ private val whitecapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ strokeCap = Paint.Cap.ROUND
+ color = context.getColor(R.color.wave_whitecap)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ startTimeMs = SystemClock.elapsedRealtime()
+ postInvalidateOnAnimation()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ skyPaint.shader = LinearGradient(
+ 0f, 0f, 0f, h * 0.6f,
+ context.getColor(R.color.wave_sky_top),
+ context.getColor(R.color.wave_sky_bottom),
+ Shader.TileMode.CLAMP
+ )
+ seaPaint.shader = LinearGradient(
+ 0f, h * 0.4f, 0f, h.toFloat(),
+ context.getColor(R.color.wave_sea_top),
+ context.getColor(R.color.wave_sea_bottom),
+ Shader.TileMode.CLAMP
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (startTimeMs < 0) startTimeMs = SystemClock.elapsedRealtime()
+ val t = (SystemClock.elapsedRealtime() - startTimeMs) / 1000.0 // seconds
+
+ val w = width.toFloat()
+ val h = height.toFloat()
+
+ // Animation speed scales inversely with period: 10s → 0.8 rad/s, 20s → 0.4, 6s → 1.33
+ val swellSpeed = 8.0 / swellPeriodSec
+ val windSpeed = swellSpeed * 2.2
+
+ // Amplitude fills most of the view height — the view itself is sized for swell scale
+ val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.08f, h * 0.42f)
+ val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.16f)
+ val swellWlen = w * (swellPeriodSec / 14f)
+ val windWlen = swellWlen * 0.35f
+ val midY = h * 0.52f
+
+ fun waveY(x: Float): Float =
+ (midY
+ + sin((x / swellWlen) * TWO_PI - t * swellSpeed) * swellAmp
+ + sin((x / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp).toFloat()
+
+ // ── Sky fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, 0f)
+ wavePath.lineTo(0f, waveY(0f))
+ var x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, 0f)
+ wavePath.close()
+ canvas.drawPath(wavePath, skyPaint)
+
+ // ── Sea fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, h)
+ wavePath.lineTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, h)
+ wavePath.close()
+ canvas.drawPath(wavePath, seaPaint)
+
+ // ── Shimmer line ──────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ canvas.drawPath(wavePath, shimmerPaint)
+
+ // ── Whitecaps — only at Beaufort 4+ (≥12 kt) ─────────────────
+ if (windSpeedKt >= 12f) {
+ val capPath = Path()
+ x = windWlen * 0.5f
+ while (x <= w) {
+ val wv = sin((x / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp
+ val wn = sin(((x + 3) / windWlen) * TWO_PI - t * windSpeed + 1.2) * windAmp
+ if (wv > windAmp * 0.55 && wv >= wn) {
+ val y = waveY(x)
+ capPath.reset()
+ capPath.moveTo(x - 7f, y + 1f)
+ capPath.quadTo(x, y - 2.5f, x + 8f, y + 1f)
+ canvas.drawPath(capPath, whitecapPaint)
+ }
+ x += windWlen * 0.9f
+ }
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ companion object {
+ private const val TWO_PI = Math.PI * 2.0
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..d435f00
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package org.terst.nav.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import org.terst.nav.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
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..418246c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/learn/LearnFragment.kt
@@ -0,0 +1,45 @@
+package org.terst.nav.ui.learn
+
+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<MaterialCardView>(R.id.card_migrate_navionics).setOnClickListener {
+ openDoc("docs/migrate_navionics.md")
+ }
+ view.findViewById<MaterialCardView>(R.id.card_migrate_seapeople).setOnClickListener {
+ openDoc("docs/migrate_sea_people.md")
+ }
+
+ // Reference — offline docs
+ view.findViewById<MaterialCardView>(R.id.card_colregs).setOnClickListener {
+ openDoc("docs/colregs_reference.md")
+ }
+ view.findViewById<MaterialCardView>(R.id.card_sailing_reference).setOnClickListener {
+ openDoc("docs/sailing_reference.md")
+ }
+
+ }
+
+ private fun openDoc(path: String) {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, DocFragment.newInstance(path))
+ .addToBackStack(null)
+ .commit()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
new file mode 100644
index 0000000..26d9ea2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
@@ -0,0 +1,198 @@
+package org.terst.nav.ui.map
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.maps.MapLibreMap
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.random.Random
+
+/**
+ * Transparent canvas overlay that renders ~300 animated wind particles.
+ *
+ * Particle positions are tracked in lat/lon (FloatArray — no per-frame allocation).
+ * Each frame: project lat/lon → screen XY via MapLibreMap.projection, draw a short
+ * tail segment, then advance the position by the wind vector.
+ *
+ * Speed is scaled to the visible viewport so the animation looks consistent at any
+ * zoom level: a particle at reference wind (10 kt) crosses ~30% of the screen in
+ * MAX_AGE seconds.
+ *
+ * Longitude arithmetic is done in a west-relative [0, lonSpan] coordinate space
+ * so that viewports crossing the antimeridian (±180°) work correctly.
+ */
+class ParticleWindView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : View(context, attrs) {
+
+ private var map: MapLibreMap? = null
+
+ /** Direction the wind is coming FROM, degrees true (meteorological convention). */
+ private var windDirFromDeg = 0.0
+ private var windSpeedKt = 0.0
+
+ private val N = 300
+ private var activeN = 150 // wind-speed-dependent; updated in setWind()
+ private val particleLat = FloatArray(N)
+ private val particleLon = FloatArray(N)
+ private val particleAge = FloatArray(N)
+ private val MAX_AGE = 25f // seconds before forced respawn
+ private var lastLatRange = 0f // for zoom-out scatter detection
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ strokeWidth = 3.5f
+ strokeCap = Paint.Cap.ROUND
+ style = Paint.Style.STROKE
+ }
+
+ private var lastFrameNs = 0L
+ private var scattered = false
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ fun attachMap(mapLibreMap: MapLibreMap) {
+ map = mapLibreMap
+ scattered = false
+ }
+
+ fun setWind(dirFromDeg: Double, speedKt: Double) {
+ windDirFromDeg = dirFromDeg
+ windSpeedKt = speedKt
+ // Scale particle count: 60 at calm → 300 at 25+ kt
+ activeN = (60 + (speedKt.coerceIn(0.0, 25.0) / 25.0 * 240)).toInt()
+ }
+
+ // ── Rendering ────────────────────────────────────────────────────────────
+
+ override fun onDraw(canvas: Canvas) {
+ val m = map ?: run { postInvalidateOnAnimation(); return }
+
+ if (!scattered) scatter(m)
+
+ val now = System.nanoTime()
+ val dt = if (lastFrameNs == 0L) 0.016f
+ else ((now - lastFrameNs) / 1_000_000_000f).coerceAtMost(0.05f)
+ lastFrameNs = now
+
+ val r = m.projection.visibleRegion
+ val nL = r.nearLeft ?: run { postInvalidateOnAnimation(); return }
+ val nR = r.nearRight ?: run { postInvalidateOnAnimation(); return }
+ val fL = r.farLeft ?: run { postInvalidateOnAnimation(); return }
+ val fR = r.farRight ?: run { postInvalidateOnAnimation(); return }
+
+ val latSouth = minOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
+ val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
+ val latRange = latNorth - latSouth
+
+ // If the user zoomed out significantly, immediately redistribute particles
+ // across the new viewport rather than waiting for them to drift to the edges.
+ if (lastLatRange > 0f && latRange > lastLatRange * 1.8f) scatter(m)
+ lastLatRange = latRange
+
+ // West edge = left screen side; east edge = right screen side.
+ // Using screen-ordered corners handles antimeridian crossing correctly.
+ val lonWest = minOf(nL.longitude, fL.longitude).toFloat()
+ val lonEast = maxOf(nR.longitude, fR.longitude).toFloat()
+ // Span wraps around antimeridian when lonEast < lonWest (e.g. Pacific viewport)
+ val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f
+
+ // Speed scale: at 10kt a particle crosses 30% of viewport in MAX_AGE seconds.
+ val speedScale = latRange * 0.03f / MAX_AGE
+
+ // Geographic travel direction: opposite of the FROM direction.
+ val travelRad = Math.toRadians((windDirFromDeg + 180.0) % 360.0)
+ val cosTravel = cos(travelRad).toFloat()
+ val sinTravel = sin(travelRad).toFloat()
+
+ val dlat = cosTravel * windSpeedKt.toFloat() * speedScale * dt
+ val dlon = sinTravel * windSpeedKt.toFloat() * speedScale * dt
+
+ // Screen-space tail direction (accounts for map bearing).
+ val screenRad = travelRad - Math.toRadians(m.cameraPosition.bearing)
+ val tailDx = sin(screenRad).toFloat() * TAIL_PX
+ val tailDy = (-cos(screenRad)).toFloat() * TAIL_PX
+
+ for (i in 0 until activeN) {
+ particleLat[i] += dlat
+ particleLon[i] += dlon
+ // Wrap longitude into [-180, 180] after movement
+ if (particleLon[i] > 180f) particleLon[i] -= 360f
+ else if (particleLon[i] < -180f) particleLon[i] += 360f
+ particleAge[i] += dt
+
+ // Normalize particle longitude relative to lonWest into [0, 360)
+ // so the antimeridian-spanning bounds check works correctly.
+ val normLon = ((particleLon[i] - lonWest + 360f) % 360f)
+ val needsRespawn = particleAge[i] > MAX_AGE
+ || particleLat[i] < latSouth || particleLat[i] > latNorth
+ || normLon > lonSpan
+
+ if (needsRespawn) {
+ particleLat[i] = latSouth + Random.nextFloat() * latRange
+ var newLon = lonWest + Random.nextFloat() * lonSpan
+ if (newLon > 180f) newLon -= 360f
+ particleLon[i] = newLon
+ particleAge[i] = Random.nextFloat() * MAX_AGE
+ continue
+ }
+
+ val pt = m.projection.toScreenLocation(
+ LatLng(particleLat[i].toDouble(), particleLon[i].toDouble())
+ )
+
+ // Sine curve: smooth fade-in from birth, peak at mid-life, smooth fade-out.
+ // No bright birth flash — particles ease in and ease out.
+ val alpha = (sin(PI * particleAge[i] / MAX_AGE) * 200).toInt().coerceIn(15, 200)
+ paint.color = Color.argb(alpha, 30, 100, 255)
+
+ canvas.drawLine(pt.x - tailDx, pt.y - tailDy, pt.x, pt.y, paint)
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
+ super.onVisibilityChanged(changedView, visibility)
+ if (visibility == VISIBLE) {
+ lastFrameNs = 0L
+ postInvalidateOnAnimation()
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private fun scatter(m: MapLibreMap) {
+ val r = m.projection.visibleRegion
+ val nL = r.nearLeft ?: return
+ val nR = r.nearRight ?: return
+ val fL = r.farLeft ?: return
+ val fR = r.farRight ?: return
+
+ val latSouth = minOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
+ val latNorth = maxOf(nL.latitude, nR.latitude, fL.latitude, fR.latitude).toFloat()
+ val latRange = latNorth - latSouth
+ val lonWest = minOf(nL.longitude, fL.longitude).toFloat()
+ val lonEast = maxOf(nR.longitude, fR.longitude).toFloat()
+ val lonSpan = if (lonEast >= lonWest) lonEast - lonWest else lonEast - lonWest + 360f
+
+ for (i in 0 until activeN) {
+ particleLat[i] = latSouth + Random.nextFloat() * latRange
+ var newLon = lonWest + Random.nextFloat() * lonSpan
+ if (newLon > 180f) newLon -= 360f
+ particleLon[i] = newLon
+ particleAge[i] = Random.nextFloat() * MAX_AGE // stagger so no mass respawn
+ }
+ scattered = true
+ }
+
+ companion object {
+ private const val TAIL_PX = 14f
+ }
+}
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 e950b5d..d906094 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
@@ -5,9 +5,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
+import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
+import com.google.android.material.materialswitch.MaterialSwitch
+import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.ais.CpaAlert
+import org.terst.nav.ais.CpaSeverity
+import org.terst.nav.ais.label
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.ui.doc.DocFragment
class SafetyFragment : Fragment() {
@@ -15,6 +22,8 @@ class SafetyFragment : Fragment() {
interface SafetyListener {
fun onActivateMob()
fun onConfigureAnchor()
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean)
+ fun onQuitRequested()
}
private var listener: SafetyListener? = null
@@ -27,9 +36,7 @@ class SafetyFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
- return inflater.inflate(R.layout.fragment_safety, container, false)
- }
+ ): View? = inflater.inflate(R.layout.fragment_safety, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -48,9 +55,52 @@ class SafetyFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener {
listener?.onConfigureAnchor()
}
+
+ val flags = (requireActivity().application as NavApplication).featureFlags
+
+ val switchAis = view.findViewById<MaterialSwitch>(R.id.switch_ais)
+ switchAis.isChecked = flags.isEnabled(HardwareSource.AIS_RECEIVER)
+ switchAis.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.AIS_RECEIVER, checked)
+ }
+
+ val switchNmea = view.findViewById<MaterialSwitch>(R.id.switch_nmea)
+ switchNmea.isChecked = flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS)
+ switchNmea.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.NMEA_INSTRUMENTS, checked)
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_plan_trip).setOnClickListener {
+ parentFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.tripreport.PreTripReportFragment())
+ .addToBackStack(null)
+ .commit()
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_quit).setOnClickListener {
+ listener?.onQuitRequested()
+ }
}
fun updateAnchorStatus(statusText: String) {
view?.findViewById<TextView>(R.id.anchor_status_summary)?.text = statusText
}
+
+ fun updateCpaAlerts(alerts: List<CpaAlert>) {
+ val tv = view?.findViewById<TextView>(R.id.tv_cpa_status) ?: return
+ if (alerts.isEmpty()) {
+ tv.text = "No traffic alerts"
+ tv.setTextColor(ContextCompat.getColor(requireContext(), android.R.color.darker_gray))
+ return
+ }
+ tv.text = alerts.joinToString("\n") { it.label() }
+ val worstColor = when (alerts.first().severity) {
+ CpaSeverity.DANGER -> ContextCompat.getColor(requireContext(), android.R.color.holo_red_light)
+ CpaSeverity.WARNING -> ContextCompat.getColor(requireContext(), android.R.color.holo_orange_light)
+ CpaSeverity.CAUTION -> ContextCompat.getColor(requireContext(), android.R.color.holo_blue_light)
+ }
+ tv.setTextColor(worstColor)
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
new file mode 100644
index 0000000..d0d8515
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
@@ -0,0 +1,250 @@
+package org.terst.nav.ui.vessel
+
+import android.app.AlertDialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import org.terst.nav.R
+import org.terst.nav.vessel.CrewMember
+import org.terst.nav.vessel.CrewRole
+import org.terst.nav.vessel.Vessel
+import org.terst.nav.vessel.VesselRepository
+
+class VesselRegistryFragment : Fragment() {
+
+ private val repo = VesselRepository()
+
+ private lateinit var tvOwnVesselName: TextView
+ private lateinit var tvOwnVesselDetails: TextView
+ private lateinit var crewListContainer: LinearLayout
+ private lateinit var tvCrewEmpty: TextView
+ private lateinit var vesselListContainer: LinearLayout
+ private lateinit var tvVesselEmpty: TextView
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? = inflater.inflate(R.layout.fragment_vessel_registry, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ tvOwnVesselName = view.findViewById(R.id.tv_own_vessel_name)
+ tvOwnVesselDetails = view.findViewById(R.id.tv_own_vessel_details)
+ crewListContainer = view.findViewById(R.id.crew_list_container)
+ tvCrewEmpty = view.findViewById(R.id.tv_crew_empty)
+ vesselListContainer = view.findViewById(R.id.vessel_list_container)
+ tvVesselEmpty = view.findViewById(R.id.tv_vessel_empty)
+
+ view.findViewById<MaterialButton>(R.id.button_edit_own_vessel).setOnClickListener {
+ showEditVesselDialog(repo.getOwnVessel()) { vessel ->
+ repo.addVessel(vessel.copy(isOwnVessel = true))
+ refreshOwnVessel()
+ }
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_add_crew).setOnClickListener {
+ showEditCrewDialog(null) { member ->
+ repo.addCrewMember(member)
+ refreshCrew()
+ }
+ }
+
+ view.findViewById<MaterialButton>(R.id.button_add_vessel).setOnClickListener {
+ showEditVesselDialog(null) { vessel ->
+ repo.addVessel(vessel.copy(isOwnVessel = false))
+ refreshVesselList()
+ }
+ }
+
+ refresh()
+ }
+
+ private fun refresh() {
+ refreshOwnVessel()
+ refreshCrew()
+ refreshVesselList()
+ }
+
+ private fun refreshOwnVessel() {
+ val own = repo.getOwnVessel()
+ if (own != null) {
+ tvOwnVesselName.text = own.name
+ tvOwnVesselDetails.text = buildVesselDetails(own)
+ } else {
+ tvOwnVesselName.text = "No vessel configured"
+ tvOwnVesselDetails.text = ""
+ }
+ }
+
+ private fun refreshCrew() {
+ crewListContainer.removeAllViews()
+ val crew = repo.getCrew()
+ tvCrewEmpty.isVisible = crew.isEmpty()
+ crew.forEach { member -> crewListContainer.addView(buildCrewRow(member)) }
+ }
+
+ private fun refreshVesselList() {
+ vesselListContainer.removeAllViews()
+ val others = repo.getVessels().filter { !it.isOwnVessel }
+ tvVesselEmpty.isVisible = others.isEmpty()
+ others.forEach { v -> vesselListContainer.addView(buildVesselRow(v)) }
+ }
+
+ private fun buildCrewRow(member: CrewMember): View {
+ val ctx = requireContext()
+ val card = layoutInflater.inflate(
+ android.R.layout.simple_list_item_2, crewListContainer, false
+ )
+ card.findViewById<TextView>(android.R.id.text1).text =
+ "${member.name} · ${member.role.name.replace('_', ' ')}"
+ card.findViewById<TextView>(android.R.id.text2).text =
+ if (member.emergencyContact.isNotBlank()) "ICE: ${member.emergencyContact}" else ""
+ card.setOnClickListener {
+ showEditCrewDialog(member) { updated ->
+ repo.updateCrewMember(updated)
+ refreshCrew()
+ }
+ }
+ card.setOnLongClickListener {
+ AlertDialog.Builder(ctx)
+ .setTitle("Remove ${member.name}?")
+ .setPositiveButton("Remove") { _, _ -> repo.removeCrewMember(member.id); refreshCrew() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ true
+ }
+ return card
+ }
+
+ private fun buildVesselRow(vessel: Vessel): View {
+ val ctx = requireContext()
+ val row = layoutInflater.inflate(
+ android.R.layout.simple_list_item_2, vesselListContainer, false
+ )
+ row.findViewById<TextView>(android.R.id.text1).text =
+ "${vessel.name}${if (vessel.mmsi.isNotBlank()) " · MMSI ${vessel.mmsi}" else ""}"
+ row.findViewById<TextView>(android.R.id.text2).text = buildVesselDetails(vessel)
+ row.setOnClickListener {
+ showEditVesselDialog(vessel) { updated ->
+ repo.updateVessel(updated)
+ refreshVesselList()
+ }
+ }
+ row.setOnLongClickListener {
+ AlertDialog.Builder(ctx)
+ .setTitle("Remove ${vessel.name}?")
+ .setPositiveButton("Remove") { _, _ -> repo.removeVessel(vessel.id); refreshVesselList() }
+ .setNegativeButton("Cancel", null)
+ .show()
+ true
+ }
+ return row
+ }
+
+ private fun buildVesselDetails(v: Vessel): String = buildString {
+ if (v.hullType.name != "MONOHULL") append("${v.hullType.name.replace('_', ' ')} ")
+ if (v.loaMeters > 0) append("LOA %.1fm ".format(v.loaMeters))
+ if (v.callsign.isNotBlank()) append("Call: ${v.callsign} ")
+ if (v.homePort.isNotBlank()) append("Home: ${v.homePort}")
+ }.trim()
+
+ // --- Dialogs ---
+
+ private fun showEditVesselDialog(vessel: Vessel?, onSave: (Vessel) -> Unit) {
+ val ctx = requireContext()
+ val layout = LinearLayout(ctx).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 24, 48, 0)
+ }
+
+ fun field(hint: String, initial: String = ""): TextInputEditText {
+ val til = TextInputLayout(ctx).apply { this.hint = hint }
+ val et = TextInputEditText(ctx).apply { setText(initial) }
+ til.addView(et)
+ layout.addView(til)
+ return et
+ }
+
+ val etName = field("Vessel name *", vessel?.name ?: "")
+ val etMmsi = field("MMSI", vessel?.mmsi ?: "")
+ val etCallsign = field("Callsign", vessel?.callsign ?: "")
+ val etFlag = field("Flag (country code)", vessel?.flag ?: "")
+ val etHomePort = field("Home port", vessel?.homePort ?: "")
+ val etLoa = field("LOA (metres)", if ((vessel?.loaMeters ?: 0.0) > 0) "%.2f".format(vessel!!.loaMeters) else "")
+ val etNotes = field("Notes", vessel?.notes ?: "")
+
+ AlertDialog.Builder(ctx)
+ .setTitle(if (vessel == null) "Add Vessel" else "Edit Vessel")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val name = etName.text.toString().trim()
+ if (name.isBlank()) return@setPositiveButton
+ val v = (vessel ?: Vessel(id = "", name = "")).copy(
+ name = name,
+ mmsi = etMmsi.text.toString().trim(),
+ callsign = etCallsign.text.toString().trim(),
+ flag = etFlag.text.toString().trim(),
+ homePort = etHomePort.text.toString().trim(),
+ loaMeters = etLoa.text.toString().toDoubleOrNull() ?: vessel?.loaMeters ?: 0.0,
+ notes = etNotes.text.toString().trim()
+ )
+ onSave(v)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun showEditCrewDialog(member: CrewMember?, onSave: (CrewMember) -> Unit) {
+ val ctx = requireContext()
+ val layout = LinearLayout(ctx).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 24, 48, 0)
+ }
+
+ fun field(hint: String, initial: String = ""): TextInputEditText {
+ val til = TextInputLayout(ctx).apply { this.hint = hint }
+ val et = TextInputEditText(ctx).apply { setText(initial) }
+ til.addView(et)
+ layout.addView(til)
+ return et
+ }
+
+ val etName = field("Name *", member?.name ?: "")
+ val etRole = field("Role (SKIPPER/FIRST_MATE/WATCH_CAPTAIN/CREW/GUEST)",
+ member?.role?.name ?: CrewRole.CREW.name)
+ val etIceName = field("Emergency contact name", member?.emergencyContact ?: "")
+ val etIcePhone = field("Emergency contact phone", member?.emergencyPhone ?: "")
+ val etNotes = field("Notes", member?.notes ?: "")
+
+ AlertDialog.Builder(ctx)
+ .setTitle(if (member == null) "Add Crew Member" else "Edit Crew Member")
+ .setView(layout)
+ .setPositiveButton("Save") { _, _ ->
+ val name = etName.text.toString().trim()
+ if (name.isBlank()) return@setPositiveButton
+ val role = runCatching {
+ CrewRole.valueOf(etRole.text.toString().trim().uppercase())
+ }.getOrDefault(CrewRole.CREW)
+ val m = (member ?: CrewMember(id = "", name = "")).copy(
+ name = name,
+ role = role,
+ emergencyContact = etIceName.text.toString().trim(),
+ emergencyPhone = etIcePhone.text.toString().trim(),
+ notes = etNotes.text.toString().trim()
+ )
+ onSave(m)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+}
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 ef48d37..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,34 +12,74 @@ 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 = InMemoryLogbookRepository())
+ 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,
@@ -48,37 +90,59 @@ 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)
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())
+ .addToBackStack(null)
+ .commit()
+ }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }
}
}
+ // ── 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")
}
@@ -86,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) {}
})
}
@@ -110,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
}
}
@@ -153,7 +236,9 @@ class VoiceLogFragment : Fragment() {
permissions: Array<out String>,
grantResults: IntArray
) {
- if (requestCode == RC_AUDIO && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ if (requestCode == RC_AUDIO
+ && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
+ ) {
startListening()
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt
new file mode 100644
index 0000000..0df8cb9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/CrewMember.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.vessel
+
+data class CrewMember(
+ val id: String,
+ val name: String,
+ val role: CrewRole = CrewRole.CREW,
+ val emergencyContact: String = "",
+ val emergencyPhone: String = "",
+ val certifications: List<String> = emptyList(),
+ val notes: String = ""
+)
+
+enum class CrewRole {
+ SKIPPER, FIRST_MATE, WATCH_CAPTAIN, CREW, GUEST
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt
new file mode 100644
index 0000000..b7c0eaf
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/Vessel.kt
@@ -0,0 +1,22 @@
+package org.terst.nav.vessel
+
+data class Vessel(
+ val id: String,
+ val name: String,
+ val mmsi: String = "",
+ val callsign: String = "",
+ val flag: String = "",
+ val homePort: String = "",
+ val hullType: HullType = HullType.MONOHULL,
+ val loaMeters: Double = 0.0,
+ val beamMeters: Double = 0.0,
+ val draftMeters: Double = 0.0,
+ val displacementKg: Double = 0.0,
+ val engineHp: Int = 0,
+ val notes: String = "",
+ val isOwnVessel: Boolean = false
+)
+
+enum class HullType {
+ MONOHULL, CATAMARAN, TRIMARAN, POWERBOAT, OTHER
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
new file mode 100644
index 0000000..b65a816
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
@@ -0,0 +1,59 @@
+package org.terst.nav.vessel
+
+import java.util.UUID
+
+class VesselRepository {
+
+ private val vessels = mutableListOf<Vessel>()
+ private val crew = mutableListOf<CrewMember>()
+
+ // --- Vessel CRUD ---
+
+ fun addVessel(vessel: Vessel): Vessel {
+ val v = if (vessel.id.isBlank()) vessel.copy(id = UUID.randomUUID().toString()) else vessel
+ vessels.removeAll { it.id == v.id }
+ vessels.add(v)
+ return v
+ }
+
+ fun updateVessel(vessel: Vessel): Boolean {
+ val idx = vessels.indexOfFirst { it.id == vessel.id }
+ if (idx < 0) return false
+ vessels[idx] = vessel
+ return true
+ }
+
+ fun removeVessel(id: String): Boolean = vessels.removeAll { it.id == id }
+
+ fun getVessel(id: String): Vessel? = vessels.find { it.id == id }
+
+ fun getVessels(): List<Vessel> = vessels.toList()
+
+ fun getOwnVessel(): Vessel? = vessels.find { it.isOwnVessel }
+
+ // --- Crew CRUD ---
+
+ fun addCrewMember(member: CrewMember): CrewMember {
+ val m = if (member.id.isBlank()) member.copy(id = UUID.randomUUID().toString()) else member
+ crew.removeAll { it.id == m.id }
+ crew.add(m)
+ return m
+ }
+
+ fun updateCrewMember(member: CrewMember): Boolean {
+ val idx = crew.indexOfFirst { it.id == member.id }
+ if (idx < 0) return false
+ crew[idx] = member
+ return true
+ }
+
+ fun removeCrewMember(id: String): Boolean = crew.removeAll { it.id == id }
+
+ fun getCrewMember(id: String): CrewMember? = crew.find { it.id == id }
+
+ fun getCrew(): List<CrewMember> = crew.toList()
+
+ fun getCrewByRole(role: CrewRole): List<CrewMember> = crew.filter { it.role == role }
+
+ fun getSkipper(): CrewMember? = crew.find { it.role == CrewRole.SKIPPER }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
new file mode 100644
index 0000000..fd504cb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..dc3117c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
new file mode 100644
index 0000000..8c3ac56
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package org.terst.nav.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Calendar outline -->
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round"
+ android:pathData="M19,4H5C3.895,4 3,4.895 3,6v14c0,1.105 0.895,2 2,2h14c1.105,0 2,-0.895 2,-2V6C21,4.895 20.105,4 19,4z" />
+ <!-- Header bar -->
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:pathData="M3,10h18" />
+ <!-- Hanger lines -->
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:pathData="M8,2v4M16,2v4" />
+ <!-- Day dots (3 representative) -->
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M8,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" />
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M12,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" />
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M16,14m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" />
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M8,18m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" />
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M12,18m-1,0a1,1 0,1,0 2,0a1,1 0,1,0 -2,0" />
+</vector>
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 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="32dp"
+ android:height="32dp"
+ android:viewportWidth="32"
+ android:viewportHeight="32">
+
+ <!-- Horizontal line -->
+ <path
+ android:pathData="M2,16 L13,16 M19,16 L30,16"
+ android:strokeColor="#CCFFFFFF"
+ android:strokeWidth="1.5"
+ android:strokeLineCap="round" />
+
+ <!-- Vertical line -->
+ <path
+ android:pathData="M16,2 L16,13 M16,19 L16,30"
+ android:strokeColor="#CCFFFFFF"
+ android:strokeWidth="1.5"
+ android:strokeLineCap="round" />
+
+ <!-- Center circle -->
+ <path
+ android:pathData="M16,16 m-2.5,0 a2.5,2.5 0 1,0 5,0 a2.5,2.5 0 1,0 -5,0"
+ android:strokeColor="#CCFFFFFF"
+ android:strokeWidth="1.5"
+ android:fillColor="#00000000" />
+
+</vector>
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 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M11.99,18.54L4.62,12.81 3,14.07l9,7 9,-7-1.63,-1.27-8.38,5.74zM12,16l8.36,-6.54L22,8.07l-10,-7-10,7 1.63,1.39L12,16z"/>
+</vector>
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 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,3L1,9l11,6 9-4.91V17h2V9L12,3zM5,13.18v4L12,21l7-3.82v-4L12,17l-7-3.82z"/>
+</vector>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="18dp"
+ android:height="18dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Box outline with open top-right corner -->
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round"
+ android:pathData="M18,13v6a2,2 0,0 1,-2,2H5a2,2 0,0 1,-2,-2V8a2,2 0,0 1,2,-2h6" />
+ <!-- Arrow pointing top-right -->
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round"
+ android:pathData="M15,3h6v6" />
+ <path
+ android:fillColor="@android:color/transparent"
+ android:strokeColor="#FF000000"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round"
+ android:pathData="M10,14L21,3" />
+</vector>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Outer ring -->
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="@android:color/white"
+ android:strokeWidth="2"
+ android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
+ <!-- Filled red dot -->
+ <path
+ android:fillColor="#E53935"
+ android:pathData="M12,12m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"/>
+</vector>
diff --git a/android-app/app/src/main/res/drawable/ic_vessel.xml b/android-app/app/src/main/res/drawable/ic_vessel.xml
new file mode 100644
index 0000000..13a93ed
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_vessel.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Simple sailboat silhouette icon -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Hull -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M3,17 L5,20 L19,20 L21,17 Z" />
+ <!-- Mast -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,4 L12,17 M11.5,4 L11.5,17"
+ android:strokeColor="@android:color/white"
+ android:strokeWidth="1.5" />
+ <!-- Main sail -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,5 L18,15 L12,15 Z" />
+ <!-- Jib -->
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,7 L7,15 L12,15 Z" />
+</vector>
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 66d1abe..4150b2e 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -1,71 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:orientation="vertical"
tools:context=".MainActivity">
- <!-- Main Content Area -->
- <androidx.constraintlayout.widget.ConstraintLayout
+ <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_marginBottom="56dp"> <!-- Space for BottomNav -->
+ android:layout_height="0dp"
+ android:layout_weight="1">
- <org.maplibre.android.maps.MapView
- android:id="@+id/mapView"
+ <!-- Main Content Area -->
+ <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
- android:layout_height="match_parent" />
+ android:layout_height="match_parent">
- <!-- Overlay Fragment Container (for Log, Safety, Help) -->
- <FrameLayout
- android:id="@+id/fragment_container"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:visibility="gone"
- android:background="?attr/colorSurface" />
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/mapView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
- </androidx.constraintlayout.widget.ConstraintLayout>
+ <org.terst.nav.ui.map.ParticleWindView
+ android:id="@+id/particle_wind_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clickable="false"
+ android:focusable="false" />
- <!-- Collapsible Instrument Bottom Sheet -->
- <androidx.cardview.widget.CardView
- android:id="@+id/instrument_bottom_sheet"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- app:behavior_hideable="false"
- app:behavior_peekHeight="120dp"
- app:cardElevation="16dp"
- app:cardCornerRadius="24dp"
- app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
-
- <include
- layout="@layout/layout_instruments_sheet"
+ <!-- Boat-instrument HUD strip — always on top of map -->
+ <include
+ android:id="@+id/nav_hud"
+ layout="@layout/layout_nav_hud"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Crosshair — visible when not following (panning) -->
+ <ImageView
+ android:id="@+id/map_crosshair"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:src="@drawable/ic_crosshair"
+ android:visibility="gone"
+ android:clickable="false"
+ android:focusable="false"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Overlay Fragment Container (for Log, Safety, Help) -->
+ <FrameLayout
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ android:clickable="true"
+ android:focusable="true"
+ android:background="?attr/colorSurface" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fab_recenter"
+ android:layout_width="wrap_content"
+ android:layout_height="40dp"
+ android:text="⊙ Recenter"
+ android:textSize="13sp"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:visibility="gone"
+ app:cornerRadius="20dp"
+ app:elevation="20dp"
+ app:layout_constraintTop_toBottomOf="@id/nav_hud"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ android:layout_marginTop="8dp" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ <!-- Collapsible Instrument Bottom Sheet -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/instrument_bottom_sheet"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ app:behavior_hideable="false"
+ app:behavior_peekHeight="120dp"
+ app:cardElevation="16dp"
+ app:shapeAppearance="@style/ShapeAppearance.Nav.BottomSheet"
+ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
+
+ <include
+ layout="@layout/layout_instruments_sheet"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
- </androidx.cardview.widget.CardView>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- Track stats bar — shown only while recording -->
+ <TextView
+ android:id="@+id/tv_track_stats"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|center_horizontal"
+ android:layout_marginTop="12dp"
+ android:paddingHorizontal="14dp"
+ android:paddingVertical="6dp"
+ android:background="#CC000000"
+ android:textColor="#FFFFFFFF"
+ android:textSize="13sp"
+ android:fontFamily="monospace"
+ android:text=""
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <!-- Record Track Button -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_record_track"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Record Track"
+ app:srcCompat="@drawable/ic_track_record"
+ app:elevation="20dp"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|end" />
+
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
<!-- Bottom Navigation -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_gravity="bottom"
android:background="?attr/colorSurface"
app:menu="@menu/bottom_nav_menu" />
- <!-- Persistent MOB Button (Crucial for safety, always on top) -->
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_mob"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="16dp"
- android:clickable="true"
- android:focusable="true"
- android:contentDescription="Man Overboard"
- app:srcCompat="@android:drawable/ic_dialog_alert"
- app:backgroundTint="@color/mob_button_background"
- app:layout_anchor="@id/bottom_navigation"
- app:layout_anchorGravity="top|start" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_anchor_watch.xml b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
new file mode 100644
index 0000000..96b9969
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_watch_title"
+ android:textSize="20sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_depth_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etDepth"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_depth_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_rode_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etRodeOut"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_rode_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadius"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_empty"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadiusHint"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_hint"
+ android:textSize="12sp"
+ android:alpha="0.6" />
+
+ </LinearLayout>
+
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_learn.xml b/android-app/app/src/main/res/layout/fragment_learn.xml
new file mode 100644
index 0000000..fa18c5d
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_learn.xml
@@ -0,0 +1,205 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Learn"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface"
+ android:layout_marginBottom="24dp" />
+
+ <!-- ── Migration Guides ─────────────────────────────────────── -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="MIGRATION GUIDES"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_migrate_navionics"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="2dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Migrating from Navionics"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="›"
+ android:textSize="20sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_migrate_seapeople"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="2dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Migrating from Sea People"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="›"
+ android:textSize="20sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Reference (offline) ──────────────────────────────────── -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="REFERENCE"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_colregs"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="2dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ColRegs — Rules of the Road"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="International collision regulations, rules 1–38"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp" />
+
+ </LinearLayout>
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="›"
+ android:textSize="20sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_sailing_reference"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="2dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Sailing Quick Reference"
+ android:textAppearance="?attr/textAppearanceTitleSmall" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Points of sail, lights &amp; shapes, knots"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp" />
+
+ </LinearLayout>
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="›"
+ android:textSize="20sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ </LinearLayout>
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml
index e5b86b7..2b9b40d 100644
--- a/android-app/app/src/main/res/layout/fragment_map.xml
+++ b/android-app/app/src/main/res/layout/fragment_map.xml
@@ -27,4 +27,18 @@
android:textSize="14sp"
android:visibility="gone" />
+ <!-- Staleness banner -->
+ <TextView
+ android:id="@+id/tvStalenessWarning"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
+ android:background="#FFCC00"
+ android:textColor="#000000"
+ android:textStyle="bold"
+ android:padding="8dp"
+ android:gravity="center"
+ android:visibility="gone"
+ android:text="" />
+
</FrameLayout>
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
new file mode 100644
index 0000000..8cb094a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_pretrip_report.xml
@@ -0,0 +1,324 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <!-- ── Title ──────────────────────────────────────────────── -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Departure Briefing"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:layout_marginBottom="16dp" />
+
+ <!-- ── Boat selector ──────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="VESSEL"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_boats"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Departure time picker ──────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_departure"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="DEPART"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tv_departure_time"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Now"
+ android:textAppearance="?attr/textAppearanceTitleMedium" />
+
+ </LinearLayout>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_pick_departure"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Pick departure date and time"
+ app:icon="@drawable/ic_calendar" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Generate button + progress ─────────────────────────── -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="16dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_pretrip"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Refresh Briefing" />
+
+ <ProgressBar
+ android:id="@+id/progress_pretrip"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_marginStart="12dp"
+ android:visibility="gone" />
+ </LinearLayout>
+
+ <!-- ── Conditions window ───────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_conditions"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="CONDITIONS WINDOW"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="12dp" />
+
+ <!-- 4-column table built programmatically -->
+ <LinearLayout
+ android:id="@+id/conditions_table"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Route projection ────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_route"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ROUTE PROJECTION"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_route"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.3" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Sail plan ───────────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_sail_plan"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="SAIL PLAN"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_sail_plan"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.4" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Watch list ──────────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_watchlist"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp"
+ app:strokeColor="?attr/colorError"
+ app:strokeWidth="1dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="WATCH FOR"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_watchlist"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.5" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Similar trips ───────────────────────────────────────── -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_similar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ app:cardElevation="2dp"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="SIMILAR TRIPS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:id="@+id/tv_similar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:lineSpacingMultiplier="1.3" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- ── Error state ─────────────────────────────────────────── -->
+ <TextView
+ android:id="@+id/tv_error"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ android:textColor="?attr/colorError"
+ android:textAppearance="?attr/textAppearanceBodyMedium" />
+
+ </LinearLayout>
+</ScrollView>
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 5b2397e..1072abe 100644
--- a/android-app/app/src/main/res/layout/fragment_safety.xml
+++ b/android-app/app/src/main/res/layout/fragment_safety.xml
@@ -63,6 +63,41 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- CPA / Collision Alert Section -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_cpa"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ app:cardCornerRadius="16dp"
+ app:layout_constraintTop_toBottomOf="@id/card_mob">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="COLLISION AVOIDANCE"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/tv_cpa_status"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No traffic alerts"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:fontFamily="monospace"
+ android:textSize="13sp" />
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
<!-- Anchor Section -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_anchor"
@@ -70,7 +105,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="16dp"
- app:layout_constraintTop_toBottomOf="@id/card_mob">
+ app:layout_constraintTop_toBottomOf="@id/card_cpa">
<LinearLayout
android:layout_width="match_parent"
@@ -104,4 +139,131 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- Hardware Data Sources -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_data_sources"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ app:cardCornerRadius="16dp"
+ app:layout_constraintTop_toBottomOf="@id/card_anchor">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="HARDWARE DATA SOURCES"
+ android:textStyle="bold" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:layout_marginBottom="12dp"
+ android:text="Enable only the hardware you have connected"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <!-- AIS Receiver row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="AIS Receiver"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Vessel tracking, collision alerts"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_ais"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <!-- NMEA Instruments row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="NMEA Instruments"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind, depth, boat speed"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_nmea"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_plan_trip"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="PLAN TRIP (PRE-TRIP REPORT)"
+ app:layout_constraintTop_toBottomOf="@id/card_data_sources" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_quit"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Quit"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ app:icon="@drawable/ic_close"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml
new file mode 100644
index 0000000..1ce0bde
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Trip Narrative"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Choose Narrative Style:"
+ android:textSize="14sp"
+ android:layout_marginBottom="8dp" />
+
+ <HorizontalScrollView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="24dp">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_styles"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_professional"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Professional"
+ android:checked="true" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_adventurous"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Adventurous" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_journal"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Journal" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_pirate"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Pirate" />
+
+ </com.google.android.material.chip.ChipGroup>
+ </HorizontalScrollView>
+
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="4dp"
+ app:strokeWidth="1dp"
+ app:strokeColor="?attr/colorOutlineVariant">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_narrative_content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="Generate a report to see your story..."
+ android:textSize="16sp"
+ android:lineSpacingExtra="4dp" />
+
+ </LinearLayout>
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_refresh_report"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:layout_marginTop="24dp"
+ android:text="REFRESH REPORT" />
+
+ <ProgressBar
+ android:id="@+id/progress_report"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_vessel_registry.xml b/android-app/app/src/main/res/layout/fragment_vessel_registry.xml
new file mode 100644
index 0000000..6b55fa6
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_vessel_registry.xml
@@ -0,0 +1,177 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.core.widget.NestedScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="24dp">
+
+ <!-- Title row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Vessel Registry"
+ android:textSize="24sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface" />
+
+ </LinearLayout>
+
+ <!-- OWN VESSEL card -->
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="20dp"
+ android:layout_marginBottom="8dp"
+ android:text="OWN VESSEL"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_own_vessel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:cardCornerRadius="12dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/tv_own_vessel_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No vessel configured"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:id="@+id/tv_own_vessel_details"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_edit_own_vessel"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:text="EDIT VESSEL" />
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <!-- CREW section -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="24dp"
+ android:layout_marginBottom="8dp">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="CREW"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_add_crew"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ ADD" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/crew_list_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <TextView
+ android:id="@+id/tv_crew_empty"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No crew members added yet."
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="visible" />
+
+ <!-- FLEET section -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="24dp"
+ android:layout_marginBottom="8dp">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="KNOWN VESSELS"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.1"
+ android:textColor="?attr/colorPrimary" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_add_vessel"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ ADD" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/vessel_list_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <TextView
+ android:id="@+id/tv_vessel_empty"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="No additional vessels recorded."
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:visibility="visible" />
+
+ </LinearLayout>
+
+</androidx.core.widget.NestedScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml
index e5f864c..c1275a6 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -1,66 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
- android:gravity="center"
- android:padding="24dp">
+ android:padding="20dp">
<TextView
- android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Tap microphone to log"
- android:textSize="18sp"
- android:textAlignment="center"
+ android:text="Log Entry"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:layout_marginBottom="16dp" />
- <TextView
- android:id="@+id/tv_recognized"
+ <!-- Note text — voice fills this; user can also type directly -->
+ <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text=""
- android:textSize="16sp"
- android:textAlignment="center"
- android:padding="12dp"
- android:minHeight="80dp"
- android:background="#F5F5F5"
- android:layout_marginBottom="24dp" />
+ android:layout_marginBottom="12dp"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/et_note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minLines="3"
+ android:maxLines="6"
+ android:gravity="top"
+ android:hint="Tap mic or type a note…"
+ android:inputType="textMultiLine|textCapSentences"
+ android:scrollbars="vertical" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <!-- Action row: mic, camera, gallery, status -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mic"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:fabSize="mini"
+ android:src="@android:drawable/ic_btn_speak_now"
+ android:contentDescription="Start voice recognition"
+ android:layout_marginEnd="12dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_camera"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Take photo"
+ app:icon="@android:drawable/ic_menu_camera"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_gallery"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Attach photo from gallery"
+ app:icon="@android:drawable/ic_menu_gallery"
+ android:layout_marginEnd="12dp" />
+
+ <TextView
+ android:id="@+id/tv_status"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_mic"
+ <!-- Photo thumbnail — hidden until a photo is attached -->
+ <FrameLayout
+ android:id="@+id/frame_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:contentDescription="Start voice recognition"
- android:src="@android:drawable/ic_btn_speak_now"
- android:layout_marginBottom="16dp" />
+ android:layout_marginBottom="12dp"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_photo"
+ android:layout_width="120dp"
+ android:layout_height="90dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Attached photo" />
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_remove_photo"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_gravity="top|end"
+ android:contentDescription="Remove photo"
+ app:icon="@drawable/ic_close" />
+
+ </FrameLayout>
+
+ <!-- Save / Clear row -->
<LinearLayout
- android:id="@+id/ll_confirm_buttons"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
- android:visibility="gone">
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
- <Button
+ <com.google.android.material.button.MaterialButton
android:id="@+id/btn_save"
- android:layout_width="wrap_content"
+ android:layout_width="0dp"
android:layout_height="wrap_content"
+ android:layout_weight="1"
android:text="Save"
android:layout_marginEnd="8dp" />
- <Button
- android:id="@+id/btn_retry"
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_clear"
+ style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Retry" />
+ android:text="Clear" />
+
</LinearLayout>
+ <!-- Saved confirmation -->
<TextView
android:id="@+id/tv_saved_confirmation"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="14sp"
- android:layout_marginTop="16dp" />
+ android:textColor="?attr/colorPrimary"
+ android:layout_marginBottom="24dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutline"
+ android:layout_marginBottom="24dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_generate_report"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:text="GENERATE TRIP REPORT" />
+
</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/item_condition_column.xml b/android-app/app/src/main/res/layout/item_condition_column.xml
new file mode 100644
index 0000000..1ffcd55
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_condition_column.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center_horizontal"
+ android:padding="4dp">
+
+ <TextView
+ android:id="@+id/col_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="10sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/col_wind"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:layout_marginBottom="2dp" />
+
+ <TextView
+ android:id="@+id/col_wind_dir"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/col_sky"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="10sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:gravity="center" />
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index 0a84418..aa785e6 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -1,166 +1,343 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:padding="16dp"
- android:background="?attr/colorSurface">
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="16dp"
+ android:background="?attr/colorSurface"
+ android:clickable="true"
+ android:focusable="true">
+ <!-- Drag handle -->
<View
android:id="@+id/drag_handle"
- android:layout_width="40dp"
+ android:layout_width="36dp"
android:layout_height="4dp"
+ android:layout_marginTop="12dp"
android:background="@color/md_theme_outline"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
- <!-- Grid of Instruments -->
- <androidx.gridlayout.widget.GridLayout
- android:id="@+id/instrument_grid"
+ <!--
+ Area-conditions header row: Wind (kts + arrow) | Temp (°C) | Baro (hPa)
+ Always shows conditions for the current map center.
+ -->
+ <LinearLayout
+ android:id="@+id/conditions_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
- app:columnCount="3"
- app:rowCount="3"
- app:layout_constraintTop_toBottomOf="@id/drag_handle">
+ android:orientation="horizontal"
+ android:layout_marginTop="12dp"
+ app:layout_constraintTop_toBottomOf="@id/drag_handle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
- <!-- Wind: AWS -->
+ <!-- Wind -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="AWS" />
- <TextView android:id="@+id/value_aws" style="@style/InstrumentPrimaryValue" tools:text="12.5" />
+ <TextView style="@style/InstrumentLabel" android:text="Wind" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_tws"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="15.5" />
+ <TextView android:id="@+id/unit_tws" style="@style/InstrumentUnit" android:text="kt" />
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_tws"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginStart="3dp"
+ android:layout_marginBottom="3dp"
+ android:layout_gravity="bottom" />
+ <ProgressBar
+ android:id="@+id/spinner_tws"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="?attr/colorOnSurfaceVariant"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
+ <TextView
+ android:id="@+id/bearing_tws"
+ style="@style/InstrumentLabel"
+ tools:text="270°" />
</LinearLayout>
- <!-- Compass: HDG -->
- <LinearLayout
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- app:layout_columnWeight="1"
- android:orientation="vertical"
- android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="HDG" />
- <TextView android:id="@+id/value_hdg" style="@style/InstrumentPrimaryValue" tools:text="225" />
- </LinearLayout>
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="@color/md_theme_outline" />
- <!-- Performance: BSP -->
+ <!-- Temp -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="BSP" />
- <TextView android:id="@+id/value_bsp" style="@style/InstrumentPrimaryValue" tools:text="6.2" />
+ <TextView style="@style/InstrumentLabel" android:text="Temp" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_temp"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="18" />
+ <TextView android:id="@+id/unit_temp" style="@style/InstrumentUnit" android:text="°F" />
+ <ProgressBar
+ android:id="@+id/spinner_temp"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="?attr/colorOnSurfaceVariant"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
</LinearLayout>
- <!-- Wind: TWS -->
- <LinearLayout
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- app:layout_columnWeight="1"
- android:orientation="vertical"
- android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="TWS" />
- <TextView android:id="@+id/value_tws" style="@style/InstrumentPrimaryValue" tools:text="15.0" />
- </LinearLayout>
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="@color/md_theme_outline" />
- <!-- Compass: COG -->
+ <!-- Baro -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="COG" />
- <TextView android:id="@+id/value_cog" style="@style/InstrumentPrimaryValue" tools:text="230" />
+ <TextView style="@style/InstrumentLabel" android:text="Baro" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_baro"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="1013" />
+ <TextView android:id="@+id/unit_baro" style="@style/InstrumentUnit" android:text="hPa" />
+ <ProgressBar
+ android:id="@+id/spinner_baro"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="?attr/colorOnSurfaceVariant"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
</LinearLayout>
- <!-- Performance: SOG -->
- <LinearLayout
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- app:layout_columnWeight="1"
- android:orientation="vertical"
- android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="SOG" />
- <TextView android:id="@+id/value_sog" style="@style/InstrumentPrimaryValue" tools:text="6.5" />
- </LinearLayout>
+ </LinearLayout>
+
+ <!-- Animated wave divider — colors adapt via wave_sky/sea color resources -->
+ <org.terst.nav.ui.WaveView
+ android:id="@+id/wave_divider"
+ android:layout_width="match_parent"
+ android:layout_height="72dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/conditions_header"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
- <!-- VMG -->
+ <!-- Forecast section (ocean — intentionally always dark, do not invert) -->
+ <LinearLayout
+ android:id="@+id/forecast_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:forceDarkAllowed="false"
+ android:background="#0D2137"
+ android:paddingTop="14dp"
+ android:paddingBottom="20dp"
+ android:layout_marginStart="-16dp"
+ android:layout_marginEnd="-16dp"
+ app:layout_constraintTop_toBottomOf="@id/wave_divider"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent">
+
+ <!-- Current -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="VMG" />
- <TextView android:id="@+id/value_vmg" style="@style/InstrumentPrimaryValue" tools:text="4.2" />
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Current" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_curr_spd"
+ style="@style/ForecastValue"
+ tools:text="0.8" />
+ <TextView android:id="@+id/unit_curr_spd" style="@style/ForecastUnit" android:text="kt" />
+ <ProgressBar
+ android:id="@+id/spinner_curr"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="#5B9EC2"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_curr"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_curr"
+ style="@style/ForecastBearing"
+ tools:text="185°" />
+ </LinearLayout>
</LinearLayout>
- <!-- Depth -->
+ <!-- Waves -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Depth" />
- <TextView android:id="@+id/value_depth" style="@style/InstrumentPrimaryValue" tools:text="12.0" />
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_wave_ht"
+ style="@style/ForecastValue"
+ tools:text="3.5" />
+ <TextView android:id="@+id/unit_wave_ht" style="@style/ForecastUnit" android:text="ft" />
+ <ProgressBar
+ android:id="@+id/spinner_waves"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="#5B9EC2"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_waves"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_waves"
+ style="@style/ForecastBearing"
+ tools:text="275°" />
+ </LinearLayout>
</LinearLayout>
- <!-- Polar % -->
+ <!-- Swell -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
- app:layout_columnWeight="1"
+ android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
- android:padding="8dp">
- <TextView style="@style/InstrumentLabel" android:text="Polar %" />
- <TextView android:id="@+id/value_polar_pct" style="@style/InstrumentPrimaryValue" tools:text="95%" />
+ android:padding="4dp">
+ <TextView style="@style/ForecastLabel" android:text="Swell" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_swell_ht"
+ style="@style/ForecastValue"
+ tools:text="5.2" />
+ <TextView android:id="@+id/unit_swell_ht" style="@style/ForecastUnit" android:text="ft" />
+ <ProgressBar
+ android:id="@+id/spinner_swell"
+ style="?android:attr/progressBarStyleSmall"
+ android:layout_width="10dp"
+ android:layout_height="10dp"
+ android:indeterminate="true"
+ android:visibility="invisible"
+ android:alpha="0.5"
+ android:indeterminateTint="#5B9EC2"
+ android:layout_marginStart="4dp" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp">
+ <org.terst.nav.ui.DirectionArrowView
+ android:id="@+id/arrow_swell"
+ android:layout_width="14dp"
+ android:layout_height="14dp" />
+ <TextView
+ android:id="@+id/bearing_swell"
+ style="@style/ForecastBearing"
+ tools:text="260°" />
+ <TextView
+ android:id="@+id/value_swell_per"
+ style="@style/ForecastPeriod"
+ tools:text="· 14s" />
+ </LinearLayout>
</LinearLayout>
- </androidx.gridlayout.widget.GridLayout>
-
- <!-- Additional Detail (Visible when expanded) -->
- <TextView
- android:id="@+id/label_baro"
- style="@style/InstrumentLabel"
- android:text="Barometer"
- android:layout_marginTop="24dp"
- app:layout_constraintTop_toBottomOf="@id/instrument_grid"
- app:layout_constraintStart_toStartOf="parent" />
-
- <TextView
- android:id="@+id/value_baro"
- style="@style/InstrumentPrimaryValue"
- tools:text="1013.2 hPa"
- app:layout_constraintTop_toBottomOf="@id/label_baro"
- app:layout_constraintStart_toStartOf="parent" />
-
- <org.terst.nav.PolarDiagramView
- android:id="@+id/polar_diagram_view"
- android:layout_width="0dp"
- android:layout_height="0dp"
- android:layout_marginTop="24dp"
- app:layout_constraintDimensionRatio="1:1"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/value_baro"
- app:layout_constraintBottom_toBottomOf="parent" />
+ </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
new file mode 100644
index 0000000..fdfbb7a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
@@ -0,0 +1,310 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/colorSurface">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="40dp">
+
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <!-- ── MAP LAYERS ──────────────────────────────────────────── -->
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:text="MAP LAYERS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary" />
+
+ <!-- Base map selection -->
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_base"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_satellite"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Satellite" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_charts"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Charts" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_hybrid"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Hybrid" />
+
+ </com.google.android.material.chip.ChipGroup>
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginBottom="16dp"
+ android:background="@color/md_theme_surfaceVariant" />
+
+ <!-- Wind overlay toggle -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind overlay"
+ android:textSize="15sp"
+ android:textColor="@color/instrument_text_normal" />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind speed colour map"
+ android:textSize="12sp"
+ android:textColor="@color/instrument_text_secondary" />
+ </LinearLayout>
+
+ <com.google.android.material.switchmaterial.SwitchMaterial
+ android:id="@+id/switch_wind"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <!-- Wind particles toggle -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind particles"
+ android:textSize="15sp"
+ android:textColor="@color/instrument_text_normal" />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Animated particle overlay"
+ android:textSize="12sp"
+ android:textColor="@color/instrument_text_secondary" />
+ </LinearLayout>
+
+ <com.google.android.material.switchmaterial.SwitchMaterial
+ android:id="@+id/switch_particles"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <!-- ── UNITS ───────────────────────────────────────────────── -->
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginTop="8dp"
+ android:layout_marginBottom="16dp"
+ android:background="@color/md_theme_surfaceVariant" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:text="UNITS"
+ android:textSize="11sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary" />
+
+ <!-- Temperature -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="10dp">
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Temperature"
+ android:textSize="14sp"
+ android:textColor="@color/instrument_text_normal" />
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_temp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_fahrenheit"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="°F" />
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_celsius"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="°C" />
+ </com.google.android.material.chip.ChipGroup>
+ </LinearLayout>
+
+ <!-- Depth / height -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="10dp">
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Depth / height"
+ android:textSize="14sp"
+ android:textColor="@color/instrument_text_normal" />
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_depth"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_feet"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ft" />
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_meters"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="m" />
+ </com.google.android.material.chip.ChipGroup>
+ </LinearLayout>
+
+ <!-- Pressure -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="10dp">
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Pressure"
+ android:textSize="14sp"
+ android:textColor="@color/instrument_text_normal" />
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_pressure"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_hpa"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="hPa" />
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_inhg"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="inHg" />
+ </com.google.android.material.chip.ChipGroup>
+ </LinearLayout>
+
+ <!-- Speed -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Speed"
+ android:textSize="14sp"
+ android:textColor="@color/instrument_text_normal" />
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group_speed"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:singleSelection="true"
+ app:selectionRequired="true">
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_knots"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="kt" />
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_mph"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="mph" />
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_kph"
+ style="@style/Widget.Material3.Chip.Filter"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="kph" />
+ </com.google.android.material.chip.ChipGroup>
+ </LinearLayout>
+
+ </LinearLayout>
+</ScrollView>
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..90119fa
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_nav_hud.xml
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Persistent boat-instrument HUD strip.
+ Lives at the top of the map; always visible; shows dashes when NMEA unavailable.
+ Fields: SOG | COG | BSP | Depth
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ android:orientation="horizontal"
+ android:background="#CC000000">
+
+ <!-- SOG -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="SOG"
+ android:textSize="9sp"
+ android:textColor="#99FFFFFF"
+ android:letterSpacing="0.08" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/hud_sog"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:textColor="#FFFFFF"
+ android:fontFamily="sans-serif-medium"
+ tools:text="7.1" />
+ <TextView
+ android:id="@+id/unit_hud_sog"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="kt"
+ android:textSize="10sp"
+ android:textColor="#99FFFFFF"
+ android:layout_marginStart="2dp" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" />
+
+ <!-- COG -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="COG"
+ android:textSize="9sp"
+ android:textColor="#99FFFFFF"
+ android:letterSpacing="0.08" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/hud_cog"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:textColor="#FFFFFF"
+ android:fontFamily="sans-serif-medium"
+ tools:text="253°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" />
+
+ <!-- BSP -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="BSP"
+ android:textSize="9sp"
+ android:textColor="#99FFFFFF"
+ android:letterSpacing="0.08" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/hud_bsp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:textColor="#FFFFFF"
+ android:fontFamily="sans-serif-medium"
+ tools:text="6.8" />
+ <TextView
+ android:id="@+id/unit_hud_bsp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="kt"
+ android:textSize="10sp"
+ android:textColor="#99FFFFFF"
+ android:layout_marginStart="2dp" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <View android:layout_width="1dp" android:layout_height="match_parent" android:background="#33FFFFFF" />
+
+ <!-- Depth -->
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Depth"
+ android:textSize="9sp"
+ android:textColor="#99FFFFFF"
+ android:letterSpacing="0.08" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/hud_depth"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="16sp"
+ android:textColor="#FFFFFF"
+ android:fontFamily="sans-serif-medium"
+ tools:text="42.0" />
+ <TextView
+ android:id="@+id/unit_hud_depth"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="ft"
+ android:textSize="10sp"
+ android:textColor="#99FFFFFF"
+ android:layout_marginStart="2dp" />
+ </LinearLayout>
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
new file mode 100644
index 0000000..a26b76e
--- /dev/null
+++ b/android-app/app/src/main/res/layout/layout_track_summary_sheet.xml
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingBottom="32dp"
+ android:background="?attr/colorSurface">
+
+ <!-- Drag handle -->
+ <View
+ android:layout_width="36dp"
+ android:layout_height="4dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="20dp"
+ android:background="@color/md_theme_outline" />
+
+ <!-- Title -->
+ <TextView
+ android:id="@+id/summary_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.12"
+ android:fontFamily="sans-serif-light"
+ android:textColor="@color/instrument_text_secondary"
+ tools:text="Track · 06 Apr 14:32" />
+
+ <!-- Distance + Duration row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Distance" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_distance"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="12.3" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="nm" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Duration" />
+ <TextView
+ android:id="@+id/summary_duration"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2h 14m" />
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Speed row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Max Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_max_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="8.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Speed" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_sog"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="5.5" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <!-- Wind + Waves row (conditionally visible) -->
+ <LinearLayout
+ android:id="@+id/summary_conditions_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+ <LinearLayout
+ android:id="@+id/summary_wind_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Wind" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wind"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="14" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="kt" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/summary_wave_cell"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:visibility="gone"
+ tools:visibility="visible">
+ <TextView
+ style="@style/InstrumentLabel"
+ android:text="Avg Waves" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/summary_avg_wave"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="2.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="ft" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/sheet_dev_log.xml b/android-app/app/src/main/res/layout/sheet_dev_log.xml
new file mode 100644
index 0000000..5464242
--- /dev/null
+++ b/android-app/app/src/main/res/layout/sheet_dev_log.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:background="?attr/colorSurface">
+
+ <!-- Header: title + action buttons -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingStart="16dp"
+ android:paddingEnd="8dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="8dp"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Dev Log"
+ android:textSize="16sp"
+ android:textStyle="bold"
+ android:textColor="?attr/colorOnSurface" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_clear"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Clear"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_copy"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Copy" />
+
+ </LinearLayout>
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutline"
+ android:alpha="0.3" />
+
+ <ScrollView
+ android:id="@+id/scroll_view"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:paddingTop="10dp"
+ android:paddingBottom="24dp"
+ android:clipToPadding="false">
+
+ <TextView
+ android:id="@+id/log_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fontFamily="monospace"
+ android:textSize="11sp"
+ android:textColor="?attr/colorOnSurface"
+ android:lineSpacingMultiplier="1.35"
+ android:textIsSelectable="true" />
+
+ </ScrollView>
+
+</LinearLayout>
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..5c6de7e 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" />
<item
- android:id="@+id/nav_instruments"
- android:icon="@drawable/ic_instruments"
- android:title="Instruments" />
+ android:id="@+id/nav_layers"
+ android:icon="@drawable/ic_layers"
+ android:title="Layers" />
<item
android:id="@+id/nav_log"
android:icon="@drawable/ic_log"
@@ -16,4 +16,12 @@
android:id="@+id/nav_safety"
android:icon="@drawable/ic_safety"
android:title="Safety" />
+ <item
+ android:id="@+id/nav_vessel"
+ android:icon="@drawable/ic_vessel"
+ android:title="Vessel" />
+ <item
+ android:id="@+id/nav_learn"
+ android:icon="@drawable/ic_learn"
+ android:title="Learn" />
</menu>
diff --git a/android-app/app/src/main/res/values-night/colors.xml b/android-app/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..b3f8669
--- /dev/null
+++ b/android-app/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- M3 Dark Palette (night mode) -->
+ <color name="md_theme_primary">#A8C8FF</color>
+ <color name="md_theme_onPrimary">#00315E</color>
+ <color name="md_theme_primaryContainer">#004886</color>
+ <color name="md_theme_onPrimaryContainer">#D6E3FF</color>
+
+ <color name="md_theme_secondary">#BBC7DB</color>
+ <color name="md_theme_onSecondary">#253140</color>
+ <color name="md_theme_secondaryContainer">#3B4858</color>
+ <color name="md_theme_onSecondaryContainer">#DAE2F9</color>
+
+ <color name="md_theme_error">#FFB4AB</color>
+ <color name="md_theme_onError">#690005</color>
+ <color name="md_theme_errorContainer">#93000A</color>
+ <color name="md_theme_onErrorContainer">#FFDAD6</color>
+
+ <color name="md_theme_background">#1C1B1F</color>
+ <color name="md_theme_onBackground">#E6E1E5</color>
+ <color name="md_theme_surface">#1C1B1F</color>
+ <color name="md_theme_onSurface">#E6E1E5</color>
+ <color name="md_theme_surfaceVariant">#49454F</color>
+ <color name="md_theme_onSurfaceVariant">#CAC4D0</color>
+ <color name="md_theme_outline">#938F99</color>
+
+ <!-- Instrument: dark-mode values -->
+ <color name="instrument_text_normal">#E6E1E5</color>
+ <color name="instrument_text_secondary">#9A94A0</color>
+ <color name="instrument_background">#1C1B1F</color>
+ <color name="instrument_card_background">#2B2930</color>
+
+ <!-- WaveView — night: dark sky + midnight ocean -->
+ <color name="wave_sky_top">#1C1B1F</color>
+ <color name="wave_sky_bottom">#162433</color>
+ <color name="wave_sea_top">#0B3050</color>
+ <color name="wave_sea_bottom">#0D2137</color>
+ <color name="wave_shimmer">#4D6FC3E8</color>
+ <color name="wave_whitecap">#80FFFFFF</color>
+</resources>
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index b380d2d..2df4109 100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -1,28 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <!-- M3 Color Palette -->
+ <!-- M3 Color Palette (light mode — dark mode overrides in values-night/colors.xml) -->
<color name="md_theme_primary">#005FB0</color>
<color name="md_theme_onPrimary">#FFFFFF</color>
<color name="md_theme_primaryContainer">#D6E3FF</color>
<color name="md_theme_onPrimaryContainer">#001B3E</color>
-
+
<color name="md_theme_secondary">#565F71</color>
<color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#DAE2F9</color>
<color name="md_theme_onSecondaryContainer">#131C2C</color>
-
+
<color name="md_theme_error">#BA1A1A</color>
<color name="md_theme_onError">#FFFFFF</color>
<color name="md_theme_errorContainer">#FFDAD6</color>
<color name="md_theme_onErrorContainer">#410002</color>
-
- <color name="md_theme_background">#FDFBFF</color>
- <color name="md_theme_onBackground">#1A1C1E</color>
- <color name="md_theme_surface">#FDFBFF</color>
- <color name="md_theme_onSurface">#1A1C1E</color>
- <color name="md_theme_surfaceVariant">#E0E2EC</color>
- <color name="md_theme_onSurfaceVariant">#44474E</color>
- <color name="md_theme_outline">#74777F</color>
+
+ <color name="md_theme_background">#FFFBFE</color>
+ <color name="md_theme_onBackground">#1C1B1F</color>
+ <color name="md_theme_surface">#FFFBFE</color>
+ <color name="md_theme_onSurface">#1C1B1F</color>
+ <color name="md_theme_surfaceVariant">#E7E0EB</color>
+ <color name="md_theme_onSurfaceVariant">#49454F</color>
+ <color name="md_theme_outline">#7A757F</color>
<!-- Legacy / Functional Colors -->
<color name="black">#FF000000</color>
@@ -31,13 +31,13 @@
<color name="primary_dark">#002171</color>
<color name="accent">#FF6D00</color>
- <!-- Instrument Specific -->
- <color name="instrument_text_normal">#1A1C1E</color>
- <color name="instrument_text_secondary">#44474E</color>
+ <!-- Instrument Specific (light-mode values; dark overrides in values-night/) -->
+ <color name="instrument_text_normal">#1C1B1F</color>
+ <color name="instrument_text_secondary">#6F6878</color>
<color name="instrument_text_alarm">#BA1A1A</color>
- <color name="instrument_text_stale">#74777F</color>
- <color name="instrument_background">#FDFBFF</color>
- <color name="instrument_card_background">#F1F4F9</color>
+ <color name="instrument_text_stale">#9A94A0</color>
+ <color name="instrument_background">#FFFBFE</color>
+ <color name="instrument_card_background">#F2EDF7</color>
<color name="mob_button_background">#BA1A1A</color>
<color name="anchor_button_background">#005FB0</color>
@@ -48,6 +48,14 @@
<color name="wind_medium">#FF9800</color>
<color name="wind_strong">#F44336</color>
+ <!-- WaveView — day: sunny sky + tropical deep-sea blue -->
+ <color name="wave_sky_top">#2B8FC4</color>
+ <color name="wave_sky_bottom">#87C8DF</color>
+ <color name="wave_sea_top">#0D7A9A</color>
+ <color name="wave_sea_bottom">#074B68</color>
+ <color name="wave_shimmer">#50FFFFFF</color>
+ <color name="wave_whitecap">#80FFFFFF</color>
+
<!-- Night Vision Mode (Stays Red) -->
<color name="night_red_primary">#FFFF0000</color>
<color name="night_red_variant">#FFBB0000</color>
diff --git a/android-app/app/src/main/res/values/dimens.xml b/android-app/app/src/main/res/values/dimens.xml
index 1b65ea9..21ff47a 100755
--- a/android-app/app/src/main/res/values/dimens.xml
+++ b/android-app/app/src/main/res/values/dimens.xml
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <!-- Font sizes for instrument display -->
- <dimen name="text_size_instrument_primary">24sp</dimen>
- <dimen name="text_size_instrument_secondary">18sp</dimen>
- <dimen name="text_size_instrument_label">14sp</dimen>
+ <dimen name="text_size_instrument_primary">26sp</dimen>
+ <dimen name="text_size_instrument_label">10sp</dimen>
+ <dimen name="text_size_instrument_unit">10sp</dimen>
+ <dimen name="text_size_forecast_value">22sp</dimen>
+ <dimen name="text_size_forecast_label">10sp</dimen>
+ <dimen name="text_size_forecast_bearing">12sp</dimen>
<dimen name="instrument_margin">8dp</dimen>
<dimen name="instrument_padding">4dp</dimen>
</resources>
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
index 499ba8d..756f5e3 100755
--- a/android-app/app/src/main/res/values/strings.xml
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -58,4 +58,14 @@
<string name="temp_fmt">%.0f °C</string>
<string name="precip_fmt">%d%%</string>
<string name="permission_rationale">Location is needed to show weather for your current position.</string>
+ <string name="nav_anchor_watch">Anchor</string>
+ <string name="anchor_watch_title">Anchor Watch</string>
+ <string name="anchor_depth_label">Depth (m)</string>
+ <string name="anchor_depth_hint">e.g. 5.0</string>
+ <string name="anchor_rode_label">Rode Out (m)</string>
+ <string name="anchor_rode_hint">e.g. 30.0</string>
+ <string name="anchor_suggested_radius_label">Suggested Watch Radius</string>
+ <string name="anchor_suggested_radius_empty">—</string>
+ <string name="anchor_suggested_radius_fmt">%.1f m</string>
+ <string name="anchor_suggested_radius_hint">Calculated from rode and depth using Pythagorean scope formula (2 m freeboard assumed)</string>
</resources>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
index b18f4a8..93f5209 100755
--- a/android-app/app/src/main/res/values/themes.xml
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -24,7 +24,7 @@
<item name="colorOnErrorContainer">@color/md_theme_onErrorContainer</item>
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorSurface</item>
- <item name="android:windowLightStatusBar">true</item>
+ <item name="android:windowLightStatusBar">false</item>
</style>
<!-- Night Vision Theme (Stays Dark and Red) -->
@@ -45,8 +45,9 @@
<item name="android:textColor">@color/instrument_text_secondary</item>
<item name="android:textSize">@dimen/text_size_instrument_label</item>
<item name="android:textAllCaps">true</item>
- <item name="android:letterSpacing">0.1</item>
- <item name="android:textStyle">bold</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
</style>
<style name="InstrumentPrimaryValue" parent="android:Widget.TextView">
@@ -54,15 +55,79 @@
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">@color/instrument_text_normal</item>
<item name="android:textSize">@dimen/text_size_instrument_primary</item>
- <item name="android:textStyle">bold</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
<item name="android:includeFontPadding">false</item>
</style>
- <style name="InstrumentSecondaryLabel" parent="android:Widget.TextView">
- <item name="android:textColor">@color/instrument_text_secondary</item>
- <item name="android:textSize">@dimen/text_size_instrument_secondary</item>
+ <style name="InstrumentUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">?attr/colorOnSurfaceVariant</item>
+ <item name="android:textSize">@dimen/text_size_instrument_unit</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">3dp</item>
</style>
-
+
+ <style name="ForecastLabel" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#5B9EC2</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:letterSpacing">0.12</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ </style>
+
+ <style name="ForecastValue" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#CAE8FF</item>
+ <item name="android:textSize">@dimen/text_size_forecast_value</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:fontFamily">sans-serif-light</item>
+ <item name="android:includeFontPadding">false</item>
+ </style>
+
+ <style name="ForecastUnit" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#3B6785</item>
+ <item name="android:textSize">@dimen/text_size_forecast_label</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">2dp</item>
+ <item name="android:layout_marginBottom">2dp</item>
+ </style>
+
+ <style name="ForecastBearing" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#6FC3E8</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+ </style>
+
+ <style name="ForecastPeriod" parent="android:Widget.TextView">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:textColor">#4A7FA0</item>
+ <item name="android:textSize">@dimen/text_size_forecast_bearing</item>
+ <item name="android:textStyle">normal</item>
+ <item name="android:layout_marginStart">4dp</item>
+ </style>
+
+ <!-- Bottom sheet top-only rounded corners -->
+ <style name="ShapeAppearance.Nav.BottomSheet" parent="ShapeAppearance.Material3.Corner.None">
+ <item name="cornerFamily">rounded</item>
+ <item name="cornerSizeTopLeft">24dp</item>
+ <item name="cornerSizeTopRight">24dp</item>
+ <item name="cornerSizeBottomLeft">0dp</item>
+ <item name="cornerSizeBottomRight">0dp</item>
+ </style>
+
<style name="InstrumentCard" parent="Widget.Material3.CardView.Elevated">
<item name="cardBackgroundColor">@color/instrument_card_background</item>
<item name="cardCornerRadius">12dp</item>
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
new file mode 100644
index 0000000..535e46a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
@@ -0,0 +1,91 @@
+package com.example.androidapp.data.weather
+
+import com.example.androidapp.data.model.GribFile
+import com.example.androidapp.data.model.GribRegion
+import com.example.androidapp.data.storage.InMemoryGribFileManager
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import java.time.Instant
+
+class GribStalenessCheckerTest {
+
+ private lateinit var manager: InMemoryGribFileManager
+ private lateinit var checker: GribStalenessChecker
+ private val region = GribRegion("test", 35.0, 40.0, -125.0, -120.0)
+
+ @Before
+ fun setUp() {
+ manager = InMemoryGribFileManager()
+ checker = GribStalenessChecker(manager)
+ }
+
+ private fun makeFile(
+ modelRunTime: Instant,
+ forecastHours: Int,
+ downloadedAt: Instant = modelRunTime
+ ) = GribFile(
+ region = region,
+ modelRunTime = modelRunTime,
+ forecastHours = forecastHours,
+ downloadedAt = downloadedAt,
+ filePath = "/tmp/test.grib",
+ sizeBytes = 1024L
+ )
+
+ @Test
+ fun `check_returnsFresh_whenFileIsNotStale`() {
+ val now = Instant.parse("2026-03-16T12:00:00Z")
+ // model run at 06:00, 24h forecast → valid until 06:00 next day, well beyond now
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 24,
+ downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(region, now)
+
+ assertTrue("Expected Fresh but got $result", result is FreshnessResult.Fresh)
+ }
+
+ @Test
+ fun `check_returnsStale_whenFileIsExpired`() {
+ val now = Instant.parse("2026-03-16T20:00:00Z")
+ // model run at 06:00, 6h forecast → valid until 12:00; now is 8h after that
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 6,
+ downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(region, now)
+
+ assertTrue("Expected Stale but got $result", result is FreshnessResult.Stale)
+ val stale = result as FreshnessResult.Stale
+ assertTrue("Message should contain hours outdated", stale.message.contains("8h"))
+ assertEquals(file, stale.file)
+ }
+
+ @Test
+ fun `check_returnsNoData_whenNoFilesForRegion`() {
+ val otherRegion = GribRegion("other", 50.0, 55.0, -10.0, 0.0)
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 24
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(otherRegion, Instant.parse("2026-03-16T12:00:00Z"))
+
+ assertEquals(FreshnessResult.NoData, result)
+ }
+
+ @Test
+ fun `check_returnsNoData_whenManagerEmpty`() {
+ val result = checker.check(region, Instant.now())
+
+ assertEquals(FreshnessResult.NoData, result)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
new file mode 100644
index 0000000..4bf7985
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
@@ -0,0 +1,180 @@
+package com.example.androidapp.data.weather
+
+import com.example.androidapp.data.model.GribParameter
+import com.example.androidapp.data.model.GribRegion
+import com.example.androidapp.data.model.SatelliteDownloadRequest
+import com.example.androidapp.data.storage.InMemoryGribFileManager
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import java.time.Instant
+
+class SatelliteGribDownloaderTest {
+
+ private lateinit var manager: InMemoryGribFileManager
+ private lateinit var downloader: SatelliteGribDownloader
+
+ // 10°×10° region at 1°: 11×11 = 121 grid points
+ private val region10x10 = GribRegion("atlantic", 30.0, 40.0, -70.0, -60.0)
+
+ @Before
+ fun setUp() {
+ manager = InMemoryGribFileManager()
+ downloader = SatelliteGribDownloader(manager)
+ }
+
+ // ------------------------------------------------------------------ size estimation
+
+ @Test
+ fun `estimateSizeBytes_scalesWithRegionArea`() {
+ // 10°×10° region: 11×11 = 121 grid points
+ val req10 = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+ // 20°×20° region: 21×21 = 441 grid points — roughly 3.6× more grid points
+ val region20x20 = GribRegion("bigger", 20.0, 40.0, -80.0, -60.0)
+ val req20 = SatelliteDownloadRequest(
+ region = region20x20,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+
+ val size10 = downloader.estimateSizeBytes(req10)
+ val size20 = downloader.estimateSizeBytes(req20)
+
+ assertTrue("Larger region must produce larger estimate", size20 > size10)
+ }
+
+ @Test
+ fun `estimateSizeBytes_scalesWithParameterCount`() {
+ val minimalReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL, // 3 params
+ forecastHours = 24
+ )
+ val fullReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.values().toSet(), // all 7 params
+ forecastHours = 24
+ )
+
+ val sizeMinimal = downloader.estimateSizeBytes(minimalReq)
+ val sizeFull = downloader.estimateSizeBytes(fullReq)
+
+ assertTrue("More parameters must produce larger estimate", sizeFull > sizeMinimal)
+ }
+
+ @Test
+ fun `estimateSizeBytes_coarserResolutionProducesSmallerFile`() {
+ val finReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24,
+ resolutionDeg = 1.0
+ )
+ val coarseReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24,
+ resolutionDeg = 2.0
+ )
+
+ val sizeFine = downloader.estimateSizeBytes(finReq)
+ val sizeCoarse = downloader.estimateSizeBytes(coarseReq)
+
+ assertTrue("Coarser resolution must produce smaller estimate", sizeCoarse < sizeFine)
+ }
+
+ @Test
+ fun `estimatedDownloadSeconds_atIridiumBandwidth`() {
+ // 10°×10°, 3 params, 24h at 1° → known estimate
+ val req = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+ val estBytes = downloader.estimateSizeBytes(req)
+ val expectedSeconds = Math.ceil(estBytes * 8.0 / SatelliteGribDownloader.SATELLITE_BANDWIDTH_BPS).toLong()
+
+ val actualSeconds = downloader.estimatedDownloadSeconds(req)
+
+ assertEquals(expectedSeconds, actualSeconds)
+ // Sanity: should be > 0 seconds and less than 10 minutes for a small region
+ assertTrue("Download estimate must be positive", actualSeconds > 0)
+ assertTrue("Small 10°×10° should complete in under 10 min at 2.4kbps", actualSeconds < 600)
+ }
+
+ // ------------------------------------------------------------------ buildMinimalRequest
+
+ @Test
+ fun `buildMinimalRequest_containsOnlyWindAndPressure`() {
+ val req = downloader.buildMinimalRequest(region10x10, 48)
+
+ assertEquals(GribParameter.SATELLITE_MINIMAL, req.parameters)
+ assertTrue(req.parameters.contains(GribParameter.WIND_SPEED))
+ assertTrue(req.parameters.contains(GribParameter.WIND_DIRECTION))
+ assertTrue(req.parameters.contains(GribParameter.SURFACE_PRESSURE))
+ assertFalse(req.parameters.contains(GribParameter.TEMPERATURE_2M))
+ assertFalse(req.parameters.contains(GribParameter.PRECIPITATION))
+ assertEquals(region10x10, req.region)
+ assertEquals(48, req.forecastHours)
+ }
+
+ // ------------------------------------------------------------------ download()
+
+ @Test
+ fun `download_abortsWhenEstimatedSizeExceedsLimit`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+ var fetcherCalled = false
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { fetcherCalled = true; ByteArray(100) },
+ outputPath = "/tmp/test.grib",
+ sizeLimitBytes = 1L // ridiculously small limit
+ )
+
+ assertTrue("Should abort without calling fetcher", result is SatelliteGribDownloader.DownloadResult.Aborted)
+ assertFalse("Fetcher must not be called when aborting", fetcherCalled)
+ val aborted = result as SatelliteGribDownloader.DownloadResult.Aborted
+ assertTrue("Should report estimated bytes", aborted.estimatedBytes > 0)
+ }
+
+ @Test
+ fun `download_returnsFailedWhenFetcherReturnsNull`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { null },
+ outputPath = "/tmp/test.grib"
+ )
+
+ assertTrue("Should fail when fetcher returns null", result is SatelliteGribDownloader.DownloadResult.Failed)
+ }
+
+ @Test
+ fun `download_savesMetadataAndReturnsSuccessOnValidFetch`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+ val fakeBytes = ByteArray(8208) { 0x00 }
+ val now = Instant.parse("2026-03-16T12:00:00Z")
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { fakeBytes },
+ outputPath = "/tmp/atlantic.grib",
+ now = now
+ )
+
+ assertTrue("Should succeed", result is SatelliteGribDownloader.DownloadResult.Success)
+ val success = result as SatelliteGribDownloader.DownloadResult.Success
+ assertEquals(region10x10, success.file.region)
+ assertEquals(24, success.file.forecastHours)
+ assertEquals(fakeBytes.size.toLong(), success.file.sizeBytes)
+ assertEquals("/tmp/atlantic.grib", success.file.filePath)
+ // Metadata must be persisted in the manager
+ assertNotNull(manager.latestFile(region10x10))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
new file mode 100644
index 0000000..8b2753c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
@@ -0,0 +1,33 @@
+package com.example.androidapp.gps
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GpsPositionTest {
+
+ @Test
+ fun `GpsPosition holds correct values`() {
+ val pos = GpsPosition(
+ latitude = 41.5,
+ longitude = -71.0,
+ sog = 5.2,
+ cog = 180.0,
+ timestampMs = 1_000L
+ )
+ assertEquals(41.5, pos.latitude, 0.0)
+ assertEquals(-71.0, pos.longitude, 0.0)
+ assertEquals(5.2, pos.sog, 0.0)
+ assertEquals(180.0, pos.cog, 0.0)
+ assertEquals(1_000L, pos.timestampMs)
+ }
+
+ @Test
+ fun `GpsPosition equality works as expected for data class`() {
+ val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L)
+
+ assertEquals(pos1, pos2)
+ assertNotEquals(pos1, pos3)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
new file mode 100644
index 0000000..4eb9898
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
@@ -0,0 +1,317 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocationServiceTest {
+
+ private fun service() = LocationService()
+
+ // ── snapshot with no data ─────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_noData_allFieldsNull() {
+ val snap = service().snapshot()
+ assertNull(snap.windSpeedKt)
+ assertNull(snap.windDirectionDeg)
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── true-wind resolution ──────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking {
+ val svc = service()
+ // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt
+ // → TW comes FROM ahead at 5 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 0.0,
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 0.0,
+ speedOverGroundKt = 5.0
+ )
+ )
+ val tw = svc.latestTrueWind.first()
+ assertNotNull(tw)
+ assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0)
+ }
+
+ @Test
+ fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking {
+ val svc = service()
+ svc.updateSensorData(
+ SensorData(
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 5.0
+ // headingTrueDeg omitted
+ )
+ )
+ assertNull(svc.latestTrueWind.first())
+ }
+
+ // ── current conditions ────────────────────────────────────────────────────
+
+ @Test
+ fun updateCurrentConditions_reflectedInSnapshot() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0)
+
+ val snap = svc.snapshot()
+ assertEquals(1.5, snap.currentSpeedKt!!, 0.001)
+ assertEquals(135.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ @Test
+ fun updateCurrentConditions_nullClears() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0)
+ svc.updateCurrentConditions(speedKt = null, directionDeg = null)
+
+ val snap = svc.snapshot()
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── combined snapshot ─────────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking {
+ val svc = service()
+
+ // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 90.0,
+ apparentWindSpeedKt = 12.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 6.0
+ )
+ )
+ svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0)
+
+ val snap = svc.snapshot()
+ assertNotNull(snap.windSpeedKt)
+ assertNotNull(snap.windDirectionDeg)
+ assertEquals(0.8, snap.currentSpeedKt!!, 0.001)
+ assertEquals(270.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ // ── latestSensor flow ─────────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_updatesLatestSensorFlow() = runBlocking {
+ val svc = service()
+ assertNull(svc.latestSensor.first())
+
+ val data = SensorData(latitude = 41.5, longitude = -71.3)
+ svc.updateSensorData(data)
+
+ assertEquals(data, svc.latestSensor.first())
+ }
+
+ // ── GPS sensor fusion ─────────────────────────────────────────────────────
+
+ private fun fusionService(
+ nmeaStalenessThresholdMs: Long = 5_000L,
+ nmeaExtendedThresholdMs: Long = 10_000L,
+ clockMs: () -> Long = System::currentTimeMillis
+ ) = LocationService(
+ nmeaStalenessThresholdMs = nmeaStalenessThresholdMs,
+ nmeaExtendedThresholdMs = nmeaExtendedThresholdMs,
+ clockMs = clockMs
+ )
+
+ private fun pos(lat: Double, lon: Double, timestampMs: Long) =
+ GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs)
+
+ private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) =
+ GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters)
+
+ @Test
+ fun noGpsData_bestPositionNullAndSourceNone() = runBlocking {
+ val svc = fusionService()
+ assertNull(svc.bestPosition.first())
+ assertEquals(GpsSource.NONE, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun freshNmea_preferredOverAndroid() = runBlocking {
+ val now = 10_000L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, now)
+ val androidFix = pos(42.0, -72.0, now - 1_000L)
+
+ svc.updateAndroidGps(androidFix)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun staleNmea_androidFallback() = runBlocking {
+ val nmeaTime = 0L
+ val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s)
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, nmeaTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking {
+ val now = 60_000L // 60 s after fix — very stale
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, 0L)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyAndroidAvailable_isUsed() = runBlocking {
+ val svc = fusionService()
+ val androidFix = pos(42.0, -72.0, System.currentTimeMillis())
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking {
+ val fixTime = 0L
+ val now = 5_000L // exactly at threshold
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, fixTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ }
+
+ // ── fix-quality (accuracy) tie-breaking ──────────────────────────────────
+
+ @Test
+ fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking {
+ // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking {
+ // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking {
+ // Neither source has accuracy metadata — conservative: prefer Android.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = pos(41.0, -71.0, nmeaTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking {
+ // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy.
+ val nmeaTime = 0L
+ val now = 15_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun nmeaRecovery_switchesBackFromAndroid() = runBlocking {
+ var now = 0L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ // Fresh NMEA
+ svc.updateNmeaGps(pos(41.0, -71.0, 0L))
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+
+ // NMEA goes stale; Android takes over
+ now = 10_000L
+ val androidFix = pos(42.0, -72.0, 10_000L)
+ svc.updateAndroidGps(androidFix)
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value)
+
+ // NMEA recovers with a fresh fix
+ val freshNmea = pos(41.1, -71.1, 10_000L)
+ svc.updateNmeaGps(freshNmea)
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+ assertEquals(freshNmea, svc.bestPosition.value)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
new file mode 100644
index 0000000..30b421f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.logbook
+
+import com.example.androidapp.data.model.LogbookEntry
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookFormatterTest {
+
+ // 2021-06-15 08:00:00 UTC = 1623744000000 ms
+ private val t0 = 1_623_744_000_000L
+
+ private fun entry(
+ ts: Long = t0,
+ lat: Double = 41.39,
+ lon: Double = -71.202,
+ sog: Double = 6.2,
+ cog: Double = 225.0,
+ windKt: Double? = 15.0,
+ windDir: Double? = 225.0,
+ baro: Double? = 1018.0,
+ depth: Double? = 14.0,
+ event: String? = "Departed slip",
+ notes: String? = null
+ ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes)
+
+ // --- formatTime ---
+
+ @Test
+ fun `formatTime returns HH_MM for UTC midnight`() {
+ // 2021-06-15 00:00:00 UTC
+ val ts = 1_623_715_200_000L
+ assertEquals("00:00", LogbookFormatter.formatTime(ts))
+ }
+
+ @Test
+ fun `formatTime returns correct UTC hour for known timestamp`() {
+ // t0 = 2021-06-15 08:00:00 UTC
+ assertEquals("08:00", LogbookFormatter.formatTime(t0))
+ }
+
+ @Test
+ fun `formatTime pads single-digit hour and minute`() {
+ // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000
+ val ts = 1_623_715_200_000L + 65 * 60_000L
+ assertEquals("01:05", LogbookFormatter.formatTime(ts))
+ }
+
+ // --- formatPosition ---
+
+ @Test
+ fun `formatPosition north east`() {
+ // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E
+ val result = LogbookFormatter.formatPosition(41.39, 71.202)
+ assertEquals("41°23.4N 71°12.1E", result)
+ }
+
+ @Test
+ fun `formatPosition south west`() {
+ // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W
+ val result = LogbookFormatter.formatPosition(-41.39, -71.202)
+ assertEquals("41°23.4S 71°12.1W", result)
+ }
+
+ @Test
+ fun `formatPosition zero zero`() {
+ val result = LogbookFormatter.formatPosition(0.0, 0.0)
+ assertEquals("0°0.0N 0°0.0E", result)
+ }
+
+ // --- formatWind ---
+
+ @Test
+ fun `formatWind null knots returns empty string`() {
+ assertEquals("", LogbookFormatter.formatWind(null, null))
+ }
+
+ @Test
+ fun `formatWind with knots and null direction returns knots only`() {
+ assertEquals("15kt", LogbookFormatter.formatWind(15.0, null))
+ }
+
+ @Test
+ fun `formatWind 225 degrees is SW`() {
+ assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0))
+ }
+
+ @Test
+ fun `formatWind 0 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0))
+ }
+
+ @Test
+ fun `formatWind 360 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0))
+ }
+
+ @Test
+ fun `formatWind 90 degrees is E`() {
+ assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0))
+ }
+
+ // --- toCompassPoint ---
+
+ @Test
+ fun `toCompassPoint covers all 16 cardinal and intercardinal points`() {
+ val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
+ expected.forEachIndexed { i, dir ->
+ val degrees = i * 22.5
+ assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees))
+ }
+ }
+
+ // --- toRow ---
+
+ @Test
+ fun `toRow formats all fields correctly`() {
+ val row = LogbookFormatter.toRow(entry())
+ assertEquals("08:00", row.time)
+ assertEquals("41°23.4N 71°12.1W", row.position)
+ assertEquals("6.2", row.sog)
+ assertEquals("225", row.cog)
+ assertEquals("15kt SW", row.wind)
+ assertEquals("1018", row.baro)
+ assertEquals("14m", row.depth)
+ assertEquals("Departed slip", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow combines event and notes with colon`() {
+ val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts"))
+ assertEquals("Reef #1: Strong gusts", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with only notes has no colon prefix`() {
+ val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas"))
+ assertEquals("Calm seas", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with null optional fields uses empty strings`() {
+ val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0)
+ val row = LogbookFormatter.toRow(e)
+ assertEquals("", row.wind)
+ assertEquals("", row.baro)
+ assertEquals("", row.depth)
+ assertEquals("", row.eventNotes)
+ }
+
+ // --- toPage ---
+
+ @Test
+ fun `toPage returns page with default title and correct column count`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertEquals("Trip Logbook", page.title)
+ assertEquals(8, page.columns.size)
+ }
+
+ @Test
+ fun `toPage maps entries to rows in order`() {
+ val entries = listOf(
+ entry(ts = t0, event = "First"),
+ entry(ts = t0 + 3_600_000L, event = "Second")
+ )
+ val page = LogbookFormatter.toPage(entries, "Voyage Log")
+ assertEquals("Voyage Log", page.title)
+ assertEquals(2, page.rows.size)
+ assertEquals("First", page.rows[0].eventNotes)
+ assertEquals("Second", page.rows[1].eventNotes)
+ }
+
+ @Test
+ fun `toPage empty entries produces empty rows`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertTrue(page.rows.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
new file mode 100644
index 0000000..b8a878a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
@@ -0,0 +1,105 @@
+package com.example.androidapp.nmea
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class NmeaParserTest {
+
+ private lateinit var parser: NmeaParser
+
+ @Before
+ fun setUp() {
+ parser = NmeaParser()
+ }
+
+ // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
+ // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E
+ // SOG 22.4 kn, COG 84.4°
+
+ @Test
+ fun `valid RMC sentence parses latitude and longitude`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ assertEquals(11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `valid RMC sentence parses SOG and COG`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(22.4, pos!!.sog, 0.001)
+ assertEquals(84.4, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `void status V returns null`() {
+ val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `malformed sentence with too few fields returns null`() {
+ assertNull(parser.parseRmc("\$GPRMC,123519,A"))
+ }
+
+ @Test
+ fun `empty string returns null`() {
+ assertNull(parser.parseRmc(""))
+ }
+
+ @Test
+ fun `non-RMC sentence returns null`() {
+ val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `south latitude is negative`() {
+ // lat: -(42 + 50.5589/60) = -42.84265
+ val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("South latitude must be negative", pos!!.latitude < 0)
+ assertEquals(-42.84265, pos.latitude, 0.0001)
+ }
+
+ @Test
+ fun `west longitude is negative`() {
+ // lon: -(11 + 31.000/60) = -11.51667
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("West longitude must be negative", pos!!.longitude < 0)
+ assertEquals(-11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `SOG and COG parse with decimal precision`() {
+ val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(12.345, pos!!.sog, 0.0001)
+ assertEquals(270.5, pos.cog, 0.0001)
+ }
+
+ @Test
+ fun `empty SOG and COG fields default to zero`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(0.0, pos!!.sog, 0.001)
+ assertEquals(0.0, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `GNRMC talker ID is also accepted`() {
+ val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
new file mode 100644
index 0000000..e5615e9
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
@@ -0,0 +1,169 @@
+package com.example.androidapp.routing
+
+import com.example.androidapp.data.model.BoatPolars
+import com.example.androidapp.data.model.WindForecast
+import org.junit.Assert.*
+import org.junit.Test
+
+class IsochroneRouterTest {
+
+ private val startTimeMs = 1_000_000_000L
+ private val oneHourMs = 3_600_000L
+
+ // ── BoatPolars ────────────────────────────────────────────────────────────
+
+ @Test
+ fun `bsp returns exact value for exact twa and tws entry`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWS=10, TWA=90 the table has 7.0 kt
+ assertEquals(7.0, polars.bsp(90.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp interpolates between twa entries`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWS=10: TWA=60 → 6.5, TWA=90 → 7.0; midpoint TWA=75 → 6.75
+ assertEquals(6.75, polars.bsp(75.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp interpolates between tws entries`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWA=90: TWS=10 → 7.0, TWS=15 → 8.0; midpoint TWS=12.5 → 7.5
+ assertEquals(7.5, polars.bsp(90.0, 12.5), 1e-9)
+ }
+
+ @Test
+ fun `bsp mirrors port tack twa to starboard`() {
+ val polars = BoatPolars.DEFAULT
+ // TWA=270 should mirror to 360-270=90, giving same as TWA=90
+ assertEquals(polars.bsp(90.0, 10.0), polars.bsp(270.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp clamps tws below table minimum`() {
+ val polars = BoatPolars.DEFAULT
+ // TWS=0 clamps to minimum TWS=5
+ assertEquals(polars.bsp(90.0, 5.0), polars.bsp(90.0, 0.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp clamps tws above table maximum`() {
+ val polars = BoatPolars.DEFAULT
+ // TWS=100 clamps to maximum TWS=20
+ assertEquals(polars.bsp(90.0, 20.0), polars.bsp(90.0, 100.0), 1e-9)
+ }
+
+ // ── IsochroneRouter geometry helpers ─────────────────────────────────────
+
+ @Test
+ fun `haversineM returns zero for same point`() {
+ assertEquals(0.0, IsochroneRouter.haversineM(10.0, 20.0, 10.0, 20.0), 1e-3)
+ }
+
+ @Test
+ fun `haversineM one degree of latitude is approximately 111_195 m`() {
+ val dist = IsochroneRouter.haversineM(0.0, 0.0, 1.0, 0.0)
+ assertEquals(111_195.0, dist, 50.0)
+ }
+
+ @Test
+ fun `bearingDeg returns 0 for due north`() {
+ val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 1.0, 0.0)
+ assertEquals(0.0, bearing, 1e-6)
+ }
+
+ @Test
+ fun `bearingDeg returns 90 for due east`() {
+ val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 0.0, 1.0)
+ assertEquals(90.0, bearing, 1e-4)
+ }
+
+ @Test
+ fun `destinationPoint due north by 1 NM moves latitude by expected amount`() {
+ val (lat, lon) = IsochroneRouter.destinationPoint(0.0, 0.0, 0.0, IsochroneRouter.NM_TO_M)
+ assertTrue("latitude should increase", lat > 0.0)
+ assertEquals(0.0, lon, 1e-9)
+ // 1 NM ≈ 1/60 degree of latitude
+ assertEquals(1.0 / 60.0, lat, 1e-4)
+ }
+
+ // ── Pruning ───────────────────────────────────────────────────────────────
+
+ @Test
+ fun `prune keeps only furthest point per sector`() {
+ // Two points both due north of origin at different distances
+ val close = RoutePoint(1.0, 0.0, startTimeMs)
+ val far = RoutePoint(2.0, 0.0, startTimeMs)
+ val result = IsochroneRouter.prune(listOf(close, far), 0.0, 0.0, 72)
+ assertEquals(1, result.size)
+ assertEquals(far, result[0])
+ }
+
+ @Test
+ fun `prune keeps points in different sectors separately`() {
+ // One point north, one point east — different sectors
+ val north = RoutePoint(1.0, 0.0, startTimeMs)
+ val east = RoutePoint(0.0, 1.0, startTimeMs)
+ val result = IsochroneRouter.prune(listOf(north, east), 0.0, 0.0, 72)
+ assertEquals(2, result.size)
+ }
+
+ // ── Full routing ──────────────────────────────────────────────────────────
+
+ @Test
+ fun `route finds path to destination with constant wind`() {
+ // Destination is ~5 NM due east of start; constant 10kt easterly (FROM east = 90°)
+ // A 10kt boat sailing downwind (TWA=180) = 6.0 kt; ~5 NM / 6 kt ≈ 50 min → 1 step
+ val destLat = 0.0
+ val destLon = 0.0 + (5.0 / 60.0) // ~5 NM east
+ val constantWind = { _: Double, _: Double, _: Long ->
+ WindForecast(0.0, 0.0, startTimeMs, twsKt = 10.0, twdDeg = 90.0)
+ }
+ val result = IsochroneRouter.route(
+ startLat = 0.0,
+ startLon = 0.0,
+ destLat = destLat,
+ destLon = destLon,
+ startTimeMs = startTimeMs,
+ stepMs = oneHourMs,
+ polars = BoatPolars.DEFAULT,
+ windAt = constantWind,
+ arrivalRadiusM = 2_000.0 // 2 km arrival radius
+ )
+ assertNotNull("Should find a route", result)
+ result!!
+ assertTrue("Path should have at least 2 points (start + arrival)", result.path.size >= 2)
+ assertEquals("Path should start at origin", 0.0, result.path.first().lat, 1e-6)
+ assertEquals("ETA should be after start", startTimeMs, result.etaMs - oneHourMs)
+ }
+
+ @Test
+ fun `route returns null when polars produce zero speed`() {
+ val zeroPolar = BoatPolars(emptyMap())
+ val result = IsochroneRouter.route(
+ startLat = 0.0,
+ startLon = 0.0,
+ destLat = 1.0,
+ destLon = 0.0,
+ startTimeMs = startTimeMs,
+ stepMs = oneHourMs,
+ polars = zeroPolar,
+ windAt = { _, _, _ -> WindForecast(0.0, 0.0, startTimeMs, 10.0, 0.0) },
+ maxSteps = 3
+ )
+ assertNull("Should return null when no progress is possible", result)
+ }
+
+ @Test
+ fun `backtrace returns path from start to arrival in order`() {
+ val p0 = RoutePoint(0.0, 0.0, 0L)
+ val p1 = RoutePoint(1.0, 0.0, 1L, parent = p0)
+ val p2 = RoutePoint(2.0, 0.0, 2L, parent = p1)
+ val path = IsochroneRouter.backtrace(p2)
+ assertEquals(3, path.size)
+ assertEquals(p0, path[0])
+ assertEquals(p1, path[1])
+ assertEquals(p2, path[2])
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
new file mode 100644
index 0000000..40f7df0
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
@@ -0,0 +1,32 @@
+package com.example.androidapp.safety
+
+import org.junit.Assert.*
+import org.junit.Test
+import kotlin.math.sqrt
+
+class AnchorWatchStateTest {
+
+ private val state = AnchorWatchState()
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_validGeometry() {
+ // depth=6m, rode=50m → vertical=8m, radius=sqrt(50²-8²)=sqrt(2436)
+ val expected = sqrt(2436.0)
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 6.0, rodeOutM = 50.0)
+ assertEquals(expected, actual, 0.001)
+ }
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_rodeShorterThanVertical_fallsBackToRode() {
+ // depth=10m, rode=5m → vertical=12m > rode, fallback returns rode
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 10.0, rodeOutM = 5.0)
+ assertEquals(5.0, actual, 0.001)
+ }
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_rodeEqualsVertical_fallsBackToRode() {
+ // depth=8m, rode=10m → vertical=10m == rode, fallback returns rode
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 8.0, rodeOutM = 10.0)
+ assertEquals(10.0, actual, 0.001)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
new file mode 100644
index 0000000..612ae34
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
@@ -0,0 +1,135 @@
+package com.example.androidapp.tide
+
+import com.example.androidapp.data.model.TideConstituent
+import com.example.androidapp.data.model.TideStation
+import org.junit.Assert.*
+import org.junit.Test
+
+class HarmonicTideCalculatorTest {
+
+ // Reference epoch: 2000-01-01 00:00:00 UTC = 946_684_800_000 ms
+ private val epochMs = HarmonicTideCalculator.EPOCH_MS
+ private val oneHourMs = 3_600_000L
+
+ private fun stationWith(
+ speed: Double = 30.0,
+ amplitude: Double = 1.0,
+ phase: Double = 0.0,
+ datum: Double = 0.0
+ ) = TideStation(
+ id = "test", name = "Test", lat = 0.0, lon = 0.0,
+ datumOffsetMeters = datum,
+ constituents = listOf(TideConstituent("S2", speed, amplitude, phase))
+ )
+
+ @Test
+ fun `predictHeight at epoch gives datum plus amplitude for zero-phase constituent`() {
+ val station = stationWith(speed = 30.0, amplitude = 1.5, phase = 0.0, datum = 0.5)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(0.5 + 1.5, height, 1e-9) // cos(0°) = 1.0
+ }
+
+ @Test
+ fun `predictHeight at half period gives datum minus amplitude`() {
+ // speed = 30 deg/hr → half period = 6 hours → cos(180°) = -1.0
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs + 6 * oneHourMs)
+ assertEquals(-1.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight at quarter period is near zero`() {
+ // speed = 30 deg/hr → quarter period = 3 hours → cos(90°) ≈ 0.0
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs + 3 * oneHourMs)
+ assertEquals(0.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight applies phase offset correctly`() {
+ // phase = 90 → cos(0 - 90°) = cos(-90°) ≈ 0.0 at epoch
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 90.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(0.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight sums multiple constituents at epoch`() {
+ val station = TideStation(
+ id = "test", name = "Test", lat = 0.0, lon = 0.0,
+ datumOffsetMeters = 2.0,
+ constituents = listOf(
+ TideConstituent("S2", 30.0, 1.0, 0.0), // +1.0 at epoch
+ TideConstituent("K1", 30.0, 0.5, 0.0) // +0.5 at epoch
+ )
+ )
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(3.5, height, 1e-9) // 2.0 + 1.0 + 0.5
+ }
+
+ @Test
+ fun `predictHeight with empty constituents returns datum offset only`() {
+ val station = TideStation("t", "T", 0.0, 0.0, 3.14, emptyList())
+ assertEquals(3.14, HarmonicTideCalculator.predictHeight(station, epochMs), 1e-9)
+ }
+
+ @Test
+ fun `predictRange returns correct number of predictions`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + 3 * oneHourMs, oneHourMs
+ )
+ assertEquals(4, predictions.size) // t=0h, 1h, 2h, 3h
+ }
+
+ @Test
+ fun `predictRange timestamps are evenly spaced`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + 2 * oneHourMs, oneHourMs
+ )
+ assertEquals(epochMs, predictions[0].timestampMs)
+ assertEquals(epochMs + oneHourMs, predictions[1].timestampMs)
+ assertEquals(epochMs + 2 * oneHourMs, predictions[2].timestampMs)
+ }
+
+ @Test
+ fun `predictRange with equal from and to returns single prediction`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(station, epochMs, epochMs, oneHourMs)
+ assertEquals(1, predictions.size)
+ assertEquals(epochMs, predictions[0].timestampMs)
+ }
+
+ @Test
+ fun `findHighLow returns empty list for fewer than 3 predictions`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + oneHourMs, oneHourMs
+ )
+ assertEquals(2, predictions.size)
+ assertTrue(HarmonicTideCalculator.findHighLow(predictions).isEmpty())
+ }
+
+ @Test
+ fun `findHighLow detects high and low water events`() {
+ // speed = 30 deg/hr, 3-hour samples over 24 hours
+ // Heights: 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0
+ // Turning points at t=6h(low), t=12h(high), t=18h(low)
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val predictions = HarmonicTideCalculator.predictRange(
+ station,
+ epochMs,
+ epochMs + 24 * oneHourMs,
+ 3 * oneHourMs
+ )
+ val highLow = HarmonicTideCalculator.findHighLow(predictions)
+ assertEquals(3, highLow.size)
+ assertEquals(epochMs + 6 * oneHourMs, highLow[0].timestampMs)
+ assertEquals(-1.0, highLow[0].heightMeters, 1e-9)
+ assertEquals(epochMs + 12 * oneHourMs, highLow[1].timestampMs)
+ assertEquals(1.0, highLow[1].heightMeters, 1e-9)
+ assertEquals(epochMs + 18 * oneHourMs, highLow[2].timestampMs)
+ assertEquals(-1.0, highLow[2].heightMeters, 1e-9)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
new file mode 100644
index 0000000..fc4580c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
@@ -0,0 +1,67 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookEntryTest {
+
+ @Test
+ fun `LogbookEntry holds all required fields`() {
+ val entry = LogbookEntry(
+ timestampMs = 1_000_000L,
+ lat = 41.39,
+ lon = -71.202,
+ sogKnots = 6.2,
+ cogDegrees = 225.0,
+ windKnots = 15.0,
+ windDirectionDeg = 225.0,
+ baroHpa = 1018.0,
+ depthMeters = 14.0,
+ event = "Departed slip",
+ notes = "Crew ready"
+ )
+ assertEquals(1_000_000L, entry.timestampMs)
+ assertEquals(41.39, entry.lat, 1e-9)
+ assertEquals(-71.202, entry.lon, 1e-9)
+ assertEquals(6.2, entry.sogKnots, 1e-9)
+ assertEquals(225.0, entry.cogDegrees, 1e-9)
+ assertEquals(15.0, entry.windKnots)
+ assertEquals(225.0, entry.windDirectionDeg)
+ assertEquals(1018.0, entry.baroHpa)
+ assertEquals(14.0, entry.depthMeters)
+ assertEquals("Departed slip", entry.event)
+ assertEquals("Crew ready", entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry optional fields default to null`() {
+ val entry = LogbookEntry(
+ timestampMs = 0L,
+ lat = 0.0,
+ lon = 0.0,
+ sogKnots = 0.0,
+ cogDegrees = 0.0
+ )
+ assertNull(entry.windKnots)
+ assertNull(entry.windDirectionDeg)
+ assertNull(entry.baroHpa)
+ assertNull(entry.depthMeters)
+ assertNull(entry.event)
+ assertNull(entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry data class equality`() {
+ val a = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ val b = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun `LogbookEntry data class copy`() {
+ val original = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0, event = "anchor")
+ val copy = original.copy(sogKnots = 3.0)
+ assertEquals(3.0, copy.sogKnots, 1e-9)
+ assertEquals("anchor", copy.event)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..0a6f4bb
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
@@ -0,0 +1,56 @@
+package com.example.androidapp.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class TideModelTest {
+
+ @Test
+ fun `TideConstituent holds all fields`() {
+ val c = TideConstituent("M2", 28.9841042, 0.85, 120.0)
+ assertEquals("M2", c.name)
+ assertEquals(28.9841042, c.speedDegPerHour, 1e-7)
+ assertEquals(0.85, c.amplitudeMeters, 1e-9)
+ assertEquals(120.0, c.phaseDeg, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction holds timestamp and height`() {
+ val p = TidePrediction(1_700_000_000_000L, 2.34)
+ assertEquals(1_700_000_000_000L, p.timestampMs)
+ assertEquals(2.34, p.heightMeters, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction data class equality`() {
+ val p1 = TidePrediction(1_000L, 1.5)
+ val p2 = TidePrediction(1_000L, 1.5)
+ assertEquals(p1, p2)
+ }
+
+ @Test
+ fun `TideStation holds all fields and constituents`() {
+ val c = TideConstituent("K1", 15.0410686, 0.3, 45.0)
+ val station = TideStation(
+ id = "9447130",
+ name = "Seattle, WA",
+ lat = 47.602,
+ lon = -122.339,
+ datumOffsetMeters = 1.8,
+ constituents = listOf(c)
+ )
+ assertEquals("9447130", station.id)
+ assertEquals("Seattle, WA", station.name)
+ assertEquals(47.602, station.lat, 1e-9)
+ assertEquals(-122.339, station.lon, 1e-9)
+ assertEquals(1.8, station.datumOffsetMeters, 1e-9)
+ assertEquals(1, station.constituents.size)
+ assertEquals("K1", station.constituents[0].name)
+ }
+
+ @Test
+ fun `TideStation with empty constituents is valid`() {
+ val station = TideStation("test", "Test", 0.0, 0.0, 0.0, emptyList())
+ assertTrue(station.constituents.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
index 749630f..c455085 100644
--- a/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
@@ -3,8 +3,7 @@ package org.terst.nav.data.repository
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.*
-import io.mockk.coEvery
-import io.mockk.mockk
+import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Before
@@ -36,6 +35,9 @@ class WeatherRepositoryTest {
time = listOf("2026-03-13T00:00", "2026-03-13T01:00"),
waveHeight = listOf(1.2, 1.1),
waveDirection = listOf(250.0, 255.0),
+ swellWaveHeight = emptyList(),
+ swellWaveDirection = emptyList(),
+ swellWavePeriod = emptyList(),
oceanCurrentVelocity = listOf(0.3, 0.4),
oceanCurrentDirection = listOf(180.0, 185.0)
)
@@ -48,7 +50,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems maps weather response to ForecastItem list`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -65,8 +67,25 @@ class WeatherRepositoryTest {
}
@Test
+ fun `fetchCurrentConditions maps responses to MarineConditions`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchCurrentConditions(37.5, -122.3)
+
+ if (result.isFailure) {
+ fail("fetchCurrentConditions failed with: ${result.exceptionOrNull()}")
+ }
+ val cond = result.getOrThrow()
+ assertEquals(15.0, cond.windSpeedKt!!, 0.001)
+ assertEquals(1.2, cond.waveHeightM!!, 0.001)
+ assertEquals(0.3 * 1.94384, cond.currentSpeedKt!!, 0.001)
+ assertEquals(180.0, cond.currentDirDeg!!, 0.001)
+ }
+
+ @Test
fun `fetchWindArrow returns WindArrow for first (current) hour`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)
@@ -81,7 +100,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems returns failure when weather API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Network error")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } throws RuntimeException("Network error")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -91,7 +110,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchWindArrow returns failure when API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Timeout")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } throws RuntimeException("Timeout")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt
new file mode 100644
index 0000000..7ed7ec7
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt
@@ -0,0 +1,83 @@
+package org.terst.nav.track
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GpxRoundTripTest {
+
+ private fun roundTrip(points: List<TrackPoint>): List<TrackPoint> {
+ val gpx = GpxSerializer.serialize(points, "Test Track")
+ return GpxParser.parse(gpx.byteInputStream())
+ }
+
+ @Test
+ fun `round-trip preserves lat and lon`() {
+ val pt = TrackPoint(lat = 37.8044, lon = -122.2712, sogKnots = 6.1, cogDeg = 247.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(37.8044, result.lat, 0.00001)
+ assertEquals(-122.2712, result.lon, 0.00001)
+ }
+
+ @Test
+ fun `round-trip preserves sog and cog`() {
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 5.3, cogDeg = 183.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(5.3, result.sogKnots, 0.001)
+ assertEquals(183.0, result.cogDeg, 0.001)
+ }
+
+ @Test
+ fun `round-trip preserves optional nav fields`() {
+ val pt = TrackPoint(
+ lat = 1.0, lon = 2.0, sogKnots = 4.0, cogDeg = 90.0,
+ depthMeters = 12.5, baroHpa = 1013.2, windSpeedKnots = 14.0,
+ windAngleDeg = 45.0, isTrueWind = true, waveHeightM = 1.2
+ )
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(12.5, result.depthMeters!!, 0.001)
+ assertEquals(1013.2, result.baroHpa!!, 0.001)
+ assertEquals(14.0, result.windSpeedKnots!!, 0.001)
+ assertEquals(45.0, result.windAngleDeg!!, 0.001)
+ assertTrue(result.isTrueWind)
+ assertEquals(1.2, result.waveHeightM!!, 0.001)
+ }
+
+ @Test
+ fun `round-trip with null optional fields leaves them null`() {
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0)
+ val result = roundTrip(listOf(pt)).first()
+ assertNull(result.depthMeters)
+ assertNull(result.baroHpa)
+ assertNull(result.windSpeedKnots)
+ }
+
+ @Test
+ fun `round-trip preserves multiple points in order`() {
+ val points = listOf(
+ TrackPoint(lat = 1.0, lon = 1.0, sogKnots = 1.0, cogDeg = 0.0, timestampMs = 1000L),
+ TrackPoint(lat = 2.0, lon = 2.0, sogKnots = 2.0, cogDeg = 90.0, timestampMs = 2000L),
+ TrackPoint(lat = 3.0, lon = 3.0, sogKnots = 3.0, cogDeg = 180.0, timestampMs = 3000L),
+ )
+ val result = roundTrip(points)
+ assertEquals(3, result.size)
+ assertEquals(1.0, result[0].lat, 0.00001)
+ assertEquals(2.0, result[1].lat, 0.00001)
+ assertEquals(3.0, result[2].lat, 0.00001)
+ }
+
+ @Test
+ fun `round-trip preserves timestamp`() {
+ val ts = 1712345678000L
+ val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0, timestampMs = ts)
+ val result = roundTrip(listOf(pt)).first()
+ assertEquals(ts, result.timestampMs)
+ }
+
+ @Test
+ fun `track name with special chars is escaped`() {
+ val gpx = GpxSerializer.serialize(emptyList(), "Track & \"Fun\" <test>")
+ assertTrue(gpx.contains("Track &amp; &quot;Fun&quot; &lt;test&gt;"))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt
new file mode 100644
index 0000000..2daaf45
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt
@@ -0,0 +1,56 @@
+package org.terst.nav.track
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TrackSummaryTest {
+
+ private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) =
+ TrackPoint(lat = lat, lon = lon, sogKnots = sog, cogDeg = 0.0, timestampMs = ts)
+
+ @Test
+ fun `distance between two points one nm apart`() {
+ // 1 nautical mile north along the prime meridian ≈ 0.01667° latitude
+ val a = pt(0.0, 0.0, ts = 0L)
+ val b = pt(0.016667, 0.0, ts = 60_000L)
+ val s = summarise(listOf(a, b))
+ assertEquals(1.0, s.distanceNm, 0.01)
+ }
+
+ @Test
+ fun `duration is last minus first timestamp`() {
+ val points = listOf(pt(0.0, 0.0, ts = 1_000L), pt(0.0, 0.0, ts = 61_000L))
+ assertEquals(60_000L, summarise(points).durationMs)
+ }
+
+ @Test
+ fun `max sog picks highest value`() {
+ val points = listOf(pt(0.0, 0.0, sog = 4.0), pt(0.0, 0.0, sog = 9.2), pt(0.0, 0.0, sog = 6.0))
+ assertEquals(9.2, summarise(points).maxSogKt, 0.001)
+ }
+
+ @Test
+ fun `avg wind is null when no wind data`() {
+ val s = summarise(listOf(pt(0.0, 0.0)))
+ assertNull(s.avgWindKt)
+ }
+
+ @Test
+ fun `avg wind averages available readings`() {
+ val points = listOf(
+ TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 10.0),
+ TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 20.0),
+ )
+ assertEquals(15.0, summarise(points).avgWindKt!!, 0.001)
+ }
+
+ @Test
+ fun `avg wave height averages available readings`() {
+ val points = listOf(
+ TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 1.0),
+ TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 3.0),
+ )
+ assertEquals(2.0, summarise(points).avgWaveHeightM!!, 0.001)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt
new file mode 100644
index 0000000..e26b67d
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/DirectionArrowViewTest.kt
@@ -0,0 +1,25 @@
+package org.terst.nav.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DirectionArrowViewTest {
+
+ @Test
+ fun `bearing is normalised — values over 360 wrap`() {
+ assertEquals(10f, normalizeBearing(370f), 0.001f)
+ }
+
+ @Test
+ fun `bearing is normalised — negative values wrap`() {
+ assertEquals(350f, normalizeBearing(-10f), 0.001f)
+ }
+
+ @Test
+ fun `bearing is normalised — exactly 360 becomes 0`() {
+ assertEquals(0f, normalizeBearing(360f), 0.001f)
+ }
+}
+
+// Top-level helper extracted from DirectionArrowView for testability
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt
new file mode 100644
index 0000000..a749ba2
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/InstrumentHandlerTest.kt
@@ -0,0 +1,39 @@
+package org.terst.nav.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.Locale
+
+class InstrumentHandlerTest {
+
+ @Test
+ fun `metresToFeet converts correctly`() {
+ assertEquals(3.28f, metresToFeet(1.0).toFloat(), 0.01f)
+ }
+
+ @Test
+ fun `metresToFeet zero returns zero`() {
+ assertEquals(0f, metresToFeet(0.0).toFloat(), 0.001f)
+ }
+
+ @Test
+ fun `formatFt formats to one decimal`() {
+ val result = formatFt(3.28084, Locale.US)
+ assertEquals("3.3", result)
+ }
+
+ @Test
+ fun `formatBearing appends degree symbol`() {
+ assertEquals("275°", formatBearing(275.0, Locale.US))
+ }
+
+ @Test
+ fun `formatBearing rounds to zero decimals`() {
+ assertEquals("123°", formatBearing(123.4, Locale.US))
+ }
+
+ @Test
+ fun `formatPeriod appends s`() {
+ assertEquals("· 14s", formatPeriod(14.0, Locale.US))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt