diff options
Diffstat (limited to 'android-app/app/src/main/kotlin/com/example')
14 files changed, 0 insertions, 755 deletions
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<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 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<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 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<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 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<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 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>(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 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<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 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" - } -} |
