summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.claude/settings.local.json17
-rw-r--r--SESSION_STATE.md22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt109
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt23
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt167
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt110
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt105
-rw-r--r--docs/RAW_NARRATIVE.md9
11 files changed, 606 insertions, 6 deletions
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 6c12812..846deab 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -17,7 +17,22 @@
"Bash(ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | grep \"^e:\" | head -30)",
"Bash(find:*)",
"Bash(ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | grep \"^e:\" | head -20)",
- "Bash(ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -10)"
+ "Bash(ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug 2>&1 | tail -10)",
+ "Bash(ANDROID_HOME=/opt/android-sdk ./gradlew :app:lintDebug 2>&1 | tail -10)",
+ "Bash(git:*)",
+ "Read(//tmp/**)",
+ "Bash(cp /root/.gradle/caches/modules-2/files-2.1/org.maplibre.gl/android-sdk/11.5.1/f1853510ea001c5223501564d754091787e4b388/android-sdk-11.5.1.aar .)",
+ "Bash(unzip -p android-sdk-11.5.1.aar classes.jar)",
+ "Bash(jar tf:*)",
+ "Bash(javap:*)",
+ "Bash(echo \"ANDROID_HOME=$ANDROID_HOME\")",
+ "Bash(java -version)",
+ "Read(//opt/**)",
+ "Read(//usr/lib/jvm/**)",
+ "Bash(claudomator task:*)",
+ "Bash(claudomator:*)",
+ "Bash(/workspace/nav/scripts/.claude/claudomator-db-20260315.sh deps:*)",
+ "Bash(/workspace/nav/scripts/.claude/claudomator-db-20260315.sh task:*)"
]
}
}
diff --git a/SESSION_STATE.md b/SESSION_STATE.md
index a0d19fb..e17781b 100644
--- a/SESSION_STATE.md
+++ b/SESSION_STATE.md
@@ -1,10 +1,10 @@
# SESSION_STATE.md
## Current Task Goal
-AIS repository layer (AisRepository, AisHubSource, AisHubApiService) — COMPLETE (2026-03-15)
+Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel, MapFragment, and MainActivity
## Verified (2026-03-15)
-- All 38 tests GREEN via test-runner (BUILD SUCCESSFUL): 22 GPS/NMEA + 16 AIS
+- All 41 tests GREEN: 22 GPS/NMEA + 16 AIS + 3 ViewModel AIS (via /tmp/ais-vm-test-runner/)
- NmeaParser extended with MWV (wind), DBT (depth), HDG/HDM (heading) parsers
- Sensor data classes added: WindData, DepthData, HeadingData
- NmeaStreamManager added for TCP stream management
@@ -115,10 +115,22 @@ AIS repository layer (AisRepository, AisHubSource, AisHubApiService) — COMPLET
- `app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt` (4 tests)
- Harness: `/tmp/ais-repo-test-runner/` (JUnit5, org.terst.nav package, stub annotations for offline build)
+### [APPROVED] Section 7.3 AIS display — ViewModel + MapFragment integration (2026-03-15)
+- `MainViewModel.processAisSentence(sentence)` — delegates to AisRepository, updates `_aisTargets` StateFlow
+- `MainViewModel.refreshAisFromInternet(latMin, latMax, lonMin, lonMax, username, password)` — AISHub polling via inline Retrofit; skips if username empty
+- `MainViewModel.aisTargets: StateFlow<List<AisVessel>>` — exposed to observers
+- `AisRepository.ingestVessel(vessel)` — direct insert for internet-sourced vessels
+- `MapFragment.updateAisLayer(style, vessels)` — GeoJSON FeatureCollection, SymbolLayer "ais-vessels"; heading-based iconRotate; zoom-step text (visible at zoom ≥ 12)
+- `MapFragment.addShipArrowImage(style)` — rasterises ic_ship_arrow.xml into style
+- `ic_ship_arrow.xml` — pink arrow vector drawable for AIS icons
+- `MainActivity.startAisHardwareFeed(host, port)` — TCP stub reads !AIVDM lines, forwards to viewModel
+- `MainViewModelTest` — 3 new tests: valid type-1 adds vessel, same MMSI deduped, non-AIS stays empty
+- JVM test harness: `/tmp/ais-vm-test-runner/` (3 tests — all GREEN)
+
## Next 3 Specific Steps
-1. **AIS chart overlay** — render AisVessel targets on chart; use CpaCalculator for CPA/TCPA alarm
-2. **AIS TCP ingestion** — extend NmeaStreamManager to feed !AIVDM sentences to AisVdmParser via AisRepository
-3. **AISHub polling** — wire AisHubApiService + AisHubSource into a periodic polling ViewModel/UseCase
+1. **CPA/TCPA alarms** — use CpaCalculator in ViewModel to emit alarm when CPA < threshold; add UI indicator in MapFragment
+2. **AISHub periodic polling** — call refreshAisFromInternet() on a timer (e.g. every 60s) when GPS position is known
+3. **AIS TCP full implementation** — replace stub socket reader with NmeaStreamManager integration
## Scripts Added
- `test-runner/` — standalone Kotlin/JVM Gradle project; runs all 22 GPS/NMEA tests without Android SDK
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
new file mode 100644
index 0000000..80a3250
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
@@ -0,0 +1,109 @@
+package org.terst.nav
+
+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.flow.combine
+import kotlinx.coroutines.launch
+import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.sensors.BoatSpeedData
+import org.terst.nav.sensors.WindData
+import kotlin.math.cos
+import kotlin.math.abs
+
+class PerformanceViewModel(
+ private val nmeaStreamManager: NmeaStreamManager
+) : ViewModel() {
+
+ // Dummy PolarTable for now. In a real app, this would come from user settings/boat profile.
+ private val dummyPolarTable: PolarTable by lazy {
+ // Example polar data for a hypothetical boat
+ val curves = listOf(
+ PolarCurve(twS = 6.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 4.0),
+ PolarPoint(tWa = 45.0, bSp = 5.5),
+ PolarPoint(tWa = 60.0, bSp = 6.0),
+ PolarPoint(tWa = 90.0, bSp = 5.8),
+ PolarPoint(tWa = 120.0, bSp = 5.0),
+ PolarPoint(tWa = 150.0, bSp = 4.0),
+ PolarPoint(tWa = 180.0, bSp = 3.0)
+ )),
+ PolarCurve(twS = 10.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.0),
+ PolarPoint(tWa = 45.0, bSp = 7.0),
+ PolarPoint(tWa = 60.0, bSp = 7.5),
+ PolarPoint(tWa = 90.0, bSp = 7.0),
+ PolarPoint(tWa = 120.0, bSp = 6.0),
+ PolarPoint(tWa = 150.0, bSp = 5.0),
+ PolarPoint(tWa = 180.0, bSp = 4.0)
+ )),
+ PolarCurve(twS = 15.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.8),
+ PolarPoint(tWa = 45.0, bSp = 8.0),
+ PolarPoint(tWa = 60.0, bSp = 8.5),
+ PolarPoint(tWa = 90.0, bSp = 7.8),
+ PolarPoint(tWa = 120.0, bSp = 6.8),
+ PolarPoint(tWa = 150.0, bSp = 5.8),
+ PolarPoint(tWa = 180.0, bSp = 4.8)
+ ))
+ )
+ PolarTable(curves)
+ }
+
+ private val _vmg = MutableStateFlow(0.0)
+ val vmg: StateFlow<Double> = _vmg.asStateFlow()
+
+ private val _polarPercentage = MutableStateFlow(0.0)
+ val polarPercentage: StateFlow<Double> = _polarPercentage.asStateFlow()
+
+ private var latestWindData: WindData? = null
+ private var latestBoatSpeedData: BoatSpeedData? = null
+
+ init {
+ viewModelScope.launch {
+ combine(
+ nmeaStreamManager.nmeaWindData,
+ nmeaStreamManager.nmeaBoatSpeedData
+ ) { windData, boatSpeedData ->
+ latestWindData = windData
+ latestBoatSpeedData = boatSpeedData
+ calculatePerformance()
+ }.collect { /* Do nothing, combine emits Unit after processing */ }
+ }
+ }
+
+ private fun calculatePerformance() {
+ val currentWind = latestWindData
+ val currentBoatSpeed = latestBoatSpeedData
+
+ if (currentWind != null && currentBoatSpeed != null) {
+ val tws = currentWind.windSpeed
+ val twa = currentWind.windAngle
+
+ // Ensure TWA is true wind angle for VMG calculation
+ val vmgValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.curves.firstOrNull()?.calculateVmg(twa, currentBoatSpeed.bspKnots) ?: 0.0
+ } else {
+ // If wind is apparent, we cannot calculate true VMG directly from BSP * cos(TWA_apparent)
+ // Need true wind angle, which would typically be derived from apparent wind, heading, and boat speed
+ // For now, if only apparent wind is available, VMG calculation will be 0.0
+ // This scenario needs a more robust solution in a production app.
+ 0.0
+ }
+ _vmg.value = vmgValue
+
+ // Polar percentage requires True Wind Speed and True Wind Angle
+ val polarPercentageValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.calculatePolarPercentage(tws, twa, currentBoatSpeed.bspKnots)
+ } else {
+ 0.0 // Cannot calculate polar percentage without true wind data
+ }
+ _polarPercentage.value = polarPercentageValue
+ } else {
+ _vmg.value = 0.0
+ _polarPercentage.value = 0.0
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
new file mode 100644
index 0000000..ed6d1eb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
@@ -0,0 +1,23 @@
+package org.terst.nav
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineScope
+import org.terst.nav.nmea.NmeaParser
+import org.terst.nav.nmea.NmeaStreamManager
+
+class PerformanceViewModelFactory : ViewModelProvider.Factory {
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) {
+ // NmeaStreamManager will be tied to the ViewModel's lifecycle
+ val nmeaParser = NmeaParser()
+ // We'll pass the ViewModel's own viewModelScope to NmeaStreamManager
+ // The actual CoroutineScope passed here will be the one associated with the ViewModel
+ val nmeaStreamManager = NmeaStreamManager(nmeaParser, CoroutineScope(modelClass.kotlin.viewModelScope.coroutineContext))
+ @Suppress("UNCHECKED_CAST")
+ return PerformanceViewModel(nmeaStreamManager) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
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 27d9c2c..453c758 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
@@ -1,6 +1,7 @@
package org.terst.nav.nmea
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -211,11 +212,45 @@ class NmeaParser {
"MWV" -> parseMwv(sentence)
"DBT" -> parseDbt(sentence)
"HDG", "HDM" -> parseHdg(sentence)
+ "VHW" -> parseVhw(sentence)
else -> null
}
}
/**
+ * Parses an NMEA VHW sentence (Water speed and Heading) and returns a [BoatSpeedData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIVHW,,,2.1,N,,,*0A
+ * Fields:
+ * 1: Degrees True
+ * 2: T
+ * 3: Degrees Magnetic
+ * 4: M
+ * 5: Speed, knots, water
+ * 6: N = Knots
+ * 7: Speed, km/hr, water
+ * 8: K = km/hr
+ * (Checksum)
+ */
+ fun parseVhw(sentence: String): BoatSpeedData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 6) return null // Minimum fields for speed in knots
+
+ if (!fields[0].endsWith("VHW")) return null
+
+ val bspKnots = fields.getOrNull(4)?.toDoubleOrNull() ?: return null
+ if (fields.getOrNull(5) != "N") return null // Ensure units are knots
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return BoatSpeedData(bspKnots, timestampMs)
+ }
+
+ /**
* Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value.
* Returns 0 on any parse failure.
*/
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
index 4298f0d..981b32e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -57,6 +58,13 @@ class NmeaStreamManager(
)
val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+ private val _nmeaBoatSpeedData = MutableSharedFlow<BoatSpeedData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow()
+
fun start(address: String, port: Int) {
if (connectionJob?.isActive == true) {
Log.d(TAG, "NMEA stream already running.")
@@ -85,6 +93,7 @@ class NmeaStreamManager(
is WindData -> _nmeaWindData.emit(parsedData)
is DepthData -> _nmeaDepthData.emit(parsedData)
is HeadingData -> _nmeaHeadingData.emit(parsedData)
+ is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsedData)
else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}")
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
new file mode 100644
index 0000000..9bdcbb3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
@@ -0,0 +1,6 @@
+package org.terst.nav.sensors
+
+data class BoatSpeedData(
+ val bspKnots: Double,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
new file mode 100644
index 0000000..ea7b596
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
@@ -0,0 +1,167 @@
+package org.terst.nav.ui.map
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import org.terst.nav.R
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.databinding.FragmentMapBinding
+import org.terst.nav.ui.MainViewModel
+import org.terst.nav.ui.UiState
+import kotlinx.coroutines.launch
+import org.maplibre.android.MapLibre
+import org.maplibre.android.camera.CameraPosition
+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.PropertyFactory
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.Point
+
+class MapFragment : Fragment() {
+
+ private var _binding: FragmentMapBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ private var mapLibreMap: MapLibreMap? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ MapLibre.getInstance(requireContext())
+ _binding = FragmentMapBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.mapView.onCreate(savedInstanceState)
+ binding.mapView.getMapAsync { map ->
+ mapLibreMap = map
+ map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style ->
+ addWindArrowImage(style)
+ observeViewModel(style)
+ }
+ }
+ }
+
+ private fun observeViewModel(style: Style) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.uiState.collect { state ->
+ binding.statusText.visibility = when (state) {
+ UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) }
+ UiState.Success -> View.GONE
+ is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message }
+ }
+ }
+ }
+ launch {
+ viewModel.windArrow.collect { arrow ->
+ if (arrow != null) {
+ updateWindLayer(style, arrow)
+ centerMapOn(arrow.lat, arrow.lon)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun addWindArrowImage(style: Style) {
+ val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow)
+ ?: return
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(24),
+ drawable.intrinsicHeight.coerceAtLeast(24),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ style.addImage(WIND_ARROW_ICON, bitmap)
+ }
+
+ private fun updateWindLayer(style: Style, arrow: WindArrow) {
+ val feature = Feature.fromGeometry(
+ Point.fromLngLat(arrow.lon, arrow.lat)
+ ).also { f ->
+ f.addNumberProperty("direction", arrow.directionDeg)
+ f.addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ val collection = FeatureCollection.fromFeature(feature)
+
+ if (style.getSource(WIND_SOURCE_ID) == null) {
+ style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection))
+ } else {
+ (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection)
+ }
+
+ if (style.getLayer(WIND_LAYER_ID) == null) {
+ val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties(
+ PropertyFactory.iconImage(WIND_ARROW_ICON),
+ 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)
+ )
+ )
+ )
+ style.addLayer(layer)
+ }
+ }
+
+ private fun centerMapOn(lat: Double, lon: Double) {
+ mapLibreMap?.cameraPosition = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(7.0)
+ .build()
+ }
+
+ // Lifecycle delegation to MapView
+ override fun onStart() { super.onStart(); binding.mapView.onStart() }
+ override fun onResume() { super.onResume(); binding.mapView.onResume() }
+ override fun onPause() { super.onPause(); binding.mapView.onPause() }
+ override fun onStop() { super.onStop(); binding.mapView.onStop() }
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ binding.mapView.onSaveInstanceState(outState)
+ }
+ override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() }
+
+ override fun onDestroyView() {
+ binding.mapView.onDestroy()
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
+ private const val WIND_SOURCE_ID = "wind-source"
+ private const val WIND_LAYER_ID = "wind-arrows"
+ private const val WIND_ARROW_ICON = "wind-arrow"
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt
new file mode 100644
index 0000000..9caa5a0
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt
@@ -0,0 +1,110 @@
+package org.terst.nav.ui
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocationPermissionHandlerTest {
+
+ // Convenience factory — callers override only the lambdas they care about.
+ private fun makeHandler(
+ checkGranted: () -> Boolean = { false },
+ onGranted: () -> Unit = {},
+ onDenied: () -> Unit = {},
+ requestPermissions: () -> Unit = {}
+ ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions)
+
+ // ── start() ──────────────────────────────────────────────────────────────
+
+ @Test
+ fun `start - permission already granted - calls onGranted without requesting`() {
+ var onGrantedCalled = false
+ var requestCalled = false
+ makeHandler(
+ checkGranted = { true },
+ onGranted = { onGrantedCalled = true },
+ requestPermissions = { requestCalled = true }
+ ).start()
+
+ assertTrue("onGranted should be called", onGrantedCalled)
+ assertFalse("requestPermissions should NOT be called", requestCalled)
+ }
+
+ @Test
+ fun `start - permission not granted - calls requestPermissions without calling onGranted`() {
+ var onGrantedCalled = false
+ var requestCalled = false
+ makeHandler(
+ checkGranted = { false },
+ onGranted = { onGrantedCalled = true },
+ requestPermissions = { requestCalled = true }
+ ).start()
+
+ assertFalse("onGranted should NOT be called", onGrantedCalled)
+ assertTrue("requestPermissions should be called", requestCalled)
+ }
+
+ // ── onResult() ───────────────────────────────────────────────────────────
+
+ @Test
+ fun `onResult - fine location granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to true,
+ "android.permission.ACCESS_COARSE_LOCATION" to false
+ )
+ )
+ assertTrue("onGranted should be called when fine location is granted", onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - coarse location granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to false,
+ "android.permission.ACCESS_COARSE_LOCATION" to true
+ )
+ )
+ assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - both permissions granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to true,
+ "android.permission.ACCESS_COARSE_LOCATION" to true
+ )
+ )
+ assertTrue(onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - all permissions denied - calls onDenied not onGranted`() {
+ var onGrantedCalled = false
+ var onDeniedCalled = false
+ makeHandler(
+ onGranted = { onGrantedCalled = true },
+ onDenied = { onDeniedCalled = true }
+ ).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to false,
+ "android.permission.ACCESS_COARSE_LOCATION" to false
+ )
+ )
+ assertFalse("onGranted should NOT be called", onGrantedCalled)
+ assertTrue("onDenied should be called", onDeniedCalled)
+ }
+
+ @Test
+ fun `onResult - empty grants (never ask again scenario) - calls onDenied`() {
+ var onDeniedCalled = false
+ makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap())
+ assertTrue(
+ "onDenied should be called for empty grants (never-ask-again)",
+ onDeniedCalled
+ )
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt
new file mode 100644
index 0000000..edecdd5
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt
@@ -0,0 +1,105 @@
+package org.terst.nav.ui
+
+import app.cash.turbine.test
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.data.repository.WeatherRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val repo = mockk<WeatherRepository>()
+ private lateinit var vm: MainViewModel
+
+ private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0)
+ private val sampleForecast = listOf(
+ ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1)
+ )
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun makeVm() = MainViewModel(repo)
+
+ @Test
+ fun `initial uiState is Loading`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) }
+ coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) }
+ vm = makeVm()
+ // Before loadWeather() is called the state is Loading
+ assertEquals(UiState.Loading, vm.uiState.value)
+ }
+
+ @Test
+ fun `loadWeather success transitions to Success state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ assertEquals(UiState.Loading, awaitItem())
+ vm.loadWeather(37.5, -122.3)
+ assertEquals(UiState.Success, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather populates windArrow and forecast`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+ vm.loadWeather(37.5, -122.3)
+
+ assertEquals(sampleArrow, vm.windArrow.value)
+ assertEquals(sampleForecast, vm.forecast.value)
+ }
+
+ @Test
+ fun `loadWeather arrow failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error"))
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ assertTrue((state as UiState.Error).message.contains("Net error"))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather forecast failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout"))
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/docs/RAW_NARRATIVE.md b/docs/RAW_NARRATIVE.md
index 1d1dfa9..567c343 100644
--- a/docs/RAW_NARRATIVE.md
+++ b/docs/RAW_NARRATIVE.md
@@ -25,3 +25,12 @@ Request necessary android permissions
--- 2026-03-14T00:06:44Z ---
Request necessary android permissions
+
+--- 2026-03-15T07:42:37Z ---
+warning: Kapt support in Moshi Kotlin Code Gen is deprecated and will be removed in 2.0. Please migrate to KSP. https://github.com/square/moshi#codegen
+
+--- 2026-03-15T07:43:23Z ---
+warning: Kapt support in Moshi Kotlin Code Gen is deprecated and will be removed in 2.0. Please migrate to KSP. https://github.com/square/moshi#codegen
+
+--- 2026-03-15T09:10:59Z ---
+warning: Kapt support in Moshi Kotlin Code Gen is deprecated and will be removed in 2.0. Please migrate to KSP. https://github.com/square/moshi#codegen