summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt30
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt21
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt31
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt43
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt63
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt125
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt74
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt167
15 files changed, 790 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
index 03e6a2f..0c63662 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
@@ -1,6 +1,7 @@
package org.terst.nav
import android.location.Location
+import kotlin.math.*
data class AnchorWatchState(
val anchorLocation: Location? = null,
@@ -10,6 +11,40 @@ data class AnchorWatchState(
) {
companion object {
const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
+
+ /**
+ * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
+ * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out))
+ *
+ * @param depthMeters Depth from surface to seabed in meters.
+ * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters.
+ * @param rodeOutMeters Length of chain/rode deployed in meters.
+ * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid.
+ */
+ fun calculateRecommendedWatchCircleRadius(
+ depthMeters: Double,
+ freeboardMeters: Double,
+ rodeOutMeters: Double
+ ): Double {
+ if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) {
+ return 0.0 // Invalid inputs
+ }
+
+ val totalVerticalDistance = depthMeters + freeboardMeters
+
+ // Ensure we don't take asin of a value > 1 or < -1
+ if (totalVerticalDistance > rodeOutMeters) {
+ // Rode is too short for the depth+freeboard, effectively boat is directly above anchor
+ // In this case, the watch circle radius is 0, or very small.
+ return 0.0
+ }
+
+ // angle = asin( (Depth + Freeboard) / Rode Out )
+ val angle = asin(totalVerticalDistance / rodeOutMeters)
+
+ // Watch circle radius = Rode Out * cos(angle)
+ return rodeOutMeters * cos(angle)
+ }
}
fun isDragging(currentLocation: Location): Boolean {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt
new file mode 100644
index 0000000..658f6dd
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt
@@ -0,0 +1,35 @@
+package org.terst.nav.data.api
+
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+object ApiClient {
+
+ private val moshi: Moshi = Moshi.Builder()
+ .addLast(KotlinJsonAdapterFactory())
+ .build()
+
+ private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(
+ HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
+ )
+ .build()
+
+ private fun retrofit(baseUrl: String): Retrofit = Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+
+ val weatherApi: WeatherApiService by lazy {
+ retrofit("https://api.open-meteo.com/").create(WeatherApiService::class.java)
+ }
+
+ val marineApi: MarineApiService by lazy {
+ retrofit("https://marine-api.open-meteo.com/").create(MarineApiService::class.java)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
new file mode 100644
index 0000000..67c14f8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt
@@ -0,0 +1,17 @@
+package org.terst.nav.data.api
+
+import org.terst.nav.data.model.MarineResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface MarineApiService {
+
+ @GET("v1/marine")
+ suspend fun getMarineForecast(
+ @Query("latitude") latitude: Double,
+ @Query("longitude") longitude: Double,
+ @Query("hourly") hourly: String =
+ "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction",
+ @Query("forecast_days") forecastDays: Int = 7
+ ): MarineResponse
+}
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
new file mode 100644
index 0000000..9713bcd
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.data.api
+
+import org.terst.nav.data.model.WeatherResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface WeatherApiService {
+
+ @GET("v1/forecast")
+ suspend fun getWeatherForecast(
+ @Query("latitude") latitude: Double,
+ @Query("longitude") longitude: Double,
+ @Query("hourly") hourly: String =
+ "windspeed_10m,winddirection_10m,temperature_2m,precipitation_probability,weathercode",
+ @Query("forecast_days") forecastDays: Int = 7,
+ @Query("wind_speed_unit") windSpeedUnit: String = "kn"
+ ): WeatherResponse
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt
new file mode 100644
index 0000000..9b5493e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt
@@ -0,0 +1,30 @@
+package org.terst.nav.data.model
+
+/** One hourly weather forecast slot shown in the forecast panel. */
+data class ForecastItem(
+ val timeIso: String,
+ val windKt: Double,
+ val windDirDeg: Double,
+ val tempC: Double,
+ val precipProbabilityPct: Int,
+ val weatherCode: Int
+) {
+ /** Human-readable description based on WMO weather code. */
+ fun weatherDescription(): String = when (weatherCode) {
+ 0 -> "Clear sky"
+ 1 -> "Mainly clear"
+ 2 -> "Partly cloudy"
+ 3 -> "Overcast"
+ 45, 48 -> "Fog"
+ 51, 53, 55 -> "Drizzle"
+ 61, 63, 65 -> "Rain"
+ 71, 73, 75 -> "Snow"
+ 80, 81, 82 -> "Rain showers"
+ 95 -> "Thunderstorm"
+ 96, 99 -> "Thunderstorm with hail"
+ else -> "Unknown ($weatherCode)"
+ }
+
+ /** WMO codes 51-67 and 80-82 are precipitation. */
+ fun isRainy(): Boolean = weatherCode in 51..67 || weatherCode in 80..82
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
new file mode 100644
index 0000000..ab9799b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt
@@ -0,0 +1,20 @@
+package org.terst.nav.data.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class MarineResponse(
+ @Json(name = "latitude") val latitude: Double,
+ @Json(name = "longitude") val longitude: Double,
+ @Json(name = "hourly") val hourly: MarineHourly
+)
+
+@JsonClass(generateAdapter = true)
+data class MarineHourly(
+ @Json(name = "time") val time: List<String>,
+ @Json(name = "wave_height") val waveHeight: List<Double?>,
+ @Json(name = "wave_direction") val waveDirection: List<Double?>,
+ @Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List<Double?>,
+ @Json(name = "ocean_current_direction") val oceanCurrentDirection: List<Double?>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt
new file mode 100644
index 0000000..784f17a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt
@@ -0,0 +1,21 @@
+package org.terst.nav.data.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class WeatherResponse(
+ @Json(name = "latitude") val latitude: Double,
+ @Json(name = "longitude") val longitude: Double,
+ @Json(name = "hourly") val hourly: WeatherHourly
+)
+
+@JsonClass(generateAdapter = true)
+data class WeatherHourly(
+ @Json(name = "time") val time: List<String>,
+ @Json(name = "windspeed_10m") val windspeed10m: List<Double>,
+ @Json(name = "winddirection_10m") val winddirection10m: List<Double>,
+ @Json(name = "temperature_2m") val temperature2m: List<Double>,
+ @Json(name = "precipitation_probability") val precipitationProbability: List<Int>,
+ @Json(name = "weathercode") val weathercode: List<Int>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt
new file mode 100644
index 0000000..aa4ca99
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt
@@ -0,0 +1,31 @@
+package org.terst.nav.data.model
+
+/** A wind vector at a geographic point, used for map overlay rendering. */
+data class WindArrow(
+ val lat: Double,
+ val lon: Double,
+ val speedKt: Double,
+ val directionDeg: Double
+) {
+ fun isCalm(): Boolean = speedKt < 1.0
+
+ /** Normalise 360° → 0°; all other values unchanged. */
+ fun normalisedDirection(): Double = if (directionDeg >= 360.0) 0.0 else directionDeg
+
+ /** Beaufort scale 0-12 from wind speed in knots. */
+ fun beaufortScale(): Int = when {
+ speedKt < 1 -> 0
+ speedKt < 4 -> 1
+ speedKt < 7 -> 2
+ speedKt < 11 -> 3
+ speedKt < 16 -> 4
+ speedKt < 22 -> 5
+ speedKt < 28 -> 6
+ speedKt < 34 -> 7
+ speedKt < 41 -> 8
+ speedKt < 48 -> 9
+ speedKt < 56 -> 10
+ speedKt < 64 -> 11
+ else -> 12
+ }
+}
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
new file mode 100644
index 0000000..ee586a5
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt
@@ -0,0 +1,57 @@
+package org.terst.nav.data.repository
+
+import org.terst.nav.data.api.MarineApiService
+import org.terst.nav.data.api.WeatherApiService
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.WindArrow
+
+class WeatherRepository(
+ private val weatherApi: WeatherApiService,
+ private val marineApi: MarineApiService
+) {
+
+ /**
+ * Fetch 7-day hourly forecast items for the given position.
+ * Both weather and marine data are requested; only weather fields are needed for ForecastItem,
+ * but marine is fetched here to prime the cache for wind-arrow use.
+ */
+ suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> =
+ runCatching {
+ val weather = weatherApi.getWeatherForecast(lat, lon)
+ val h = weather.hourly
+ h.time.indices.map { i ->
+ ForecastItem(
+ timeIso = h.time[i],
+ windKt = h.windspeed10m[i],
+ windDirDeg = h.winddirection10m[i],
+ tempC = h.temperature2m[i],
+ precipProbabilityPct = h.precipitationProbability[i],
+ weatherCode = h.weathercode[i]
+ )
+ }
+ }
+
+ /**
+ * Fetch a single WindArrow representing the current conditions at [lat]/[lon].
+ * Uses the first hourly slot as the current-hour proxy.
+ */
+ suspend fun fetchWindArrow(lat: Double, lon: Double): Result<WindArrow> =
+ runCatching {
+ val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1)
+ val h = weather.hourly
+ WindArrow(
+ lat = lat,
+ lon = lon,
+ speedKt = h.windspeed10m.firstOrNull() ?: 0.0,
+ directionDeg = h.winddirection10m.firstOrNull() ?: 0.0
+ )
+ }
+
+ companion object {
+ /** Factory using the shared ApiClient singletons. */
+ fun create(): WeatherRepository {
+ val client = org.terst.nav.data.api.ApiClient
+ return WeatherRepository(client.weatherApi, client.marineApi)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt
new file mode 100644
index 0000000..cbb2fc1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt
@@ -0,0 +1,43 @@
+package org.terst.nav.ui
+
+/**
+ * Encapsulates location permission decision logic.
+ *
+ * Extracted for testability — no direct Android framework dependency in the core logic.
+ *
+ * Permissions handled: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION
+ *
+ * Usage:
+ * - Call [start] on activity start to check existing permission or trigger a request.
+ * - Call [onResult] from the ActivityResultLauncher callback with the permission grants map.
+ */
+class LocationPermissionHandler(
+ /** Returns true if location permission is already granted. */
+ private val checkGranted: () -> Boolean,
+ /** Called when location permission is available (already granted or just granted). */
+ private val onGranted: () -> Unit,
+ /** Called when location permission is denied or the user refuses (including "never ask again"). */
+ private val onDenied: () -> Unit,
+ /** Called when permission needs to be requested from the user via the system dialog. */
+ private val requestPermissions: () -> Unit
+) {
+ /**
+ * Check current permission state and dispatch:
+ * - If already granted, invoke [onGranted] immediately.
+ * - Otherwise, invoke [requestPermissions] to trigger the system dialog.
+ */
+ fun start() {
+ if (checkGranted()) onGranted() else requestPermissions()
+ }
+
+ /**
+ * Process the result from the system permission dialog.
+ *
+ * @param grants Map of permission name → granted status from ActivityResultLauncher.
+ * Invokes [onGranted] if any permission was granted, [onDenied] otherwise.
+ * An empty map (e.g. "never ask again" scenario) also triggers [onDenied].
+ */
+ fun onResult(grants: Map<String, Boolean>) {
+ if (grants.values.any { it }) onGranted() else onDenied()
+ }
+}
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..53d02fd
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -0,0 +1,63 @@
+package org.terst.nav.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.data.repository.WeatherRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+sealed class UiState {
+ object Loading : UiState()
+ object Success : UiState()
+ data class Error(val message: String) : UiState()
+}
+
+class MainViewModel(
+ private val repository: WeatherRepository = WeatherRepository.create()
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
+ val uiState: StateFlow<UiState> = _uiState
+
+ private val _windArrow = MutableStateFlow<WindArrow?>(null)
+ val windArrow: StateFlow<WindArrow?> = _windArrow
+
+ private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
+ val forecast: StateFlow<List<ForecastItem>> = _forecast
+
+ /**
+ * Fetch weather and marine data for [lat]/[lon] in parallel.
+ * Called once the device location is known.
+ */
+ fun loadWeather(lat: Double, lon: Double) {
+ viewModelScope.launch {
+ val arrowDeferred = async { repository.fetchWindArrow(lat, lon) }
+ val forecastDeferred = async { repository.fetchForecastItems(lat, lon) }
+
+ val arrowResult = arrowDeferred.await()
+ val forecastResult = forecastDeferred.await()
+
+ when {
+ arrowResult.isFailure -> {
+ _uiState.value = UiState.Error(
+ arrowResult.exceptionOrNull()?.message ?: "Unknown error"
+ )
+ }
+ forecastResult.isFailure -> {
+ _uiState.value = UiState.Error(
+ forecastResult.exceptionOrNull()?.message ?: "Unknown error"
+ )
+ }
+ else -> {
+ _windArrow.value = arrowResult.getOrNull()
+ _forecast.value = forecastResult.getOrThrow()
+ _uiState.value = UiState.Success
+ }
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt
new file mode 100644
index 0000000..1a60aa7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt
@@ -0,0 +1,125 @@
+package org.terst.nav.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import org.terst.nav.R
+import org.terst.nav.databinding.ActivityWeatherBinding
+import org.terst.nav.ui.forecast.ForecastFragment
+import org.terst.nav.ui.map.MapFragment
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+
+class WeatherActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityWeatherBinding
+ private val viewModel: MainViewModel by viewModels()
+
+ // Default position (San Francisco Bay) used when location is unavailable
+ private val defaultLat = 37.8
+ private val defaultLon = -122.4
+
+ private val permissionHandler: LocationPermissionHandler by lazy {
+ LocationPermissionHandler(
+ checkGranted = {
+ ContextCompat.checkSelfPermission(
+ this, Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ },
+ onGranted = { fetchLocationAndLoad() },
+ onDenied = { loadWeatherAtDefault() },
+ requestPermissions = {
+ locationPermissionLauncher.launch(
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ )
+ }
+ )
+ }
+
+ private val locationPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { grants ->
+ permissionHandler.onResult(grants)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityWeatherBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupBottomNav()
+
+ if (savedInstanceState == null) {
+ showFragment(MapFragment(), TAG_MAP)
+ requestLocationOrLoad()
+ }
+ }
+
+ private fun setupBottomNav() {
+ binding.bottomNav.setOnItemSelectedListener { item ->
+ when (item.itemId) {
+ R.id.nav_map -> {
+ showFragment(MapFragment(), TAG_MAP)
+ true
+ }
+ R.id.nav_forecast -> {
+ showFragment(ForecastFragment(), TAG_FORECAST)
+ true
+ }
+ else -> false
+ }
+ }
+ }
+
+ private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) {
+ val existing = supportFragmentManager.findFragmentByTag(tag)
+ supportFragmentManager.beginTransaction()
+ .apply {
+ supportFragmentManager.fragments.forEach { hide(it) }
+ if (existing == null) add(R.id.fragment_container, fragment, tag)
+ else show(existing)
+ }
+ .commit()
+ }
+
+ private fun requestLocationOrLoad() {
+ permissionHandler.start()
+ }
+
+ private fun fetchLocationAndLoad() {
+ val client = LocationServices.getFusedLocationProviderClient(this)
+ try {
+ client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
+ .addOnSuccessListener { location ->
+ if (location != null) {
+ viewModel.loadWeather(location.latitude, location.longitude)
+ } else {
+ loadWeatherAtDefault()
+ }
+ }
+ .addOnFailureListener {
+ loadWeatherAtDefault()
+ }
+ } catch (e: SecurityException) {
+ loadWeatherAtDefault()
+ }
+ }
+
+ private fun loadWeatherAtDefault() {
+ Toast.makeText(this, R.string.error_location, Toast.LENGTH_SHORT).show()
+ viewModel.loadWeather(defaultLat, defaultLon)
+ }
+
+ companion object {
+ private const val TAG_MAP = "map"
+ private const val TAG_FORECAST = "forecast"
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt
new file mode 100644
index 0000000..d581c55
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.ui.forecast
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.databinding.ItemForecastBinding
+
+class ForecastAdapter : ListAdapter<ForecastItem, ForecastAdapter.ViewHolder>(DIFF) {
+
+ inner class ViewHolder(private val binding: ItemForecastBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(item: ForecastItem) {
+ // Display short time (e.g. "13:00" from "2026-03-13T13:00")
+ binding.tvTime.text = item.timeIso.substringAfter("T", item.timeIso)
+
+ binding.tvDescription.text = item.weatherDescription()
+
+ binding.tvWind.text = String.format("%.0f kt %s",
+ item.windKt, directionLabel(item.windDirDeg))
+
+ binding.tvTemp.text = String.format("%.0f °C", item.tempC)
+
+ binding.tvPrecip.text = "${item.precipProbabilityPct}%"
+ }
+
+ private fun directionLabel(deg: Double): String {
+ val dirs = arrayOf("N","NE","E","SE","S","SW","W","NW")
+ val idx = ((deg + 22.5) / 45.0).toInt() % 8
+ return dirs[idx]
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemForecastBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ companion object {
+ private val DIFF = object : DiffUtil.ItemCallback<ForecastItem>() {
+ override fun areItemsTheSame(a: ForecastItem, b: ForecastItem) = a.timeIso == b.timeIso
+ override fun areContentsTheSame(a: ForecastItem, b: ForecastItem) = a == b
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt
new file mode 100644
index 0000000..6e213c3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt
@@ -0,0 +1,74 @@
+package org.terst.nav.ui.forecast
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.LinearLayoutManager
+import org.terst.nav.databinding.FragmentForecastBinding
+import org.terst.nav.ui.MainViewModel
+import org.terst.nav.ui.UiState
+import kotlinx.coroutines.launch
+
+class ForecastFragment : Fragment() {
+
+ private var _binding: FragmentForecastBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: MainViewModel by activityViewModels()
+ private val adapter = ForecastAdapter()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentForecastBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.forecastList.layoutManager = LinearLayoutManager(requireContext())
+ binding.forecastList.adapter = adapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.uiState.collect { state ->
+ when (state) {
+ UiState.Loading -> {
+ binding.progress.visibility = View.VISIBLE
+ binding.errorText.visibility = View.GONE
+ }
+ UiState.Success -> {
+ binding.progress.visibility = View.GONE
+ binding.errorText.visibility = View.GONE
+ }
+ is UiState.Error -> {
+ binding.progress.visibility = View.GONE
+ binding.errorText.visibility = View.VISIBLE
+ binding.errorText.text = state.message
+ }
+ }
+ }
+ }
+ launch {
+ viewModel.forecast.collect { items ->
+ adapter.submitList(items)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
new file mode 100644
index 0000000..ea7b596
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/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"
+ }
+}