summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-13 19:59:01 +0000
committerClaudomator Agent <agent@claudomator>2026-03-13 19:59:01 +0000
commit51f86cff118f9532783c4e61724e07173ec029d7 (patch)
tree1c5601142391003830527f0c97d8ef7fa4145052 /android-app/app/src/main
parent7e40bd03ab0246552d26d92fda8623b8da4653f3 (diff)
feat: add wind/current map overlay and weather forecast on startup
Implements the wind/current map overlay and 7-day weather forecast display that loads on application launch: Data layer: - Open-Meteo API (free, no key): WeatherApiService + MarineApiService - Moshi-annotated response models (WeatherResponse, MarineResponse) - WeatherRepository: parallel async fetch, Result<T> error propagation - Domain models: WindArrow (with beaufortScale/isCalm) + ForecastItem (with weatherDescription/isRainy via WMO weather codes) Presentation layer: - MainViewModel: StateFlow<UiState> (Loading/Success/Error), windArrow and forecast streams - MainActivity: runtime location permission, FusedLocationProvider GPS fetch on startup, falls back to SF Bay default - MapFragment: MapLibre GL map with wind-arrow SymbolLayer; icon rotated by wind direction, size scaled by speed in knots - ForecastFragment + ForecastAdapter: RecyclerView ListAdapter showing 7-day hourly forecast (time, description, wind, temp, precip) Resources: - ic_wind_arrow.xml vector drawable (north-pointing, rotated by MapLibre) - BottomNavigationView: Map / Forecast tabs - ViewBinding enabled throughout Tests (TDD — written before implementation): - WindArrowTest: calm detection, direction normalisation, Beaufort scale - ForecastItemTest: weatherDescription, isRainy for WMO codes - WeatherApiServiceTest: MockWebServer request params + response parsing - WeatherRepositoryTest: MockK service mocks, data mapping, error paths - MainViewModelTest: Turbine StateFlow assertions for all state transitions 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/AndroidManifest.xml27
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt35
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt17
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt18
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt30
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt20
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt21
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt31
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt57
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt114
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt63
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt54
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt74
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt167
-rw-r--r--android-app/app/src/main/res/drawable/ic_wind_arrow.xml21
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml22
-rw-r--r--android-app/app/src/main/res/layout/fragment_forecast.xml42
-rw-r--r--android-app/app/src/main/res/layout/fragment_map.xml30
-rw-r--r--android-app/app/src/main/res/layout/item_forecast.xml59
-rw-r--r--android-app/app/src/main/res/menu/bottom_nav_menu.xml11
-rw-r--r--android-app/app/src/main/res/values/colors.xml12
-rw-r--r--android-app/app/src/main/res/values/strings.xml13
-rw-r--r--android-app/app/src/main/res/values/themes.xml9
23 files changed, 947 insertions, 0 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..86b9c75
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+
+ <application
+ android:allowBackup="true"
+ android:icon="@android:drawable/ic_dialog_map"
+ android:label="@string/app_name"
+ android:roundIcon="@android:drawable/ic_dialog_map"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.NavApp">
+
+ <activity
+ android:name=".ui.MainActivity"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ </application>
+
+</manifest>
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt
new file mode 100644
index 0000000..dd53f2e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt
@@ -0,0 +1,35 @@
+package com.example.androidapp.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/com/example/androidapp/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt
new file mode 100644
index 0000000..641cebc
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt
@@ -0,0 +1,17 @@
+package com.example.androidapp.data.api
+
+import com.example.androidapp.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/com/example/androidapp/data/api/WeatherApiService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt
new file mode 100644
index 0000000..0d53ff9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt
@@ -0,0 +1,18 @@
+package com.example.androidapp.data.api
+
+import com.example.androidapp.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/com/example/androidapp/data/model/ForecastItem.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt
new file mode 100644
index 0000000..3c3fc4d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt
@@ -0,0 +1,30 @@
+package com.example.androidapp.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/com/example/androidapp/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt
new file mode 100644
index 0000000..8bbacb1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt
@@ -0,0 +1,20 @@
+package com.example.androidapp.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/com/example/androidapp/data/model/WeatherResponse.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt
new file mode 100644
index 0000000..89d8a11
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt
@@ -0,0 +1,21 @@
+package com.example.androidapp.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/com/example/androidapp/data/model/WindArrow.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt
new file mode 100644
index 0000000..48699da
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt
@@ -0,0 +1,31 @@
+package com.example.androidapp.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/com/example/androidapp/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt
new file mode 100644
index 0000000..6affdbd
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt
@@ -0,0 +1,57 @@
+package com.example.androidapp.data.repository
+
+import com.example.androidapp.data.api.MarineApiService
+import com.example.androidapp.data.api.WeatherApiService
+import com.example.androidapp.data.model.ForecastItem
+import com.example.androidapp.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 = com.example.androidapp.data.api.ApiClient
+ return WeatherRepository(client.weatherApi, client.marineApi)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt
new file mode 100644
index 0000000..0a326f4
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt
@@ -0,0 +1,114 @@
+package com.example.androidapp.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 com.example.androidapp.R
+import com.example.androidapp.databinding.ActivityMainBinding
+import com.example.androidapp.ui.forecast.ForecastFragment
+import com.example.androidapp.ui.map.MapFragment
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ 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 locationPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { grants ->
+ val granted = grants.values.any { it }
+ if (granted) fetchLocationAndLoad() else loadWeatherAtDefault()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.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() {
+ val fine = Manifest.permission.ACCESS_FINE_LOCATION
+ val coarse = Manifest.permission.ACCESS_COARSE_LOCATION
+ val hasPermission = ContextCompat.checkSelfPermission(this, fine) ==
+ PackageManager.PERMISSION_GRANTED
+ if (hasPermission) {
+ fetchLocationAndLoad()
+ } else {
+ locationPermissionLauncher.launch(arrayOf(fine, coarse))
+ }
+ }
+
+ 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/com/example/androidapp/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt
new file mode 100644
index 0000000..eabb594
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt
@@ -0,0 +1,63 @@
+package com.example.androidapp.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.androidapp.data.model.ForecastItem
+import com.example.androidapp.data.model.WindArrow
+import com.example.androidapp.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/com/example/androidapp/ui/forecast/ForecastAdapter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt
new file mode 100644
index 0000000..06c5eed
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt
@@ -0,0 +1,54 @@
+package com.example.androidapp.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 com.example.androidapp.data.model.ForecastItem
+import com.example.androidapp.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/com/example/androidapp/ui/forecast/ForecastFragment.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt
new file mode 100644
index 0000000..a8be8f6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt
@@ -0,0 +1,74 @@
+package com.example.androidapp.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 com.example.androidapp.databinding.FragmentForecastBinding
+import com.example.androidapp.ui.MainViewModel
+import com.example.androidapp.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/com/example/androidapp/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt
new file mode 100644
index 0000000..82dd999
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt
@@ -0,0 +1,167 @@
+package com.example.androidapp.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 com.example.androidapp.R
+import com.example.androidapp.data.model.WindArrow
+import com.example.androidapp.databinding.FragmentMapBinding
+import com.example.androidapp.ui.MainViewModel
+import com.example.androidapp.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"
+ }
+}
diff --git a/android-app/app/src/main/res/drawable/ic_wind_arrow.xml b/android-app/app/src/main/res/drawable/ic_wind_arrow.xml
new file mode 100644
index 0000000..110f1b3
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_wind_arrow.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Wind-direction arrow pointing UP (north).
+ MapLibre rotates the icon to match wind direction via icon-rotate expression.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+
+ <!-- Arrowhead pointing up -->
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M12,2 L18,14 L12,11 L6,14 Z" />
+ <!-- Shaft -->
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M11,11 L11,22 L13,22 L13,11 Z" />
+
+</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
new file mode 100644
index 0000000..757dbdb
--- /dev/null
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/fragment_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginBottom="56dp" />
+
+ <com.google.android.material.bottomnavigation.BottomNavigationView
+ android:id="@+id/bottom_nav"
+ android:layout_width="match_parent"
+ android:layout_height="56dp"
+ android:layout_gravity="bottom"
+ android:background="?attr/colorSurface"
+ app:menu="@menu/bottom_nav_menu" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_forecast.xml b/android-app/app/src/main/res/layout/fragment_forecast.xml
new file mode 100644
index 0000000..aca38ba
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_forecast.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:background="?attr/colorSurface">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="16dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="8dp"
+ android:text="7-Day Forecast"
+ android:textAppearance="?attr/textAppearanceHeadline6"
+ android:textColor="?attr/colorOnSurface" />
+
+ <ProgressBar
+ android:id="@+id/progress"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:visibility="gone" />
+
+ <TextView
+ android:id="@+id/error_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp"
+ android:textColor="@color/wind_strong"
+ android:visibility="gone" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/forecast_list"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:clipToPadding="false"
+ android:paddingBottom="8dp" />
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml
new file mode 100644
index 0000000..e5b86b7
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_map.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/map_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:maplibre_cameraTargetLat="37.0"
+ app:maplibre_cameraTargetLng="-122.0"
+ app:maplibre_cameraZoom="5.0" />
+
+ <!-- Loading / error overlay -->
+ <TextView
+ android:id="@+id/status_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|center_horizontal"
+ android:layout_marginTop="16dp"
+ android:background="#AA000000"
+ android:paddingHorizontal="12dp"
+ android:paddingVertical="4dp"
+ android:textColor="#FFFFFF"
+ android:textSize="14sp"
+ android:visibility="gone" />
+
+</FrameLayout>
diff --git a/android-app/app/src/main/res/layout/item_forecast.xml b/android-app/app/src/main/res/layout/item_forecast.xml
new file mode 100644
index 0000000..473661a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_forecast.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="12dp"
+ android:gravity="center_vertical">
+
+ <!-- Time -->
+ <TextView
+ android:id="@+id/tv_time"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="2"
+ android:textAppearance="?attr/textAppearanceBody2"
+ android:textColor="?attr/colorOnSurface" />
+
+ <!-- Weather description -->
+ <TextView
+ android:id="@+id/tv_description"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="3"
+ android:textAppearance="?attr/textAppearanceBody2"
+ android:textColor="?attr/colorOnSurface" />
+
+ <!-- Wind -->
+ <TextView
+ android:id="@+id/tv_wind"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="2"
+ android:gravity="end"
+ android:textAppearance="?attr/textAppearanceBody2"
+ android:textColor="?attr/colorOnSurface" />
+
+ <!-- Temperature -->
+ <TextView
+ android:id="@+id/tv_temp"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1.5"
+ android:gravity="end"
+ android:textAppearance="?attr/textAppearanceBody2"
+ android:textColor="?attr/colorOnSurface" />
+
+ <!-- Precip probability -->
+ <TextView
+ android:id="@+id/tv_precip"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1.5"
+ android:gravity="end"
+ android:textAppearance="?attr/textAppearanceBody2"
+ android:textColor="@color/primary" />
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
new file mode 100644
index 0000000..6922dee
--- /dev/null
+++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+ <item
+ android:id="@+id/nav_map"
+ android:icon="@android:drawable/ic_dialog_map"
+ android:title="@string/nav_map" />
+ <item
+ android:id="@+id/nav_forecast"
+ android:icon="@android:drawable/ic_dialog_info"
+ android:title="@string/nav_forecast" />
+</menu>
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..2382364
--- /dev/null
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="primary">#0D47A1</color>
+ <color name="primary_dark">#002171</color>
+ <color name="accent">#FF6D00</color>
+ <color name="surface">#FFFFFF</color>
+ <color name="on_primary">#FFFFFF</color>
+ <color name="wind_arrow">#FFFFFFFF</color>
+ <color name="wind_slow">#4CAF50</color>
+ <color name="wind_medium">#FF9800</color>
+ <color name="wind_strong">#F44336</color>
+</resources>
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..b7d3bd8
--- /dev/null
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Nav</string>
+ <string name="nav_map">Map</string>
+ <string name="nav_forecast">Forecast</string>
+ <string name="loading_weather">Fetching weather…</string>
+ <string name="error_location">Could not get location. Showing default position.</string>
+ <string name="error_weather">Failed to load weather data.</string>
+ <string name="wind_speed_fmt">%.0f kt</string>
+ <string name="temp_fmt">%.0f °C</string>
+ <string name="precip_fmt">%d%%</string>
+ <string name="permission_rationale">Location is needed to show weather for your current position.</string>
+</resources>
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..cecd32f
--- /dev/null
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.NavApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
+ <item name="colorPrimary">@color/primary</item>
+ <item name="colorPrimaryDark">@color/primary_dark</item>
+ <item name="colorAccent">@color/accent</item>
+ <item name="android:statusBarColor">@color/primary_dark</item>
+ </style>
+</resources>