diff options
Diffstat (limited to 'android-app/app/src/main')
24 files changed, 992 insertions, 26 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index abb4dc5..0b9fc05 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.INTERNET" /> @@ -26,4 +27,4 @@ </intent-filter> </activity> </application> -</manifest>
\ No newline at end of file +</manifest> diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt new file mode 100644 index 0000000..dd53f2e --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/ApiClient.kt @@ -0,0 +1,35 @@ +package com.example.androidapp.data.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +object ApiClient { + + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + .build() + + private fun retrofit(baseUrl: String): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + val weatherApi: WeatherApiService by lazy { + retrofit("https://api.open-meteo.com/").create(WeatherApiService::class.java) + } + + val marineApi: MarineApiService by lazy { + retrofit("https://marine-api.open-meteo.com/").create(MarineApiService::class.java) + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt new file mode 100644 index 0000000..641cebc --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/MarineApiService.kt @@ -0,0 +1,17 @@ +package com.example.androidapp.data.api + +import com.example.androidapp.data.model.MarineResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface MarineApiService { + + @GET("v1/marine") + suspend fun getMarineForecast( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("hourly") hourly: String = + "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction", + @Query("forecast_days") forecastDays: Int = 7 + ): MarineResponse +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt new file mode 100644 index 0000000..0d53ff9 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/api/WeatherApiService.kt @@ -0,0 +1,18 @@ +package com.example.androidapp.data.api + +import com.example.androidapp.data.model.WeatherResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface WeatherApiService { + + @GET("v1/forecast") + suspend fun getWeatherForecast( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("hourly") hourly: String = + "windspeed_10m,winddirection_10m,temperature_2m,precipitation_probability,weathercode", + @Query("forecast_days") forecastDays: Int = 7, + @Query("wind_speed_unit") windSpeedUnit: String = "kn" + ): WeatherResponse +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt new file mode 100644 index 0000000..3c3fc4d --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/ForecastItem.kt @@ -0,0 +1,30 @@ +package com.example.androidapp.data.model + +/** One hourly weather forecast slot shown in the forecast panel. */ +data class ForecastItem( + val timeIso: String, + val windKt: Double, + val windDirDeg: Double, + val tempC: Double, + val precipProbabilityPct: Int, + val weatherCode: Int +) { + /** Human-readable description based on WMO weather code. */ + fun weatherDescription(): String = when (weatherCode) { + 0 -> "Clear sky" + 1 -> "Mainly clear" + 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, 48 -> "Fog" + 51, 53, 55 -> "Drizzle" + 61, 63, 65 -> "Rain" + 71, 73, 75 -> "Snow" + 80, 81, 82 -> "Rain showers" + 95 -> "Thunderstorm" + 96, 99 -> "Thunderstorm with hail" + else -> "Unknown ($weatherCode)" + } + + /** WMO codes 51-67 and 80-82 are precipitation. */ + fun isRainy(): Boolean = weatherCode in 51..67 || weatherCode in 80..82 +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt new file mode 100644 index 0000000..8bbacb1 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/MarineResponse.kt @@ -0,0 +1,20 @@ +package com.example.androidapp.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MarineResponse( + @Json(name = "latitude") val latitude: Double, + @Json(name = "longitude") val longitude: Double, + @Json(name = "hourly") val hourly: MarineHourly +) + +@JsonClass(generateAdapter = true) +data class MarineHourly( + @Json(name = "time") val time: List<String>, + @Json(name = "wave_height") val waveHeight: List<Double?>, + @Json(name = "wave_direction") val waveDirection: List<Double?>, + @Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List<Double?>, + @Json(name = "ocean_current_direction") val oceanCurrentDirection: List<Double?> +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt new file mode 100644 index 0000000..89d8a11 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WeatherResponse.kt @@ -0,0 +1,21 @@ +package com.example.androidapp.data.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WeatherResponse( + @Json(name = "latitude") val latitude: Double, + @Json(name = "longitude") val longitude: Double, + @Json(name = "hourly") val hourly: WeatherHourly +) + +@JsonClass(generateAdapter = true) +data class WeatherHourly( + @Json(name = "time") val time: List<String>, + @Json(name = "windspeed_10m") val windspeed10m: List<Double>, + @Json(name = "winddirection_10m") val winddirection10m: List<Double>, + @Json(name = "temperature_2m") val temperature2m: List<Double>, + @Json(name = "precipitation_probability") val precipitationProbability: List<Int>, + @Json(name = "weathercode") val weathercode: List<Int> +) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt new file mode 100644 index 0000000..48699da --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/WindArrow.kt @@ -0,0 +1,31 @@ +package com.example.androidapp.data.model + +/** A wind vector at a geographic point, used for map overlay rendering. */ +data class WindArrow( + val lat: Double, + val lon: Double, + val speedKt: Double, + val directionDeg: Double +) { + fun isCalm(): Boolean = speedKt < 1.0 + + /** Normalise 360° → 0°; all other values unchanged. */ + fun normalisedDirection(): Double = if (directionDeg >= 360.0) 0.0 else directionDeg + + /** Beaufort scale 0-12 from wind speed in knots. */ + fun beaufortScale(): Int = when { + speedKt < 1 -> 0 + speedKt < 4 -> 1 + speedKt < 7 -> 2 + speedKt < 11 -> 3 + speedKt < 16 -> 4 + speedKt < 22 -> 5 + speedKt < 28 -> 6 + speedKt < 34 -> 7 + speedKt < 41 -> 8 + speedKt < 48 -> 9 + speedKt < 56 -> 10 + speedKt < 64 -> 11 + else -> 12 + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..6affdbd --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/repository/WeatherRepository.kt @@ -0,0 +1,57 @@ +package com.example.androidapp.data.repository + +import com.example.androidapp.data.api.MarineApiService +import com.example.androidapp.data.api.WeatherApiService +import com.example.androidapp.data.model.ForecastItem +import com.example.androidapp.data.model.WindArrow + +class WeatherRepository( + private val weatherApi: WeatherApiService, + private val marineApi: MarineApiService +) { + + /** + * Fetch 7-day hourly forecast items for the given position. + * Both weather and marine data are requested; only weather fields are needed for ForecastItem, + * but marine is fetched here to prime the cache for wind-arrow use. + */ + suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> = + runCatching { + val weather = weatherApi.getWeatherForecast(lat, lon) + val h = weather.hourly + h.time.indices.map { i -> + ForecastItem( + timeIso = h.time[i], + windKt = h.windspeed10m[i], + windDirDeg = h.winddirection10m[i], + tempC = h.temperature2m[i], + precipProbabilityPct = h.precipitationProbability[i], + weatherCode = h.weathercode[i] + ) + } + } + + /** + * Fetch a single WindArrow representing the current conditions at [lat]/[lon]. + * Uses the first hourly slot as the current-hour proxy. + */ + suspend fun fetchWindArrow(lat: Double, lon: Double): Result<WindArrow> = + runCatching { + val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) + val h = weather.hourly + WindArrow( + lat = lat, + lon = lon, + speedKt = h.windspeed10m.firstOrNull() ?: 0.0, + directionDeg = h.winddirection10m.firstOrNull() ?: 0.0 + ) + } + + companion object { + /** Factory using the shared ApiClient singletons. */ + fun create(): WeatherRepository { + val client = com.example.androidapp.data.api.ApiClient + return WeatherRepository(client.weatherApi, client.marineApi) + } + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt new file mode 100644 index 0000000..664d5bb --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt @@ -0,0 +1,43 @@ +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<String, Boolean>) { + 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 new file mode 100644 index 0000000..b29aefa --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.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 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 new file mode 100644 index 0000000..eabb594 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainViewModel.kt @@ -0,0 +1,63 @@ +package com.example.androidapp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.androidapp.data.model.ForecastItem +import com.example.androidapp.data.model.WindArrow +import com.example.androidapp.data.repository.WeatherRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class UiState { + object Loading : UiState() + object Success : UiState() + data class Error(val message: String) : UiState() +} + +class MainViewModel( + private val repository: WeatherRepository = WeatherRepository.create() +) : ViewModel() { + + private val _uiState = MutableStateFlow<UiState>(UiState.Loading) + val uiState: StateFlow<UiState> = _uiState + + private val _windArrow = MutableStateFlow<WindArrow?>(null) + val windArrow: StateFlow<WindArrow?> = _windArrow + + private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList()) + val forecast: StateFlow<List<ForecastItem>> = _forecast + + /** + * Fetch weather and marine data for [lat]/[lon] in parallel. + * Called once the device location is known. + */ + fun loadWeather(lat: Double, lon: Double) { + viewModelScope.launch { + val arrowDeferred = async { repository.fetchWindArrow(lat, lon) } + val forecastDeferred = async { repository.fetchForecastItems(lat, lon) } + + val arrowResult = arrowDeferred.await() + val forecastResult = forecastDeferred.await() + + when { + arrowResult.isFailure -> { + _uiState.value = UiState.Error( + arrowResult.exceptionOrNull()?.message ?: "Unknown error" + ) + } + forecastResult.isFailure -> { + _uiState.value = UiState.Error( + forecastResult.exceptionOrNull()?.message ?: "Unknown error" + ) + } + else -> { + _windArrow.value = arrowResult.getOrNull() + _forecast.value = forecastResult.getOrThrow() + _uiState.value = UiState.Success + } + } + } + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt new file mode 100644 index 0000000..06c5eed --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastAdapter.kt @@ -0,0 +1,54 @@ +package com.example.androidapp.ui.forecast + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.example.androidapp.data.model.ForecastItem +import com.example.androidapp.databinding.ItemForecastBinding + +class ForecastAdapter : ListAdapter<ForecastItem, ForecastAdapter.ViewHolder>(DIFF) { + + inner class ViewHolder(private val binding: ItemForecastBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ForecastItem) { + // Display short time (e.g. "13:00" from "2026-03-13T13:00") + binding.tvTime.text = item.timeIso.substringAfter("T", item.timeIso) + + binding.tvDescription.text = item.weatherDescription() + + binding.tvWind.text = String.format("%.0f kt %s", + item.windKt, directionLabel(item.windDirDeg)) + + binding.tvTemp.text = String.format("%.0f °C", item.tempC) + + binding.tvPrecip.text = "${item.precipProbabilityPct}%" + } + + private fun directionLabel(deg: Double): String { + val dirs = arrayOf("N","NE","E","SE","S","SW","W","NW") + val idx = ((deg + 22.5) / 45.0).toInt() % 8 + return dirs[idx] + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemForecastBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback<ForecastItem>() { + override fun areItemsTheSame(a: ForecastItem, b: ForecastItem) = a.timeIso == b.timeIso + override fun areContentsTheSame(a: ForecastItem, b: ForecastItem) = a == b + } + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt new file mode 100644 index 0000000..a8be8f6 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/forecast/ForecastFragment.kt @@ -0,0 +1,74 @@ +package com.example.androidapp.ui.forecast + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.example.androidapp.databinding.FragmentForecastBinding +import com.example.androidapp.ui.MainViewModel +import com.example.androidapp.ui.UiState +import kotlinx.coroutines.launch + +class ForecastFragment : Fragment() { + + private var _binding: FragmentForecastBinding? = null + private val binding get() = _binding!! + + private val viewModel: MainViewModel by activityViewModels() + private val adapter = ForecastAdapter() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentForecastBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.forecastList.layoutManager = LinearLayoutManager(requireContext()) + binding.forecastList.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + when (state) { + UiState.Loading -> { + binding.progress.visibility = View.VISIBLE + binding.errorText.visibility = View.GONE + } + UiState.Success -> { + binding.progress.visibility = View.GONE + binding.errorText.visibility = View.GONE + } + is UiState.Error -> { + binding.progress.visibility = View.GONE + binding.errorText.visibility = View.VISIBLE + binding.errorText.text = state.message + } + } + } + } + launch { + viewModel.forecast.collect { items -> + adapter.submitList(items) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt new file mode 100644 index 0000000..82dd999 --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/map/MapFragment.kt @@ -0,0 +1,167 @@ +package com.example.androidapp.ui.map + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.androidapp.R +import com.example.androidapp.data.model.WindArrow +import com.example.androidapp.databinding.FragmentMapBinding +import com.example.androidapp.ui.MainViewModel +import com.example.androidapp.ui.UiState +import kotlinx.coroutines.launch +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point + +class MapFragment : Fragment() { + + private var _binding: FragmentMapBinding? = null + private val binding get() = _binding!! + + private val viewModel: MainViewModel by activityViewModels() + + private var mapLibreMap: MapLibreMap? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + MapLibre.getInstance(requireContext()) + _binding = FragmentMapBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.mapView.onCreate(savedInstanceState) + binding.mapView.getMapAsync { map -> + mapLibreMap = map + map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style -> + addWindArrowImage(style) + observeViewModel(style) + } + } + } + + private fun observeViewModel(style: Style) { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collect { state -> + binding.statusText.visibility = when (state) { + UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) } + UiState.Success -> View.GONE + is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message } + } + } + } + launch { + viewModel.windArrow.collect { arrow -> + if (arrow != null) { + updateWindLayer(style, arrow) + centerMapOn(arrow.lat, arrow.lon) + } + } + } + } + } + } + + private fun addWindArrowImage(style: Style) { + val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow) + ?: return + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth.coerceAtLeast(24), + drawable.intrinsicHeight.coerceAtLeast(24), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + style.addImage(WIND_ARROW_ICON, bitmap) + } + + private fun updateWindLayer(style: Style, arrow: WindArrow) { + val feature = Feature.fromGeometry( + Point.fromLngLat(arrow.lon, arrow.lat) + ).also { f -> + f.addNumberProperty("direction", arrow.directionDeg) + f.addNumberProperty("speed_kt", arrow.speedKt) + } + val collection = FeatureCollection.fromFeature(feature) + + if (style.getSource(WIND_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection)) + } else { + (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection) + } + + if (style.getLayer(WIND_LAYER_ID) == null) { + val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties( + PropertyFactory.iconImage(WIND_ARROW_ICON), + PropertyFactory.iconRotate(Expression.get("direction")), + PropertyFactory.iconRotationAlignment("map"), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconSize( + Expression.interpolate( + Expression.linear(), + Expression.get("speed_kt"), + Expression.stop(0, 0.6f), + Expression.stop(30, 1.4f) + ) + ) + ) + style.addLayer(layer) + } + } + + private fun centerMapOn(lat: Double, lon: Double) { + mapLibreMap?.cameraPosition = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(7.0) + .build() + } + + // Lifecycle delegation to MapView + override fun onStart() { super.onStart(); binding.mapView.onStart() } + override fun onResume() { super.onResume(); binding.mapView.onResume() } + override fun onPause() { super.onPause(); binding.mapView.onPause() } + override fun onStop() { super.onStop(); binding.mapView.onStop() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + binding.mapView.onSaveInstanceState(outState) + } + override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() } + + override fun onDestroyView() { + binding.mapView.onDestroy() + super.onDestroyView() + _binding = null + } + + companion object { + private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json" + private const val WIND_SOURCE_ID = "wind-source" + private const val WIND_LAYER_ID = "wind-arrows" + private const val WIND_ARROW_ICON = "wind-arrow" + } +} diff --git a/android-app/app/src/main/res/drawable/ic_wind_arrow.xml b/android-app/app/src/main/res/drawable/ic_wind_arrow.xml new file mode 100644 index 0000000..110f1b3 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_wind_arrow.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Wind-direction arrow pointing UP (north). + MapLibre rotates the icon to match wind direction via icon-rotate expression. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + + <!-- Arrowhead pointing up --> + <path + android:fillColor="#FFFFFF" + android:pathData="M12,2 L18,14 L12,11 L6,14 Z" /> + <!-- Shaft --> + <path + android:fillColor="#FFFFFF" + android:pathData="M11,11 L11,22 L13,22 L13,11 Z" /> + +</vector> diff --git a/android-app/app/src/main/res/layout/activity_weather.xml b/android-app/app/src/main/res/layout/activity_weather.xml new file mode 100644 index 0000000..36ea871 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_weather.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <FrameLayout + android:id="@+id/fragment_container" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + + <com.google.android.material.bottomnavigation.BottomNavigationView + android:id="@+id/bottom_nav" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:menu="@menu/bottom_nav_menu" + xmlns:app="http://schemas.android.com/apk/res-auto" /> + +</LinearLayout> diff --git a/android-app/app/src/main/res/layout/fragment_forecast.xml b/android-app/app/src/main/res/layout/fragment_forecast.xml new file mode 100644 index 0000000..aca38ba --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_forecast.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:background="?attr/colorSurface"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="16dp" + android:paddingTop="16dp" + android:paddingBottom="8dp" + android:text="7-Day Forecast" + android:textAppearance="?attr/textAppearanceHeadline6" + android:textColor="?attr/colorOnSurface" /> + + <ProgressBar + android:id="@+id/progress" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:visibility="gone" /> + + <TextView + android:id="@+id/error_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp" + android:textColor="@color/wind_strong" + android:visibility="gone" /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/forecast_list" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:clipToPadding="false" + android:paddingBottom="8dp" /> + +</LinearLayout> diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml new file mode 100644 index 0000000..e5b86b7 --- /dev/null +++ b/android-app/app/src/main/res/layout/fragment_map.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <org.maplibre.android.maps.MapView + android:id="@+id/map_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:maplibre_cameraTargetLat="37.0" + app:maplibre_cameraTargetLng="-122.0" + app:maplibre_cameraZoom="5.0" /> + + <!-- Loading / error overlay --> + <TextView + android:id="@+id/status_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top|center_horizontal" + android:layout_marginTop="16dp" + android:background="#AA000000" + android:paddingHorizontal="12dp" + android:paddingVertical="4dp" + android:textColor="#FFFFFF" + android:textSize="14sp" + android:visibility="gone" /> + +</FrameLayout> diff --git a/android-app/app/src/main/res/layout/item_forecast.xml b/android-app/app/src/main/res/layout/item_forecast.xml new file mode 100644 index 0000000..473661a --- /dev/null +++ b/android-app/app/src/main/res/layout/item_forecast.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp" + android:gravity="center_vertical"> + + <!-- Time --> + <TextView + android:id="@+id/tv_time" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="2" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="?attr/colorOnSurface" /> + + <!-- Weather description --> + <TextView + android:id="@+id/tv_description" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="3" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="?attr/colorOnSurface" /> + + <!-- Wind --> + <TextView + android:id="@+id/tv_wind" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="2" + android:gravity="end" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="?attr/colorOnSurface" /> + + <!-- Temperature --> + <TextView + android:id="@+id/tv_temp" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1.5" + android:gravity="end" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="?attr/colorOnSurface" /> + + <!-- Precip probability --> + <TextView + android:id="@+id/tv_precip" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1.5" + android:gravity="end" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="@color/primary" /> + +</LinearLayout> diff --git a/android-app/app/src/main/res/menu/bottom_nav_menu.xml b/android-app/app/src/main/res/menu/bottom_nav_menu.xml new file mode 100644 index 0000000..6922dee --- /dev/null +++ b/android-app/app/src/main/res/menu/bottom_nav_menu.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:id="@+id/nav_map" + android:icon="@android:drawable/ic_dialog_map" + android:title="@string/nav_map" /> + <item + android:id="@+id/nav_forecast" + android:icon="@android:drawable/ic_dialog_info" + android:title="@string/nav_forecast" /> +</menu> diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index 32f5036..43e0076 100755 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -1,3 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> <resources> <color name="purple_200">#FFBB86FC</color> <color name="purple_500">#FF6200EE</color> @@ -7,21 +8,34 @@ <color name="black">#FF000000</color> <color name="white">#FFFFFFFF</color> + <!-- Maritime theme colors --> + <color name="primary">#0D47A1</color> + <color name="primary_dark">#002171</color> + <color name="accent">#FF6D00</color> + <color name="surface">#FFFFFF</color> + <color name="on_primary">#FFFFFF</color> + <!-- Colors for instrument display --> - <color name="instrument_text_normal">#FFFFFFFF</color> <!-- White for normal text on dark background --> - <color name="instrument_text_secondary">#B3FFFFFF</color> <!-- 70% white --> - <color name="instrument_text_alarm">#FFFF0000</color> <!-- Red for alarm --> - <color name="instrument_text_stale">#FFFFFF00</color> <!-- Yellow for stale data --> - <color name="instrument_background">#E61E1E1E</color> <!-- Slightly transparent dark grey --> - <color name="mob_button_background">#FFD70000</color> <!-- High-contrast red for MOB button --> + <color name="instrument_text_normal">#FFFFFFFF</color> + <color name="instrument_text_secondary">#B3FFFFFF</color> + <color name="instrument_text_alarm">#FFFF0000</color> + <color name="instrument_text_stale">#FFFFFF00</color> + <color name="instrument_background">#E61E1E1E</color> + <color name="mob_button_background">#FFD70000</color> <color name="anchor_button_background">#3F51B5</color> + <!-- Wind overlay colors --> + <color name="wind_arrow">#FFFFFFFF</color> + <color name="wind_slow">#4CAF50</color> + <color name="wind_medium">#FF9800</color> + <color name="wind_strong">#F44336</color> + <!-- Night Vision Mode Colors --> - <color name="night_red_primary">#FFFF0000</color> <!-- Bright red for primary elements --> - <color name="night_red_variant">#FFBB0000</color> <!-- Slightly darker red for variants --> - <color name="night_on_red">#FF000000</color> <!-- Black text on red background --> - <color name="night_background">#FF000000</color> <!-- Pure black background --> - <color name="night_on_background">#FFFF0000</color> <!-- Bright red text on black background --> - <color name="night_surface">#FF110000</color> <!-- Very dark red surface color --> - <color name="night_on_surface">#FFFF0000</color> <!-- Red text on dark red surface --> -</resources>
\ No newline at end of file + <color name="night_red_primary">#FFFF0000</color> + <color name="night_red_variant">#FFBB0000</color> + <color name="night_on_red">#FF000000</color> + <color name="night_background">#FF000000</color> + <color name="night_on_background">#FFFF0000</color> + <color name="night_surface">#FF110000</color> + <color name="night_on_surface">#FFFF0000</color> +</resources> diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml index cec4850..499ba8d 100755 --- a/android-app/app/src/main/res/values/strings.xml +++ b/android-app/app/src/main/res/values/strings.xml @@ -1,6 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">nav</string> + <!-- Navigation --> + <string name="nav_map">Map</string> + <string name="nav_forecast">Forecast</string> + <!-- Instrument Labels --> <string name="instrument_label_wind">WIND</string> <string name="instrument_label_aws">AWS</string> @@ -44,4 +49,13 @@ <string name="anchor_inactive">Anchor Watch Inactive</string> <string name="anchor_active_format">Anchor Set at %1$.4f, %2$.4f\nRadius: %3$.1fm\nDistance: %4$.1fm (%5$.1fm from limit)</string> <string name="anchor_active_dragging_format">!!! ANCHOR DRAG !!!\nAnchor Set at %1$.4f, %2$.4f\nRadius: %3$.1fm\nDistance: %4$.1fm (%5$.1fm OVER limit)</string> + + <!-- Weather / Forecast Strings --> + <string name="loading_weather">Fetching weather…</string> + <string name="error_location">Could not get location. Showing default position.</string> + <string name="error_weather">Failed to load weather data.</string> + <string name="wind_speed_fmt">%.0f kt</string> + <string name="temp_fmt">%.0f °C</string> + <string name="precip_fmt">%d%%</string> + <string name="permission_rationale">Location is needed to show weather for your current position.</string> </resources> diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml index 612bba1..abef4b9 100755 --- a/android-app/app/src/main/res/values/themes.xml +++ b/android-app/app/src/main/res/values/themes.xml @@ -1,37 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools"> <!-- Base application theme. --> <style name="Theme.Nav" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> - <!-- Primary brand color. --> <item name="colorPrimary">@color/purple_200</item> <item name="colorPrimaryVariant">@color/purple_700</item> <item name="colorOnPrimary">@color/black</item> - <!-- Secondary brand color. --> <item name="colorSecondary">@color/teal_200</item> <item name="colorSecondaryVariant">@color/teal_200</item> <item name="colorOnSecondary">@color/black</item> - <!-- Status bar color. --> <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> - <!-- Customize your theme here. --> + </style> + + <!-- Maritime theme (weather/forecast features) --> + <style name="Theme.NavApp" parent="Theme.MaterialComponents.DayNight.NoActionBar"> + <item name="colorPrimary">@color/primary</item> + <item name="colorPrimaryDark">@color/primary_dark</item> + <item name="colorAccent">@color/accent</item> + <item name="android:statusBarColor">@color/primary_dark</item> </style> <!-- Night Vision Theme --> <style name="Theme.Nav.NightVision" parent="Theme.MaterialComponents.NoActionBar"> - <!-- Primary brand color. --> <item name="colorPrimary">@color/night_red_primary</item> <item name="colorPrimaryVariant">@color/night_red_variant</item> <item name="colorOnPrimary">@color/night_on_red</item> - <!-- Secondary brand color. --> <item name="colorSecondary">@color/night_red_primary</item> <item name="colorSecondaryVariant">@color/night_red_variant</item> <item name="colorOnSecondary">@color/night_on_red</item> - <!-- Background color --> <item name="android:colorBackground">@color/night_background</item> - <!-- Surface color --> <item name="colorSurface">@color/night_surface</item> <item name="colorOnSurface">@color/night_on_surface</item> - <!-- Status bar color. --> <item name="android:statusBarColor" tools:targetApi="l">@color/night_background</item> - <!-- Customize your theme here. --> </style> <!-- Instrument Display Styles --> @@ -62,4 +61,4 @@ <item name="android:textColor">@color/instrument_text_normal</item> <item name="android:textSize">@dimen/text_size_instrument_secondary</item> </style> -</resources>
\ No newline at end of file +</resources> |
