summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/worklog.md19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt29
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt32
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt29
-rw-r--r--android-app/app/src/main/res/drawable/ic_track_record.xml17
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml13
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt12
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt24
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt79
11 files changed, 284 insertions, 6 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index e17781b..7a4467f 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -127,10 +127,23 @@ Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into 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)
+### [APPROVED] TrackRepository (2026-03-25)
+- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`
+- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`
+- `isRecording` flag; `startTrack()` clears + starts; `stopTrack()`; `addPoint()` guards on isRecording; `getPoints()` returns snapshot
+- 8 tests — all GREEN (`TrackRepositoryTest`)
+
+### [APPROVED] Track ViewModel + Map overlay + Record FAB (2026-03-25)
+- `MainViewModel`: TrackRepository wired in; exposes `isRecording: StateFlow<Boolean>`, `trackPoints: StateFlow<List<TrackPoint>>`; `startTrack()`, `stopTrack()`, `addGpsPoint(lat, lon, sogKnots, cogDeg)`
+- `MapHandler.updateTrackLayer(style, points)`: lazy LineLayer init; red (#E53935) 3dp polyline from List<TrackPoint>
+- `MainActivity`: stores `loadedStyle`; GPS flow feeds `viewModel.addGpsPoint()` (m/s→knots); observes `trackPoints` → `mapHandler.updateTrackLayer()`; observes `isRecording` → FAB icon toggle (ic_track_record / ic_close)
+- `activity_main.xml`: `fab_record_track` FAB anchored top|end of bottom nav
+- `drawable/ic_track_record.xml`: red dot record icon
+
## Next 3 Specific Steps
-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
+1. **Persist track to GPX/Room** — export recorded track as GPX file or store in Room DB
+2. **Track stats in Log tab** — show elapsed time, distance, avg SOG while recording
+3. **AnchorWatchHandler UI** — wire `AnchorWatchHandler` fully into SafetyFragment (currently stub)
## 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/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 61d8b9b..ecaddc0 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
@@ -45,9 +45,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
private var anchorWatchHandler: AnchorWatchHandler? = null
-
+ private var loadedStyle: Style? = null
+
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
+ private lateinit var fabRecordTrack: FloatingActionButton
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -81,6 +83,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
onActivateMob()
}
+
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabRecordTrack.setOnClickListener {
+ if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
+ }
}
private fun setupBottomSheet() {
@@ -227,10 +234,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}, 256))
.withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
- maplibreMap.setStyle(style) { loadedStyle ->
+ maplibreMap.setStyle(style) { style ->
+ loadedStyle = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
- mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
}
}
}
@@ -239,6 +247,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
+ val sogKnots = gpsData.speedOverGround * 1.94384
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.toDouble())
}
}
lifecycleScope.launch {
@@ -246,6 +256,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
}
+ lifecycleScope.launch {
+ viewModel.trackPoints.collect { points ->
+ val style = loadedStyle ?: return@collect
+ mapHandler?.updateTrackLayer(style, points)
+ }
+ }
+ 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"
+ }
+ }
}
private fun startInstrumentSimulation(polarTable: PolarTable) {
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..c90adb9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,24 @@
+package org.terst.nav.track
+
+class TrackRepository {
+
+ var isRecording: Boolean = false
+ private set
+
+ private val points = mutableListOf<TrackPoint>()
+
+ fun startTrack() {
+ points.clear()
+ isRecording = true
+ }
+
+ fun stopTrack() {
+ isRecording = false
+ }
+
+ fun addPoint(point: TrackPoint) {
+ if (isRecording) points.add(point)
+ }
+
+ 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
index 8e84e1e..33decbe 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
@@ -6,6 +6,8 @@ import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
import org.terst.nav.data.api.AisHubApiService
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.TrackRepository
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
@@ -43,6 +45,36 @@ class MainViewModel(
private val aisRepository = AisRepository()
+ private val trackRepository = TrackRepository()
+
+ 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()
+
+ fun startTrack() {
+ trackRepository.startTrack()
+ _trackPoints.value = emptyList()
+ _isRecording.value = true
+ }
+
+ fun stopTrack() {
+ trackRepository.stopTrack()
+ _isRecording.value = false
+ }
+
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false,
+ timestampMs = System.currentTimeMillis()
+ )
+ trackRepository.addPoint(point)
+ _trackPoints.value = trackRepository.getPoints()
+ }
+
private val aisHubApi: AisHubApiService by lazy {
Retrofit.Builder()
.baseUrl("https://data.aishub.net")
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..cbc2e90 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
@@ -7,6 +7,7 @@ import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
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,10 +16,12 @@ 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.TidalCurrentState
+import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
@@ -37,9 +40,13 @@ 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 TRACK_SOURCE_ID = "track-source"
+ private val TRACK_LAYER_ID = "track-line"
+
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
+ private var trackSource: GeoJsonSource? = null
/**
* Initializes map layers for anchor watch and tidal currents.
@@ -127,6 +134,28 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
}
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layer on first call.
+ */
+ fun updateTrackLayer(style: Style, points: List<TrackPoint>) {
+ if (trackSource == null) {
+ trackSource = GeoJsonSource(TRACK_SOURCE_ID)
+ style.addSource(trackSource!!)
+ style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(3f)
+ )
+ })
+ }
+ if (points.size >= 2) {
+ val coords = points.map { Point.fromLngLat(it.lon, it.lat) }
+ trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
val points = mutableListOf<Point>()
val degreesBetweenPoints = 8
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/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 66d1abe..552bf99 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -68,4 +68,17 @@
app:layout_anchor="@id/bottom_navigation"
app:layout_anchorGravity="top|start" />
+ <!-- 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:layout_anchor="@id/bottom_navigation"
+ app:layout_anchorGravity="top|end" />
+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
new file mode 100644
index 0000000..d803c8c
--- /dev/null
+++ b/test-runner/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/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
new file mode 100644
index 0000000..c90adb9
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,24 @@
+package org.terst.nav.track
+
+class TrackRepository {
+
+ var isRecording: Boolean = false
+ private set
+
+ private val points = mutableListOf<TrackPoint>()
+
+ fun startTrack() {
+ points.clear()
+ isRecording = true
+ }
+
+ fun stopTrack() {
+ isRecording = false
+ }
+
+ fun addPoint(point: TrackPoint) {
+ if (isRecording) points.add(point)
+ }
+
+ fun getPoints(): List<TrackPoint> = points.toList()
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt
new file mode 100644
index 0000000..dea19c6
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt
@@ -0,0 +1,79 @@
+package org.terst.nav.track
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+private fun makePoint(
+ lat: Double = 37.0,
+ lon: Double = -122.0,
+ sog: Double = 5.0,
+ cog: Double = 90.0,
+ windSpeed: Double = 10.0,
+ windAngle: Double = 45.0,
+ isTrueWind: Boolean = false,
+ ts: Long = 1_000L
+) = TrackPoint(lat, lon, sog, cog, windSpeed, windAngle, isTrueWind, ts)
+
+class TrackRepositoryTest {
+
+ private lateinit var repo: TrackRepository
+
+ @Before fun setUp() {
+ repo = TrackRepository()
+ }
+
+ @Test fun `initial state is not recording`() {
+ assertFalse(repo.isRecording)
+ }
+
+ @Test fun `startTrack sets isRecording to true`() {
+ repo.startTrack()
+ assertTrue(repo.isRecording)
+ }
+
+ @Test fun `stopTrack sets isRecording to false`() {
+ repo.startTrack()
+ repo.stopTrack()
+ assertFalse(repo.isRecording)
+ }
+
+ @Test fun `addPoint appends point when recording`() {
+ repo.startTrack()
+ repo.addPoint(makePoint(lat = 37.1))
+ assertEquals(1, repo.getPoints().size)
+ assertEquals(37.1, repo.getPoints()[0].lat, 0.0001)
+ }
+
+ @Test fun `addPoint is ignored when not recording`() {
+ repo.addPoint(makePoint())
+ assertEquals(0, repo.getPoints().size)
+ }
+
+ @Test fun `startTrack clears previous points`() {
+ repo.startTrack()
+ repo.addPoint(makePoint())
+ repo.addPoint(makePoint())
+ repo.stopTrack()
+ repo.startTrack()
+ assertEquals(0, repo.getPoints().size)
+ }
+
+ @Test fun `multiple points accumulate in order`() {
+ repo.startTrack()
+ repo.addPoint(makePoint(lat = 37.0, ts = 1000L))
+ repo.addPoint(makePoint(lat = 37.1, ts = 2000L))
+ repo.addPoint(makePoint(lat = 37.2, ts = 3000L))
+ val pts = repo.getPoints()
+ assertEquals(3, pts.size)
+ assertEquals(37.0, pts[0].lat, 0.0001)
+ assertEquals(37.2, pts[2].lat, 0.0001)
+ }
+
+ @Test fun `getPoints returns snapshot not live list`() {
+ repo.startTrack()
+ val snapshot = repo.getPoints()
+ repo.addPoint(makePoint())
+ assertEquals(0, snapshot.size) // snapshot taken before addPoint
+ }
+}