From cc76e4f3cc4e4d958f398ed2899a8d653815985b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 15 Mar 2026 06:52:23 +0000 Subject: fix: move weather feature to org/terst/nav package directories Package declarations were already org.terst.nav.* but files lived under com/example/androidapp/. Kotlin 2.0 (K2) compiler on CI fails when package declarations don't match directory structure during kapt stub generation. Moved all 20 files to their correct locations and renamed MainActivity (weather) -> WeatherActivity to avoid confusion with the nav app's MainActivity. Co-Authored-By: Claude Sonnet 4.6 --- .../com/example/androidapp/data/api/ApiClient.kt | 35 ----- .../androidapp/data/api/MarineApiService.kt | 17 --- .../androidapp/data/api/WeatherApiService.kt | 18 --- .../example/androidapp/data/model/ForecastItem.kt | 30 ---- .../androidapp/data/model/MarineResponse.kt | 20 --- .../androidapp/data/model/WeatherResponse.kt | 21 --- .../com/example/androidapp/data/model/WindArrow.kt | 31 ---- .../data/repository/WeatherRepository.kt | 57 ------- .../androidapp/ui/LocationPermissionHandler.kt | 43 ------ .../com/example/androidapp/ui/MainActivity.kt | 125 --------------- .../com/example/androidapp/ui/MainViewModel.kt | 63 -------- .../androidapp/ui/forecast/ForecastAdapter.kt | 54 ------- .../androidapp/ui/forecast/ForecastFragment.kt | 74 --------- .../com/example/androidapp/ui/map/MapFragment.kt | 167 --------------------- .../main/kotlin/org/terst/nav/AnchorWatchData.kt | 35 +++++ .../kotlin/org/terst/nav/data/api/ApiClient.kt | 35 +++++ .../org/terst/nav/data/api/MarineApiService.kt | 17 +++ .../org/terst/nav/data/api/WeatherApiService.kt | 18 +++ .../org/terst/nav/data/model/ForecastItem.kt | 30 ++++ .../org/terst/nav/data/model/MarineResponse.kt | 20 +++ .../org/terst/nav/data/model/WeatherResponse.kt | 21 +++ .../kotlin/org/terst/nav/data/model/WindArrow.kt | 31 ++++ .../terst/nav/data/repository/WeatherRepository.kt | 57 +++++++ .../org/terst/nav/ui/LocationPermissionHandler.kt | 43 ++++++ .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 63 ++++++++ .../kotlin/org/terst/nav/ui/WeatherActivity.kt | 125 +++++++++++++++ .../org/terst/nav/ui/forecast/ForecastAdapter.kt | 54 +++++++ .../org/terst/nav/ui/forecast/ForecastFragment.kt | 74 +++++++++ .../kotlin/org/terst/nav/ui/map/MapFragment.kt | 167 +++++++++++++++++++++ .../androidapp/data/api/WeatherApiServiceTest.kt | 88 ----------- .../androidapp/data/model/ForecastItemTest.kt | 57 ------- .../example/androidapp/data/model/WindArrowTest.kt | 49 ------ .../data/repository/WeatherRepositoryTest.kt | 101 ------------- .../androidapp/ui/LocationPermissionHandlerTest.kt | 110 -------------- .../com/example/androidapp/ui/MainViewModelTest.kt | 105 ------------- .../terst/nav/data/api/WeatherApiServiceTest.kt | 88 +++++++++++ .../org/terst/nav/data/model/ForecastItemTest.kt | 57 +++++++ .../org/terst/nav/data/model/WindArrowTest.kt | 49 ++++++ .../nav/data/repository/WeatherRepositoryTest.kt | 101 +++++++++++++ .../terst/nav/ui/LocationPermissionHandlerTest.kt | 110 ++++++++++++++ .../kotlin/org/terst/nav/ui/MainViewModelTest.kt | 105 +++++++++++++ 41 files changed, 1300 insertions(+), 1265 deletions(-) delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt delete mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt delete mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/data/api/WeatherApiServiceTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/data/model/ForecastItemTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/data/model/WindArrowTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/ui/LocationPermissionHandlerTest.kt create mode 100644 android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt (limited to 'android-app/app/src') 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 deleted file mode 100644 index dd53f2e..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 641cebc..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0d53ff9..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 3c3fc4d..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 8bbacb1..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -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, - @Json(name = "wave_height") val waveHeight: List, - @Json(name = "wave_direction") val waveDirection: List, - @Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List, - @Json(name = "ocean_current_direction") val oceanCurrentDirection: List -) 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 deleted file mode 100644 index 89d8a11..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt +++ /dev/null @@ -1,21 +0,0 @@ -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, - @Json(name = "windspeed_10m") val windspeed10m: List, - @Json(name = "winddirection_10m") val winddirection10m: List, - @Json(name = "temperature_2m") val temperature2m: List, - @Json(name = "precipitation_probability") val precipitationProbability: List, - @Json(name = "weathercode") val weathercode: List -) 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 deleted file mode 100644 index 48699da..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 6affdbd..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt +++ /dev/null @@ -1,57 +0,0 @@ -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> = - 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 = - 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/LocationPermissionHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt deleted file mode 100644 index 664d5bb..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.androidapp.ui - -/** - * Encapsulates location permission decision logic. - * - * Extracted for testability — no direct Android framework dependency in the core logic. - * - * Permissions handled: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION - * - * Usage: - * - Call [start] on activity start to check existing permission or trigger a request. - * - Call [onResult] from the ActivityResultLauncher callback with the permission grants map. - */ -class LocationPermissionHandler( - /** Returns true if location permission is already granted. */ - private val checkGranted: () -> Boolean, - /** Called when location permission is available (already granted or just granted). */ - private val onGranted: () -> Unit, - /** Called when location permission is denied or the user refuses (including "never ask again"). */ - private val onDenied: () -> Unit, - /** Called when permission needs to be requested from the user via the system dialog. */ - private val requestPermissions: () -> Unit -) { - /** - * Check current permission state and dispatch: - * - If already granted, invoke [onGranted] immediately. - * - Otherwise, invoke [requestPermissions] to trigger the system dialog. - */ - fun start() { - if (checkGranted()) onGranted() else requestPermissions() - } - - /** - * Process the result from the system permission dialog. - * - * @param grants Map of permission name → granted status from ActivityResultLauncher. - * Invokes [onGranted] if any permission was granted, [onDenied] otherwise. - * An empty map (e.g. "never ask again" scenario) also triggers [onDenied]. - */ - fun onResult(grants: Map) { - if (grants.values.any { it }) onGranted() else onDenied() - } -} 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 deleted file mode 100644 index b29aefa..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt +++ /dev/null @@ -1,125 +0,0 @@ -package org.terst.nav.ui - -import android.Manifest -import android.content.pm.PackageManager -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import org.terst.nav.R -import org.terst.nav.databinding.ActivityWeatherBinding -import org.terst.nav.ui.forecast.ForecastFragment -import org.terst.nav.ui.map.MapFragment -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority - -class MainActivity : AppCompatActivity() { - - private lateinit var binding: ActivityWeatherBinding - private val viewModel: MainViewModel by viewModels() - - // Default position (San Francisco Bay) used when location is unavailable - private val defaultLat = 37.8 - private val defaultLon = -122.4 - - private val permissionHandler: LocationPermissionHandler by lazy { - LocationPermissionHandler( - checkGranted = { - ContextCompat.checkSelfPermission( - this, Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - }, - onGranted = { fetchLocationAndLoad() }, - onDenied = { loadWeatherAtDefault() }, - requestPermissions = { - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - } - ) - } - - private val locationPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { grants -> - permissionHandler.onResult(grants) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityWeatherBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupBottomNav() - - if (savedInstanceState == null) { - showFragment(MapFragment(), TAG_MAP) - requestLocationOrLoad() - } - } - - private fun setupBottomNav() { - binding.bottomNav.setOnItemSelectedListener { item -> - when (item.itemId) { - R.id.nav_map -> { - showFragment(MapFragment(), TAG_MAP) - true - } - R.id.nav_forecast -> { - showFragment(ForecastFragment(), TAG_FORECAST) - true - } - else -> false - } - } - } - - private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) { - val existing = supportFragmentManager.findFragmentByTag(tag) - supportFragmentManager.beginTransaction() - .apply { - supportFragmentManager.fragments.forEach { hide(it) } - if (existing == null) add(R.id.fragment_container, fragment, tag) - else show(existing) - } - .commit() - } - - private fun requestLocationOrLoad() { - permissionHandler.start() - } - - private fun fetchLocationAndLoad() { - val client = LocationServices.getFusedLocationProviderClient(this) - try { - client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .addOnSuccessListener { location -> - if (location != null) { - viewModel.loadWeather(location.latitude, location.longitude) - } else { - loadWeatherAtDefault() - } - } - .addOnFailureListener { - loadWeatherAtDefault() - } - } catch (e: SecurityException) { - loadWeatherAtDefault() - } - } - - private fun loadWeatherAtDefault() { - Toast.makeText(this, R.string.error_location, Toast.LENGTH_SHORT).show() - viewModel.loadWeather(defaultLat, defaultLon) - } - - companion object { - private const val TAG_MAP = "map" - private const val TAG_FORECAST = "forecast" - } -} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt deleted file mode 100644 index eabb594..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -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.Loading) - val uiState: StateFlow = _uiState - - private val _windArrow = MutableStateFlow(null) - val windArrow: StateFlow = _windArrow - - private val _forecast = MutableStateFlow>(emptyList()) - val forecast: StateFlow> = _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 deleted file mode 100644 index 06c5eed..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -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(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() { - 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 deleted file mode 100644 index a8be8f6..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 82dd999..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt +++ /dev/null @@ -1,167 +0,0 @@ -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/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt index 03e6a2f..0c63662 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt @@ -1,6 +1,7 @@ package org.terst.nav import android.location.Location +import kotlin.math.* data class AnchorWatchState( val anchorLocation: Location? = null, @@ -10,6 +11,40 @@ data class AnchorWatchState( ) { companion object { const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters + + /** + * Calculates the recommended watch circle radius based on depth, freeboard, and rode out. + * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out)) + * + * @param depthMeters Depth from surface to seabed in meters. + * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters. + * @param rodeOutMeters Length of chain/rode deployed in meters. + * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid. + */ + fun calculateRecommendedWatchCircleRadius( + depthMeters: Double, + freeboardMeters: Double, + rodeOutMeters: Double + ): Double { + if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) { + return 0.0 // Invalid inputs + } + + val totalVerticalDistance = depthMeters + freeboardMeters + + // Ensure we don't take asin of a value > 1 or < -1 + if (totalVerticalDistance > rodeOutMeters) { + // Rode is too short for the depth+freeboard, effectively boat is directly above anchor + // In this case, the watch circle radius is 0, or very small. + return 0.0 + } + + // angle = asin( (Depth + Freeboard) / Rode Out ) + val angle = asin(totalVerticalDistance / rodeOutMeters) + + // Watch circle radius = Rode Out * cos(angle) + return rodeOutMeters * cos(angle) + } } fun isDragging(currentLocation: Location): Boolean { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt new file mode 100644 index 0000000..658f6dd --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/ApiClient.kt @@ -0,0 +1,35 @@ +package org.terst.nav.data.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +object ApiClient { + + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + .build() + + private fun retrofit(baseUrl: String): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + val weatherApi: WeatherApiService by lazy { + retrofit("https://api.open-meteo.com/").create(WeatherApiService::class.java) + } + + val marineApi: MarineApiService by lazy { + retrofit("https://marine-api.open-meteo.com/").create(MarineApiService::class.java) + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt new file mode 100644 index 0000000..67c14f8 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt @@ -0,0 +1,17 @@ +package org.terst.nav.data.api + +import org.terst.nav.data.model.MarineResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface MarineApiService { + + @GET("v1/marine") + suspend fun getMarineForecast( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("hourly") hourly: String = + "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction", + @Query("forecast_days") forecastDays: Int = 7 + ): MarineResponse +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt new file mode 100644 index 0000000..9713bcd --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt @@ -0,0 +1,18 @@ +package org.terst.nav.data.api + +import org.terst.nav.data.model.WeatherResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface WeatherApiService { + + @GET("v1/forecast") + suspend fun getWeatherForecast( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("hourly") hourly: String = + "windspeed_10m,winddirection_10m,temperature_2m,precipitation_probability,weathercode", + @Query("forecast_days") forecastDays: Int = 7, + @Query("wind_speed_unit") windSpeedUnit: String = "kn" + ): WeatherResponse +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt new file mode 100644 index 0000000..9b5493e --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/ForecastItem.kt @@ -0,0 +1,30 @@ +package org.terst.nav.data.model + +/** One hourly weather forecast slot shown in the forecast panel. */ +data class ForecastItem( + val timeIso: String, + val windKt: Double, + val windDirDeg: Double, + val tempC: Double, + val precipProbabilityPct: Int, + val weatherCode: Int +) { + /** Human-readable description based on WMO weather code. */ + fun weatherDescription(): String = when (weatherCode) { + 0 -> "Clear sky" + 1 -> "Mainly clear" + 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, 48 -> "Fog" + 51, 53, 55 -> "Drizzle" + 61, 63, 65 -> "Rain" + 71, 73, 75 -> "Snow" + 80, 81, 82 -> "Rain showers" + 95 -> "Thunderstorm" + 96, 99 -> "Thunderstorm with hail" + else -> "Unknown ($weatherCode)" + } + + /** WMO codes 51-67 and 80-82 are precipitation. */ + fun isRainy(): Boolean = weatherCode in 51..67 || weatherCode in 80..82 +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt new file mode 100644 index 0000000..ab9799b --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt @@ -0,0 +1,20 @@ +package org.terst.nav.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MarineResponse( + @Json(name = "latitude") val latitude: Double, + @Json(name = "longitude") val longitude: Double, + @Json(name = "hourly") val hourly: MarineHourly +) + +@JsonClass(generateAdapter = true) +data class MarineHourly( + @Json(name = "time") val time: List, + @Json(name = "wave_height") val waveHeight: List, + @Json(name = "wave_direction") val waveDirection: List, + @Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List, + @Json(name = "ocean_current_direction") val oceanCurrentDirection: List +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt new file mode 100644 index 0000000..784f17a --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WeatherResponse.kt @@ -0,0 +1,21 @@ +package org.terst.nav.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WeatherResponse( + @Json(name = "latitude") val latitude: Double, + @Json(name = "longitude") val longitude: Double, + @Json(name = "hourly") val hourly: WeatherHourly +) + +@JsonClass(generateAdapter = true) +data class WeatherHourly( + @Json(name = "time") val time: List, + @Json(name = "windspeed_10m") val windspeed10m: List, + @Json(name = "winddirection_10m") val winddirection10m: List, + @Json(name = "temperature_2m") val temperature2m: List, + @Json(name = "precipitation_probability") val precipitationProbability: List, + @Json(name = "weathercode") val weathercode: List +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt new file mode 100644 index 0000000..aa4ca99 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindArrow.kt @@ -0,0 +1,31 @@ +package org.terst.nav.data.model + +/** A wind vector at a geographic point, used for map overlay rendering. */ +data class WindArrow( + val lat: Double, + val lon: Double, + val speedKt: Double, + val directionDeg: Double +) { + fun isCalm(): Boolean = speedKt < 1.0 + + /** Normalise 360° → 0°; all other values unchanged. */ + fun normalisedDirection(): Double = if (directionDeg >= 360.0) 0.0 else directionDeg + + /** Beaufort scale 0-12 from wind speed in knots. */ + fun beaufortScale(): Int = when { + speedKt < 1 -> 0 + speedKt < 4 -> 1 + speedKt < 7 -> 2 + speedKt < 11 -> 3 + speedKt < 16 -> 4 + speedKt < 22 -> 5 + speedKt < 28 -> 6 + speedKt < 34 -> 7 + speedKt < 41 -> 8 + speedKt < 48 -> 9 + speedKt < 56 -> 10 + speedKt < 64 -> 11 + else -> 12 + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..ee586a5 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -0,0 +1,57 @@ +package org.terst.nav.data.repository + +import org.terst.nav.data.api.MarineApiService +import org.terst.nav.data.api.WeatherApiService +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.WindArrow + +class WeatherRepository( + private val weatherApi: WeatherApiService, + private val marineApi: MarineApiService +) { + + /** + * Fetch 7-day hourly forecast items for the given position. + * Both weather and marine data are requested; only weather fields are needed for ForecastItem, + * but marine is fetched here to prime the cache for wind-arrow use. + */ + suspend fun fetchForecastItems(lat: Double, lon: Double): Result> = + 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 = + runCatching { + val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) + val h = weather.hourly + WindArrow( + lat = lat, + lon = lon, + speedKt = h.windspeed10m.firstOrNull() ?: 0.0, + directionDeg = h.winddirection10m.firstOrNull() ?: 0.0 + ) + } + + companion object { + /** Factory using the shared ApiClient singletons. */ + fun create(): WeatherRepository { + val client = org.terst.nav.data.api.ApiClient + return WeatherRepository(client.weatherApi, client.marineApi) + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt new file mode 100644 index 0000000..cbb2fc1 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LocationPermissionHandler.kt @@ -0,0 +1,43 @@ +package org.terst.nav.ui + +/** + * Encapsulates location permission decision logic. + * + * Extracted for testability — no direct Android framework dependency in the core logic. + * + * Permissions handled: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION + * + * Usage: + * - Call [start] on activity start to check existing permission or trigger a request. + * - Call [onResult] from the ActivityResultLauncher callback with the permission grants map. + */ +class LocationPermissionHandler( + /** Returns true if location permission is already granted. */ + private val checkGranted: () -> Boolean, + /** Called when location permission is available (already granted or just granted). */ + private val onGranted: () -> Unit, + /** Called when location permission is denied or the user refuses (including "never ask again"). */ + private val onDenied: () -> Unit, + /** Called when permission needs to be requested from the user via the system dialog. */ + private val requestPermissions: () -> Unit +) { + /** + * Check current permission state and dispatch: + * - If already granted, invoke [onGranted] immediately. + * - Otherwise, invoke [requestPermissions] to trigger the system dialog. + */ + fun start() { + if (checkGranted()) onGranted() else requestPermissions() + } + + /** + * Process the result from the system permission dialog. + * + * @param grants Map of permission name → granted status from ActivityResultLauncher. + * Invokes [onGranted] if any permission was granted, [onDenied] otherwise. + * An empty map (e.g. "never ask again" scenario) also triggers [onDenied]. + */ + fun onResult(grants: Map) { + if (grants.values.any { it }) onGranted() else onDenied() + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt new file mode 100644 index 0000000..53d02fd --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -0,0 +1,63 @@ +package org.terst.nav.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.WindArrow +import org.terst.nav.data.repository.WeatherRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class UiState { + object Loading : UiState() + object Success : UiState() + data class Error(val message: String) : UiState() +} + +class MainViewModel( + private val repository: WeatherRepository = WeatherRepository.create() +) : ViewModel() { + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState + + private val _windArrow = MutableStateFlow(null) + val windArrow: StateFlow = _windArrow + + private val _forecast = MutableStateFlow>(emptyList()) + val forecast: StateFlow> = _forecast + + /** + * Fetch weather and marine data for [lat]/[lon] in parallel. + * Called once the device location is known. + */ + fun loadWeather(lat: Double, lon: Double) { + viewModelScope.launch { + val arrowDeferred = async { repository.fetchWindArrow(lat, lon) } + val forecastDeferred = async { repository.fetchForecastItems(lat, lon) } + + val arrowResult = arrowDeferred.await() + val forecastResult = forecastDeferred.await() + + when { + arrowResult.isFailure -> { + _uiState.value = UiState.Error( + arrowResult.exceptionOrNull()?.message ?: "Unknown error" + ) + } + forecastResult.isFailure -> { + _uiState.value = UiState.Error( + forecastResult.exceptionOrNull()?.message ?: "Unknown error" + ) + } + else -> { + _windArrow.value = arrowResult.getOrNull() + _forecast.value = forecastResult.getOrThrow() + _uiState.value = UiState.Success + } + } + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt new file mode 100644 index 0000000..1a60aa7 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WeatherActivity.kt @@ -0,0 +1,125 @@ +package org.terst.nav.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import org.terst.nav.R +import org.terst.nav.databinding.ActivityWeatherBinding +import org.terst.nav.ui.forecast.ForecastFragment +import org.terst.nav.ui.map.MapFragment +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority + +class WeatherActivity : AppCompatActivity() { + + private lateinit var binding: ActivityWeatherBinding + private val viewModel: MainViewModel by viewModels() + + // Default position (San Francisco Bay) used when location is unavailable + private val defaultLat = 37.8 + private val defaultLon = -122.4 + + private val permissionHandler: LocationPermissionHandler by lazy { + LocationPermissionHandler( + checkGranted = { + ContextCompat.checkSelfPermission( + this, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + }, + onGranted = { fetchLocationAndLoad() }, + onDenied = { loadWeatherAtDefault() }, + requestPermissions = { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + ) + } + + private val locationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grants -> + permissionHandler.onResult(grants) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityWeatherBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupBottomNav() + + if (savedInstanceState == null) { + showFragment(MapFragment(), TAG_MAP) + requestLocationOrLoad() + } + } + + private fun setupBottomNav() { + binding.bottomNav.setOnItemSelectedListener { item -> + when (item.itemId) { + R.id.nav_map -> { + showFragment(MapFragment(), TAG_MAP) + true + } + R.id.nav_forecast -> { + showFragment(ForecastFragment(), TAG_FORECAST) + true + } + else -> false + } + } + } + + private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) { + val existing = supportFragmentManager.findFragmentByTag(tag) + supportFragmentManager.beginTransaction() + .apply { + supportFragmentManager.fragments.forEach { hide(it) } + if (existing == null) add(R.id.fragment_container, fragment, tag) + else show(existing) + } + .commit() + } + + private fun requestLocationOrLoad() { + permissionHandler.start() + } + + private fun fetchLocationAndLoad() { + val client = LocationServices.getFusedLocationProviderClient(this) + try { + client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .addOnSuccessListener { location -> + if (location != null) { + viewModel.loadWeather(location.latitude, location.longitude) + } else { + loadWeatherAtDefault() + } + } + .addOnFailureListener { + loadWeatherAtDefault() + } + } catch (e: SecurityException) { + loadWeatherAtDefault() + } + } + + private fun loadWeatherAtDefault() { + Toast.makeText(this, R.string.error_location, Toast.LENGTH_SHORT).show() + viewModel.loadWeather(defaultLat, defaultLon) + } + + companion object { + private const val TAG_MAP = "map" + private const val TAG_FORECAST = "forecast" + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt new file mode 100644 index 0000000..d581c55 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastAdapter.kt @@ -0,0 +1,54 @@ +package org.terst.nav.ui.forecast + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.databinding.ItemForecastBinding + +class ForecastAdapter : ListAdapter(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() { + override fun areItemsTheSame(a: ForecastItem, b: ForecastItem) = a.timeIso == b.timeIso + override fun areContentsTheSame(a: ForecastItem, b: ForecastItem) = a == b + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt new file mode 100644 index 0000000..6e213c3 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/forecast/ForecastFragment.kt @@ -0,0 +1,74 @@ +package org.terst.nav.ui.forecast + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import org.terst.nav.databinding.FragmentForecastBinding +import org.terst.nav.ui.MainViewModel +import org.terst.nav.ui.UiState +import kotlinx.coroutines.launch + +class ForecastFragment : Fragment() { + + private var _binding: FragmentForecastBinding? = null + private val binding get() = _binding!! + + private val viewModel: MainViewModel by activityViewModels() + private val adapter = ForecastAdapter() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentForecastBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.forecastList.layoutManager = LinearLayoutManager(requireContext()) + binding.forecastList.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + when (state) { + UiState.Loading -> { + binding.progress.visibility = View.VISIBLE + binding.errorText.visibility = View.GONE + } + UiState.Success -> { + binding.progress.visibility = View.GONE + binding.errorText.visibility = View.GONE + } + is UiState.Error -> { + binding.progress.visibility = View.GONE + binding.errorText.visibility = View.VISIBLE + binding.errorText.text = state.message + } + } + } + } + launch { + viewModel.forecast.collect { items -> + adapter.submitList(items) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt new file mode 100644 index 0000000..ea7b596 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt @@ -0,0 +1,167 @@ +package org.terst.nav.ui.map + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import org.terst.nav.R +import org.terst.nav.data.model.WindArrow +import org.terst.nav.databinding.FragmentMapBinding +import org.terst.nav.ui.MainViewModel +import org.terst.nav.ui.UiState +import kotlinx.coroutines.launch +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point + +class MapFragment : Fragment() { + + private var _binding: FragmentMapBinding? = null + private val binding get() = _binding!! + + private val viewModel: MainViewModel by activityViewModels() + + private var mapLibreMap: MapLibreMap? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + MapLibre.getInstance(requireContext()) + _binding = FragmentMapBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.mapView.onCreate(savedInstanceState) + binding.mapView.getMapAsync { map -> + mapLibreMap = map + map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style -> + addWindArrowImage(style) + observeViewModel(style) + } + } + } + + private fun observeViewModel(style: Style) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + binding.statusText.visibility = when (state) { + UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) } + UiState.Success -> View.GONE + is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message } + } + } + } + launch { + viewModel.windArrow.collect { arrow -> + if (arrow != null) { + updateWindLayer(style, arrow) + centerMapOn(arrow.lat, arrow.lon) + } + } + } + } + } + } + + private fun addWindArrowImage(style: Style) { + val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow) + ?: return + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth.coerceAtLeast(24), + drawable.intrinsicHeight.coerceAtLeast(24), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + style.addImage(WIND_ARROW_ICON, bitmap) + } + + private fun updateWindLayer(style: Style, arrow: WindArrow) { + val feature = Feature.fromGeometry( + Point.fromLngLat(arrow.lon, arrow.lat) + ).also { f -> + f.addNumberProperty("direction", arrow.directionDeg) + f.addNumberProperty("speed_kt", arrow.speedKt) + } + val collection = FeatureCollection.fromFeature(feature) + + if (style.getSource(WIND_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection)) + } else { + (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection) + } + + if (style.getLayer(WIND_LAYER_ID) == null) { + val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties( + PropertyFactory.iconImage(WIND_ARROW_ICON), + PropertyFactory.iconRotate(Expression.get("direction")), + PropertyFactory.iconRotationAlignment("map"), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconSize( + Expression.interpolate( + Expression.linear(), + Expression.get("speed_kt"), + Expression.stop(0, 0.6f), + Expression.stop(30, 1.4f) + ) + ) + ) + style.addLayer(layer) + } + } + + private fun centerMapOn(lat: Double, lon: Double) { + mapLibreMap?.cameraPosition = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(7.0) + .build() + } + + // Lifecycle delegation to MapView + override fun onStart() { super.onStart(); binding.mapView.onStart() } + override fun onResume() { super.onResume(); binding.mapView.onResume() } + override fun onPause() { super.onPause(); binding.mapView.onPause() } + override fun onStop() { super.onStop(); binding.mapView.onStop() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + binding.mapView.onSaveInstanceState(outState) + } + override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() } + + override fun onDestroyView() { + binding.mapView.onDestroy() + super.onDestroyView() + _binding = null + } + + companion object { + private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json" + private const val WIND_SOURCE_ID = "wind-source" + private const val WIND_LAYER_ID = "wind-arrows" + private const val WIND_ARROW_ICON = "wind-arrow" + } +} 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 deleted file mode 100644 index ac2a652..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index f0a903f..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index b61e6fb..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index e1bf288..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt +++ /dev/null @@ -1,101 +0,0 @@ -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() - private val marineApi = mockk() - 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/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt deleted file mode 100644 index 54afc26..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.example.androidapp.ui - -import org.junit.Assert.* -import org.junit.Test - -class LocationPermissionHandlerTest { - - // Convenience factory — callers override only the lambdas they care about. - private fun makeHandler( - checkGranted: () -> Boolean = { false }, - onGranted: () -> Unit = {}, - onDenied: () -> Unit = {}, - requestPermissions: () -> Unit = {} - ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions) - - // ── start() ────────────────────────────────────────────────────────────── - - @Test - fun `start - permission already granted - calls onGranted without requesting`() { - var onGrantedCalled = false - var requestCalled = false - makeHandler( - checkGranted = { true }, - onGranted = { onGrantedCalled = true }, - requestPermissions = { requestCalled = true } - ).start() - - assertTrue("onGranted should be called", onGrantedCalled) - assertFalse("requestPermissions should NOT be called", requestCalled) - } - - @Test - fun `start - permission not granted - calls requestPermissions without calling onGranted`() { - var onGrantedCalled = false - var requestCalled = false - makeHandler( - checkGranted = { false }, - onGranted = { onGrantedCalled = true }, - requestPermissions = { requestCalled = true } - ).start() - - assertFalse("onGranted should NOT be called", onGrantedCalled) - assertTrue("requestPermissions should be called", requestCalled) - } - - // ── onResult() ─────────────────────────────────────────────────────────── - - @Test - fun `onResult - fine location granted - calls onGranted`() { - var onGrantedCalled = false - makeHandler(onGranted = { onGrantedCalled = true }).onResult( - mapOf( - "android.permission.ACCESS_FINE_LOCATION" to true, - "android.permission.ACCESS_COARSE_LOCATION" to false - ) - ) - assertTrue("onGranted should be called when fine location is granted", onGrantedCalled) - } - - @Test - fun `onResult - coarse location granted - calls onGranted`() { - var onGrantedCalled = false - makeHandler(onGranted = { onGrantedCalled = true }).onResult( - mapOf( - "android.permission.ACCESS_FINE_LOCATION" to false, - "android.permission.ACCESS_COARSE_LOCATION" to true - ) - ) - assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled) - } - - @Test - fun `onResult - both permissions granted - calls onGranted`() { - var onGrantedCalled = false - makeHandler(onGranted = { onGrantedCalled = true }).onResult( - mapOf( - "android.permission.ACCESS_FINE_LOCATION" to true, - "android.permission.ACCESS_COARSE_LOCATION" to true - ) - ) - assertTrue(onGrantedCalled) - } - - @Test - fun `onResult - all permissions denied - calls onDenied not onGranted`() { - var onGrantedCalled = false - var onDeniedCalled = false - makeHandler( - onGranted = { onGrantedCalled = true }, - onDenied = { onDeniedCalled = true } - ).onResult( - mapOf( - "android.permission.ACCESS_FINE_LOCATION" to false, - "android.permission.ACCESS_COARSE_LOCATION" to false - ) - ) - assertFalse("onGranted should NOT be called", onGrantedCalled) - assertTrue("onDenied should be called", onDeniedCalled) - } - - @Test - fun `onResult - empty grants (never ask again scenario) - calls onDenied`() { - var onDeniedCalled = false - makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap()) - assertTrue( - "onDenied should be called for empty grants (never-ask-again)", - onDeniedCalled - ) - } -} 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 deleted file mode 100644 index cb5f6f9..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -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() - 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() - } - } -} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/api/WeatherApiServiceTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/api/WeatherApiServiceTest.kt new file mode 100644 index 0000000..1d35170 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/api/WeatherApiServiceTest.kt @@ -0,0 +1,88 @@ +package org.terst.nav.data.api + +import org.terst.nav.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/org/terst/nav/data/model/ForecastItemTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/ForecastItemTest.kt new file mode 100644 index 0000000..b54f3f3 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/ForecastItemTest.kt @@ -0,0 +1,57 @@ +package org.terst.nav.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/org/terst/nav/data/model/WindArrowTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/WindArrowTest.kt new file mode 100644 index 0000000..ccb28a8 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/WindArrowTest.kt @@ -0,0 +1,49 @@ +package org.terst.nav.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/org/terst/nav/data/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt new file mode 100644 index 0000000..749630f --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt @@ -0,0 +1,101 @@ +package org.terst.nav.data.repository + +import org.terst.nav.data.api.MarineApiService +import org.terst.nav.data.api.WeatherApiService +import org.terst.nav.data.model.* +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() + private val marineApi = mockk() + 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/org/terst/nav/ui/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/LocationPermissionHandlerTest.kt new file mode 100644 index 0000000..9caa5a0 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/LocationPermissionHandlerTest.kt @@ -0,0 +1,110 @@ +package org.terst.nav.ui + +import org.junit.Assert.* +import org.junit.Test + +class LocationPermissionHandlerTest { + + // Convenience factory — callers override only the lambdas they care about. + private fun makeHandler( + checkGranted: () -> Boolean = { false }, + onGranted: () -> Unit = {}, + onDenied: () -> Unit = {}, + requestPermissions: () -> Unit = {} + ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions) + + // ── start() ────────────────────────────────────────────────────────────── + + @Test + fun `start - permission already granted - calls onGranted without requesting`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { true }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertTrue("onGranted should be called", onGrantedCalled) + assertFalse("requestPermissions should NOT be called", requestCalled) + } + + @Test + fun `start - permission not granted - calls requestPermissions without calling onGranted`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { false }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("requestPermissions should be called", requestCalled) + } + + // ── onResult() ─────────────────────────────────────────────────────────── + + @Test + fun `onResult - fine location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertTrue("onGranted should be called when fine location is granted", onGrantedCalled) + } + + @Test + fun `onResult - coarse location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled) + } + + @Test + fun `onResult - both permissions granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue(onGrantedCalled) + } + + @Test + fun `onResult - all permissions denied - calls onDenied not onGranted`() { + var onGrantedCalled = false + var onDeniedCalled = false + makeHandler( + onGranted = { onGrantedCalled = true }, + onDenied = { onDeniedCalled = true } + ).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("onDenied should be called", onDeniedCalled) + } + + @Test + fun `onResult - empty grants (never ask again scenario) - calls onDenied`() { + var onDeniedCalled = false + makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap()) + assertTrue( + "onDenied should be called for empty grants (never-ask-again)", + onDeniedCalled + ) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt new file mode 100644 index 0000000..edecdd5 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt @@ -0,0 +1,105 @@ +package org.terst.nav.ui + +import app.cash.turbine.test +import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.WindArrow +import org.terst.nav.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() + 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() + } + } +} -- cgit v1.2.3