diff options
Diffstat (limited to 'android-app/app/src')
8 files changed, 197 insertions, 0 deletions
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 new file mode 100644 index 0000000..810313c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -0,0 +1,18 @@ +package org.terst.nav + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.terst.nav.sensors.WindData + +class LocationService { + + companion object { + private val _nmeaWindDataFlow = MutableSharedFlow<WindData>() + val nmeaWindDataFlow: SharedFlow<WindData> = _nmeaWindDataFlow + + // line 362 — emit wind data parsed from NMEA sentences + suspend fun emitWind(wind: WindData) { + _nmeaWindDataFlow.emit(wind) + } + } +} 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 new file mode 100644 index 0000000..886d025 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -0,0 +1,21 @@ +package org.terst.nav + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.terst.nav.ui.MainViewModel + +class MainActivity { + + private val viewModel = MainViewModel() + private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun observeDataSources() { + lifecycleScope.launch { + LocationService.nmeaWindDataFlow.collect { wind -> + viewModel.updateWind(wind) + } + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt new file mode 100644 index 0000000..95fd5e3 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt @@ -0,0 +1,8 @@ +package org.terst.nav.sensors + +data class WindData( + val windAngle: Double, // degrees, relative or true + val windSpeed: Double, // knots + val isTrueWind: Boolean, + val timestampMs: Long +) 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..d803c8c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt @@ -0,0 +1,12 @@ +package org.terst.nav.track + +data class TrackPoint( + val lat: Double, + val lon: Double, + val sogKnots: Double, + val cogDeg: Double, + val windSpeedKnots: Double, + val windAngleDeg: Double, + val isTrueWind: Boolean, + val timestampMs: Long +) 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..c77852f --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -0,0 +1,23 @@ +package org.terst.nav.track + +class TrackRepository { + private val points = mutableListOf<TrackPoint>() + private var tracking = false + + fun startTrack() { + points.clear() + tracking = true + } + + fun stopTrack() { + tracking = false + } + + fun addPoint(point: TrackPoint): Boolean { + if (!tracking) return false + points.add(point) + return true + } + + fun getPoints(): List<TrackPoint> = points.toList() +} 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 new file mode 100644 index 0000000..c5b90c3 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -0,0 +1,44 @@ +package org.terst.nav.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.terst.nav.sensors.WindData +import org.terst.nav.track.TrackPoint +import org.terst.nav.track.TrackRepository + +class MainViewModel { + + private val trackRepository = TrackRepository() + private var latestWind: WindData? = null + + private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList()) + val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints + + fun startTrack() { + trackRepository.startTrack() + _trackPoints.value = emptyList() + } + + fun stopTrack() { + trackRepository.stopTrack() + } + + fun updateWind(wind: WindData) { + latestWind = wind + } + + fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { + val wind = latestWind + val point = TrackPoint( + lat = lat, lon = lon, + sogKnots = sogKnots, cogDeg = cogDeg, + windSpeedKnots = wind?.windSpeed ?: 0.0, + windAngleDeg = wind?.windAngle ?: 0.0, + isTrueWind = wind?.isTrueWind ?: false, + timestampMs = System.currentTimeMillis() + ) + if (trackRepository.addPoint(point)) { + _trackPoints.value = trackRepository.getPoints() + } + } +} diff --git a/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt b/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt new file mode 100644 index 0000000..96bf63a --- /dev/null +++ b/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt @@ -0,0 +1,13 @@ +package androidx.arch.core.executor.testing + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * No-op stub: provides the InstantTaskExecutorRule type so test files compile + * on JVM. The real rule is only needed for LiveData; these tests use StateFlow. + */ +class InstantTaskExecutorRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = base +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt new file mode 100644 index 0000000..8e7125c --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt @@ -0,0 +1,58 @@ +package org.terst.nav.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.terst.nav.sensors.WindData +import org.terst.nav.ui.MainViewModel + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelWindTest { + + @get:Rule val instantTask = InstantTaskExecutorRule() + private val dispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() { Dispatchers.setMain(dispatcher) } + @After fun tearDown() { Dispatchers.resetMain() } + + @Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest { + val vm = MainViewModel() + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + val pt = vm.trackPoints.value.first() + assertEquals(0.0, pt.windSpeedKnots, 0.001) + assertEquals(0.0, pt.windAngleDeg, 0.001) + assertEquals(false, pt.isTrueWind) + } + + @Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest { + val vm = MainViewModel() + vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L)) + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + val pt = vm.trackPoints.value.first() + assertEquals(15.5, pt.windSpeedKnots, 0.001) + assertEquals(45.0, pt.windAngleDeg, 0.001) + assertEquals(true, pt.isTrueWind) + } + + @Test fun `addGpsPoint reflects wind update between fixes`() = runTest { + val vm = MainViewModel() + vm.startTrack() + vm.addGpsPoint(37.0, -122.0, 5.0, 90.0) + vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L)) + vm.addGpsPoint(37.1, -122.0, 5.0, 90.0) + val pts = vm.trackPoints.value + assertEquals(0.0, pts[0].windSpeedKnots, 0.001) + assertEquals(8.0, pts[1].windSpeedKnots, 0.001) + } +} |
