summaryrefslogtreecommitdiff
path: root/android-app
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
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')
-rw-r--r--android-app/app/build.gradle35
-rw-r--r--android-app/app/proguard-rules.pro15
-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
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt88
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt57
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt49
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt101
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt105
30 files changed, 1397 insertions, 0 deletions
diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle
index 968b305..1d4f145 100644
--- a/android-app/app/build.gradle
+++ b/android-app/app/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
+ id 'kotlin-kapt'
}
android {
@@ -33,6 +34,10 @@ android {
jvmTarget = '1.8'
}
+ buildFeatures {
+ viewBinding true
+ }
+
sourceSets {
main {
kotlin.srcDirs = ['src/main/kotlin', 'src/main/java']
@@ -47,12 +52,42 @@ android {
}
dependencies {
+ // AndroidX core
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.fragment:fragment-ktx:1.6.2'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+
+ // Lifecycle / ViewModel / Coroutines
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+
+ // Networking
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
+ implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
+ // JSON parsing
+ implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
+ kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.15.0'
+
+ // Location
+ implementation 'com.google.android.gms:play-services-location:21.2.0'
+
+ // Map
+ implementation 'org.maplibre.gl:android-sdk:10.0.2'
+
+ // Testing
testImplementation 'junit:junit:4.13.2'
+ testImplementation 'io.mockk:mockk:1.13.9'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
+ testImplementation 'app.cash.turbine:turbine:1.1.0'
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
+
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..25bf917
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -0,0 +1,15 @@
+# Moshi
+-keep class com.example.androidapp.data.model.** { *; }
+-keepclassmembers class ** { @com.squareup.moshi.Json <fields>; }
+-keep @com.squareup.moshi.JsonClass class * { *; }
+
+# Retrofit
+-keepattributes Signature, Exceptions
+-keep class retrofit2.** { *; }
+-keepclassmembers,allowshrinking,allowobfuscation interface * {
+ @retrofit2.http.* <methods>;
+}
+
+# OkHttp
+-dontwarn okhttp3.**
+-dontwarn okio.**
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>
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt
new file mode 100644
index 0000000..ac2a652
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt
@@ -0,0 +1,88 @@
+package com.example.androidapp.data.api
+
+import com.example.androidapp.data.model.WeatherResponse
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import kotlinx.coroutines.test.runTest
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+
+class WeatherApiServiceTest {
+
+ private lateinit var mockServer: MockWebServer
+ private lateinit var service: WeatherApiService
+
+ @Before
+ fun setUp() {
+ mockServer = MockWebServer()
+ mockServer.start()
+
+ val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
+ service = Retrofit.Builder()
+ .baseUrl(mockServer.url("/"))
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+ .create(WeatherApiService::class.java)
+ }
+
+ @After
+ fun tearDown() {
+ mockServer.shutdown()
+ }
+
+ @Test
+ fun `getWeatherForecast sends correct query parameters`() = runTest {
+ mockServer.enqueue(MockResponse().setBody(WEATHER_JSON).setResponseCode(200))
+
+ service.getWeatherForecast(
+ latitude = 37.5,
+ longitude = -122.3,
+ hourly = "windspeed_10m,winddirection_10m,temperature_2m,precipitation_probability,weathercode",
+ forecastDays = 1,
+ windSpeedUnit = "kn"
+ )
+
+ val request = mockServer.takeRequest()
+ val url = request.requestUrl!!
+ assertEquals("37.5", url.queryParameter("latitude"))
+ assertEquals("-122.3", url.queryParameter("longitude"))
+ assertEquals("kn", url.queryParameter("wind_speed_unit"))
+ }
+
+ @Test
+ fun `getWeatherForecast parses response correctly`() = runTest {
+ mockServer.enqueue(MockResponse().setBody(WEATHER_JSON).setResponseCode(200))
+
+ val response = service.getWeatherForecast(37.5, -122.3)
+ assertEquals(37.5, response.latitude, 0.01)
+ assertEquals(2, response.hourly.time.size)
+ assertEquals(15.0, response.hourly.windspeed10m[0], 0.01)
+ assertEquals(270.0, response.hourly.winddirection10m[0], 0.01)
+ assertEquals(18.5, response.hourly.temperature2m[0], 0.01)
+ assertEquals(20, response.hourly.precipitationProbability[0])
+ assertEquals(1, response.hourly.weathercode[0])
+ }
+
+ companion object {
+ private val WEATHER_JSON = """
+ {
+ "latitude": 37.5,
+ "longitude": -122.3,
+ "hourly": {
+ "time": ["2026-03-13T00:00", "2026-03-13T01:00"],
+ "windspeed_10m": [15.0, 16.0],
+ "winddirection_10m": [270.0, 275.0],
+ "temperature_2m": [18.5, 18.0],
+ "precipitation_probability": [20, 25],
+ "weathercode": [1, 1]
+ }
+ }
+ """.trimIndent()
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt
new file mode 100644
index 0000000..f0a903f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt
@@ -0,0 +1,57 @@
+package com.example.androidapp.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class ForecastItemTest {
+
+ private fun makeItem(windKt: Double = 10.0, precipPct: Int = 0, weatherCode: Int = 0) =
+ ForecastItem(
+ timeIso = "2026-03-13T12:00",
+ windKt = windKt,
+ windDirDeg = 180.0,
+ tempC = 15.0,
+ precipProbabilityPct = precipPct,
+ weatherCode = weatherCode
+ )
+
+ @Test
+ fun `ForecastItem stores all fields correctly`() {
+ val item = makeItem(windKt = 12.5, precipPct = 30, weatherCode = 61)
+ assertEquals("2026-03-13T12:00", item.timeIso)
+ assertEquals(12.5, item.windKt, 0.001)
+ assertEquals(30, item.precipProbabilityPct)
+ assertEquals(61, item.weatherCode)
+ }
+
+ @Test
+ fun `weatherDescription returns rain for code 61`() {
+ val item = makeItem(weatherCode = 61)
+ assertTrue(item.weatherDescription().contains("Rain", ignoreCase = true))
+ }
+
+ @Test
+ fun `weatherDescription returns clear for code 0`() {
+ val item = makeItem(weatherCode = 0)
+ assertTrue(item.weatherDescription().contains("Clear", ignoreCase = true))
+ }
+
+ @Test
+ fun `weatherDescription returns cloudy for code 2`() {
+ val item = makeItem(weatherCode = 2)
+ assertTrue(item.weatherDescription().contains("Cloud", ignoreCase = true))
+ }
+
+ @Test
+ fun `isRainy returns true for rain codes 51 to 67`() {
+ assertTrue(makeItem(weatherCode = 51).isRainy())
+ assertTrue(makeItem(weatherCode = 63).isRainy())
+ assertTrue(makeItem(weatherCode = 67).isRainy())
+ }
+
+ @Test
+ fun `isRainy returns false for clear codes`() {
+ assertFalse(makeItem(weatherCode = 0).isRainy())
+ assertFalse(makeItem(weatherCode = 1).isRainy())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt
new file mode 100644
index 0000000..b61e6fb
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt
@@ -0,0 +1,49 @@
+package com.example.androidapp.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class WindArrowTest {
+
+ @Test
+ fun `WindArrow holds lat lon speed direction`() {
+ val arrow = WindArrow(lat = 37.5, lon = -122.3, speedKt = 15.0, directionDeg = 270.0)
+ assertEquals(37.5, arrow.lat, 0.001)
+ assertEquals(-122.3, arrow.lon, 0.001)
+ assertEquals(15.0, arrow.speedKt, 0.001)
+ assertEquals(270.0, arrow.directionDeg, 0.001)
+ }
+
+ @Test
+ fun `WindArrow with zero speed is calm`() {
+ val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 0.0, directionDeg = 0.0)
+ assertEquals(0.0, arrow.speedKt, 0.001)
+ assertTrue(arrow.isCalm())
+ }
+
+ @Test
+ fun `WindArrow isCalm returns false when speed above threshold`() {
+ val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 5.0, directionDeg = 90.0)
+ assertFalse(arrow.isCalm())
+ }
+
+ @Test
+ fun `WindArrow direction 360 is normalised to 0`() {
+ val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 10.0, directionDeg = 360.0)
+ assertEquals(0.0, arrow.normalisedDirection(), 0.001)
+ }
+
+ @Test
+ fun `WindArrow direction within 0 to 359 is unchanged`() {
+ val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 10.0, directionDeg = 180.0)
+ assertEquals(180.0, arrow.normalisedDirection(), 0.001)
+ }
+
+ @Test
+ fun `WindArrow beaufortScale returns correct force for various speeds`() {
+ assertEquals(0, WindArrow(0.0, 0.0, 0.0, 0.0).beaufortScale()) // calm
+ assertEquals(1, WindArrow(0.0, 0.0, 2.0, 0.0).beaufortScale()) // light air
+ assertEquals(3, WindArrow(0.0, 0.0, 9.0, 0.0).beaufortScale()) // gentle
+ assertEquals(7, WindArrow(0.0, 0.0, 30.0, 0.0).beaufortScale()) // near gale
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt
new file mode 100644
index 0000000..e1bf288
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt
@@ -0,0 +1,101 @@
+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.*
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class WeatherRepositoryTest {
+
+ private val weatherApi = mockk<WeatherApiService>()
+ private val marineApi = mockk<MarineApiService>()
+ private lateinit var repo: WeatherRepository
+
+ private val weatherResponse = WeatherResponse(
+ latitude = 37.5,
+ longitude = -122.3,
+ hourly = WeatherHourly(
+ time = listOf("2026-03-13T00:00", "2026-03-13T01:00"),
+ windspeed10m = listOf(15.0, 16.0),
+ winddirection10m = listOf(270.0, 275.0),
+ temperature2m = listOf(18.5, 18.0),
+ precipitationProbability = listOf(20, 25),
+ weathercode = listOf(1, 1)
+ )
+ )
+
+ private val marineResponse = MarineResponse(
+ latitude = 37.5,
+ longitude = -122.3,
+ hourly = MarineHourly(
+ time = listOf("2026-03-13T00:00", "2026-03-13T01:00"),
+ waveHeight = listOf(1.2, 1.1),
+ waveDirection = listOf(250.0, 255.0),
+ oceanCurrentVelocity = listOf(0.3, 0.4),
+ oceanCurrentDirection = listOf(180.0, 185.0)
+ )
+ )
+
+ @Before
+ fun setUp() {
+ repo = WeatherRepository(weatherApi, marineApi)
+ }
+
+ @Test
+ fun `fetchForecastItems maps weather response to ForecastItem list`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchForecastItems(37.5, -122.3)
+
+ assertTrue(result.isSuccess)
+ val items = result.getOrThrow()
+ assertEquals(2, items.size)
+ assertEquals("2026-03-13T00:00", items[0].timeIso)
+ assertEquals(15.0, items[0].windKt, 0.001)
+ assertEquals(270.0, items[0].windDirDeg, 0.001)
+ assertEquals(18.5, items[0].tempC, 0.001)
+ assertEquals(20, items[0].precipProbabilityPct)
+ assertEquals(1, items[0].weatherCode)
+ }
+
+ @Test
+ fun `fetchWindArrow returns WindArrow for first (current) hour`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchWindArrow(37.5, -122.3)
+
+ assertTrue(result.isSuccess)
+ val arrow = result.getOrThrow()
+ assertEquals(37.5, arrow.lat, 0.001)
+ assertEquals(-122.3, arrow.lon, 0.001)
+ assertEquals(15.0, arrow.speedKt, 0.001)
+ assertEquals(270.0, arrow.directionDeg, 0.001)
+ }
+
+ @Test
+ fun `fetchForecastItems returns failure when weather API throws`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Network error")
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchForecastItems(37.5, -122.3)
+
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `fetchWindArrow returns failure when API throws`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Timeout")
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchWindArrow(37.5, -122.3)
+
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt
new file mode 100644
index 0000000..cb5f6f9
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt
@@ -0,0 +1,105 @@
+package com.example.androidapp.ui
+
+import app.cash.turbine.test
+import com.example.androidapp.data.model.ForecastItem
+import com.example.androidapp.data.model.WindArrow
+import com.example.androidapp.data.repository.WeatherRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val repo = mockk<WeatherRepository>()
+ private lateinit var vm: MainViewModel
+
+ private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0)
+ private val sampleForecast = listOf(
+ ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1)
+ )
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun makeVm() = MainViewModel(repo)
+
+ @Test
+ fun `initial uiState is Loading`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) }
+ coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) }
+ vm = makeVm()
+ // Before loadWeather() is called the state is Loading
+ assertEquals(UiState.Loading, vm.uiState.value)
+ }
+
+ @Test
+ fun `loadWeather success transitions to Success state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ assertEquals(UiState.Loading, awaitItem())
+ vm.loadWeather(37.5, -122.3)
+ assertEquals(UiState.Success, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather populates windArrow and forecast`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+ vm.loadWeather(37.5, -122.3)
+
+ assertEquals(sampleArrow, vm.windArrow.value)
+ assertEquals(sampleForecast, vm.forecast.value)
+ }
+
+ @Test
+ fun `loadWeather arrow failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error"))
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ assertTrue((state as UiState.Error).message.contains("Net error"))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather forecast failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout"))
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}