summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-25 18:09:53 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 18:09:53 +0000
commitca57e40adc0b89e7dc5409475f7510c0c188d715 (patch)
treed1fd636e0bdd67e36870ae891ed55d7d764689cf /android-app/app/src/main
parentb5ab0c5236d7503dc002b7bf04e0e33b9c7ff9fa (diff)
feat(track): implement GPS track recording with map overlay
- TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List<TrackPoint> - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main')
-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
7 files changed, 153 insertions, 3 deletions
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>