summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt18
-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/repository/WeatherRepository.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt28
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt39
5 files changed, 114 insertions, 1 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 5950844..17fa52f 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
@@ -50,6 +50,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var anchorWatchHandler: AnchorWatchHandler? = null
private val loadedStyleFlow = MutableStateFlow<Style?>(null)
private var weatherLoaded = false
+ private var mapLibreMapRef: org.maplibre.android.maps.MapLibreMap? = null
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
@@ -232,6 +233,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
mapView = findViewById(R.id.mapView)
mapView?.onCreate(null)
mapView?.getMapAsync { maplibreMap ->
+ mapLibreMapRef = maplibreMap
mapHandler = MapHandler(maplibreMap)
val style = Style.Builder()
.fromUri("https://tiles.openfreemap.org/styles/liberty")
@@ -240,7 +242,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
it.setMaxZoom(18f)
}, 256))
.withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
-
+
maplibreMap.setStyle(style) { style ->
loadedStyleFlow.value = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
@@ -248,6 +250,15 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow)
mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
mapHandler?.setupWindLayer(style, windArrowBitmap)
+ mapHandler?.setupWindGridLayer(style)
+ }
+
+ maplibreMap.addOnCameraIdleListener {
+ val bounds = maplibreMap.projection.visibleRegion.latLngBounds
+ viewModel.loadWindGrid(
+ bounds.latSouth, bounds.latNorth,
+ bounds.lonWest, bounds.lonEast
+ )
}
}
}
@@ -270,6 +281,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ viewModel.windGrid.collect { arrows ->
+ mapHandler?.updateWindGridLayer(arrows)
+ }
+ }
+ lifecycleScope.launch {
LocationService.anchorWatchState.collect { state ->
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
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/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
index ee586a5..3459d7b 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
@@ -47,6 +47,26 @@ class WeatherRepository(
)
}
+ /**
+ * 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/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 0efff52..4c2c555 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
@@ -37,6 +37,9 @@ 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
@@ -121,6 +124,31 @@ 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.
*/
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 cdf0c25..bb73c53 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
@@ -49,11 +49,15 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
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 trackSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
+ private var windGridSource: GeoJsonSource? = null
/**
* Initializes map layers for anchor watch and tidal currents.
@@ -167,6 +171,41 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
/**
+ * Registers the wind-grid layer. Uses the same bitmap as the single arrow — call after setupWindLayer.
+ */
+ fun setupWindGridLayer(style: Style) {
+ 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)
+ )
+ )
+ )
+ })
+ }
+
+ /** 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))
+ }
+
+ /**
* Places or moves the single wind arrow at the GPS position.
*/
fun updateWindLayer(arrow: WindArrow) {