From 72c4ffbbf0a8f0edee91c6ce1ba5eb3a606072d0 Mon Sep 17 00:00:00 2001 From: Renan Bernordi Date: Tue, 18 Aug 2026 16:10:39 -0300 Subject: [PATCH] add imet integration --- .../core/di/network/ApiModule.kt | 15 + .../di/weather/WeatherRepositoryModule.kt | 15 + .../core/model/sources/WeatherSource.kt | 8 + .../network/sources/weather/inmet/InmetApi.kt | 100 +++++ .../sources/weather/inmet/InmetRepository.kt | 239 ++++++++++ .../weather/inmet/InmetWeatherConditionMap.kt | 72 +++ .../weather/inmet/json/InmetForecastJson.kt | 30 ++ .../weather/inmet/json/InmetIbgeJson.kt | 20 + .../weather/inmet/json/InmetStationJson.kt | 32 ++ .../weather/sources/inmet/InmetJsonMapper.kt | 412 ++++++++++++++++++ .../provider/WeatherRepositoryProvider.kt | 5 +- app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 13 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetApi.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetRepository.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetWeatherConditionMap.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetForecastJson.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetIbgeJson.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetStationJson.kt create mode 100644 app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/inmet/InmetJsonMapper.kt diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt index cab4665c3..f386b6bff 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt @@ -22,6 +22,9 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccA import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiApi import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoApi import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.IbgeApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetForecastApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetObservationApi import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaApi import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceApi @@ -164,4 +167,16 @@ object ApiModule { @Provides @Singleton fun provideCwaApi(): CwaApi = CwaApi.create() + + @Provides + @Singleton + fun provideInmetForecastApi(): InmetForecastApi = InmetForecastApi.create() + + @Provides + @Singleton + fun provideInmetObservationApi(): InmetObservationApi = InmetObservationApi.create() + + @Provides + @Singleton + fun provideIbgeApi(): IbgeApi = IbgeApi.create() } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt index a0158ca14..5925a25e9 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt @@ -32,6 +32,10 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.G import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdApi import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdRepository +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.IbgeApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetForecastApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetObservationApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaApi import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi @@ -278,4 +282,15 @@ object WeatherRepositoryModule { apiKeysDao: ApiKeysDao, locationKeysDao: LocationKeysDao ): CwaRepository = CwaRepository(dao, weatherDao, api, apiKeysDao, locationKeysDao) + + @Provides + @Singleton + fun provideInmetRepository( + dao: LocationsDao, + weatherDao: WeatherDao, + forecastApi: InmetForecastApi, + observationApi: InmetObservationApi, + ibgeApi: IbgeApi, + locationKeysDao: LocationKeysDao + ): InmetRepository = InmetRepository(dao, weatherDao, forecastApi, observationApi, ibgeApi, locationKeysDao) } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt index 40bb17a6e..220311e31 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt @@ -128,6 +128,12 @@ enum class WeatherSource( displayLink = "https://opendata.cwa.gov.tw/", countryNameRes = R.string.country_taiwan, requiresUserApiKey = true + ), + INMET( + displayName = "INMET", + fullName = "Instituto Nacional de Meteorologia", + displayLink = "https://portal.inmet.gov.br/", + countryNameRes = R.string.country_brazil ); // Sources that provide snow/rain as precipitation @@ -139,6 +145,7 @@ enum class WeatherSource( BMKG -> false IMD -> false CWA -> false + INMET -> false else -> true } } @@ -164,6 +171,7 @@ private val weatherSourcesByCountry = buildMap { put("ES", listOf(WeatherSource.AEMET)) put("IN", listOf(WeatherSource.IMD)) put("TW", listOf(WeatherSource.CWA)) + put("BR", listOf(WeatherSource.INMET)) } diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetApi.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetApi.kt new file mode 100644 index 000000000..fba375924 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetApi.kt @@ -0,0 +1,100 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet + +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.IbgeMunicipioJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetDayJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetHourlyEntryJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetStationJson +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path +import java.util.concurrent.TimeUnit + + +interface InmetForecastApi { + + @GET("previsao/{ibgeCode}") + suspend fun fetchForecast( + @Path("ibgeCode") ibgeCode: String + ): Response>> + + companion object { + const val BASE_URL = "https://apiprevmet3.inmet.gov.br/" + + fun create(): InmetForecastApi { + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(InmetForecastApi::class.java) + } + } +} + + +interface InmetObservationApi { + + @GET("estacoes/T") + suspend fun fetchStations(): Response> + + @GET("estacao/{dataInicio}/{dataFim}/{stationCode}") + suspend fun fetchHourlyData( + @Path("dataInicio") dataInicio: String, + @Path("dataFim") dataFim: String, + @Path("stationCode") stationCode: String + ): Response> + + companion object { + const val BASE_URL = "https://apitempo.inmet.gov.br/" + + fun create(): InmetObservationApi { + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(InmetObservationApi::class.java) + } + } +} + + +interface IbgeApi { + + @GET("localidades/estados/{uf}/municipios") + suspend fun fetchMunicipiosByState( + @Path("uf") uf: String + ): Response> + + companion object { + const val BASE_URL = "https://servicodados.ibge.gov.br/api/v1/" + + fun create(): IbgeApi { + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(IbgeApi::class.java) + } + } +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetRepository.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetRepository.kt new file mode 100644 index 000000000..019a16775 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetRepository.kt @@ -0,0 +1,239 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet + +import com.pranshulgg.weather_master_app.core.model.domain.location.Location +import com.pranshulgg.weather_master_app.core.model.domain.toAppException +import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult +import com.pranshulgg.weather_master_app.core.model.weather.WeatherResultType +import com.pranshulgg.weather_master_app.core.network.calls.safeApiCall +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.IbgeMunicipioJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetStationJson +import com.pranshulgg.weather_master_app.core.utils.weather.cache.isWeatherCacheSafe +import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnWeatherCache +import com.pranshulgg.weather_master_app.core.utils.weather.forecast.mergeHourlyWeather +import com.pranshulgg.weather_master_app.data.local.dao.location.LocationKeysDao +import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao +import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherDao +import com.pranshulgg.weather_master_app.data.local.entity.location.LocationKeyEntity +import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.inmet.InmetWeatherBundle +import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.inmet.toDomain +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toCurrentWeatherEntity +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toDailyWeatherEntity +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toDomain +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toHourlyWeatherEntity +import com.pranshulgg.weather_master_app.data.repository.WeatherRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + + +class InmetRepository @Inject constructor( + val dao: LocationsDao, + val weatherDao: WeatherDao, + val forecastApi: InmetForecastApi, + val observationApi: InmetObservationApi, + val ibgeApi: IbgeApi, + val locationKeysDao: LocationKeysDao +) : WeatherRepository { + + @Volatile + private var cachedStations: List? = null + + override suspend fun getWeather( + location: Location, + isManualRefresh: Boolean, + isForceRefresh: Boolean + ): WeatherResult = + withContext(Dispatchers.IO) { + val cache = dao.getWeatherDataForLocation(location.id) + + val shouldReturnCache = shouldReturnWeatherCache(cache, isManualRefresh, isForceRefresh) + + val existingHourly = weatherDao.getHourlyDataForLocation(location.id, location.source) + + when (shouldReturnCache) { + WeatherResultType.REFRESH_TOO_EARLY -> return@withContext WeatherResult.RefreshNotAvailable + WeatherResultType.SUCCESS -> return@withContext WeatherResult.Success(cache!!.toDomain()) + else -> {} + } + + return@withContext try { + val ibgeCode = resolveIbgeCode(location) + + val forecastResponse = safeApiCall { + forecastApi.fetchForecast(ibgeCode) + }.getOrElse { return@withContext WeatherResult.Error(exception = it.toAppException()) } + + val hourlyObservations = try { + val stationCode = resolveNearestStationCode(location) + if (stationCode != null) { + val today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + val result = safeApiCall { + observationApi.fetchHourlyData(today, today, stationCode) + } + result.getOrNull()?.takeIf { it.isNotEmpty() } + } else null + } catch (e: Exception) { + null + } + + val bundle = InmetWeatherBundle( + forecast = forecastResponse, + hourlyObservations = hourlyObservations + ) + + val domain = bundle.toDomain(location) + + val mergedHourly = mergeHourlyWeather( + existing = existingHourly, + incoming = domain.hourly.toHourlyWeatherEntity(location) + ) + weatherDao.insertWeather( + domain.current.toCurrentWeatherEntity(location.id), + mergedHourly, + domain.daily.toDailyWeatherEntity(location.id), + location.id + ) + WeatherResult.Success(domain) + + } catch (e: Exception) { + val isCacheSafe = isWeatherCacheSafe(cache) + WeatherResult.Error( + exception = e, + if (isCacheSafe) cache?.toDomain() else null + ) + } + } + + private suspend fun resolveIbgeCode(location: Location): String { + locationKeysDao.getCityKeyForLocation(location.id)?.cityKey?.let { return it } + + val stations = getStations() + val nearestStation = stations?.findNearestStation(location.latitude, location.longitude) + + val uf = nearestStation?.sgEstado + ?: inferUfFromState(location) + + val ibgeCode = if (uf != null) { + val municipios = safeApiCall { + ibgeApi.fetchMunicipiosByState(uf) + }.getOrNull() + + municipios?.let { list -> + val stationName = nearestStation?.dcNome + val matchTarget = stationName ?: location.name + + list.bestMatch(matchTarget)?.id?.toString() + } + } else null + + val resolvedCode = ibgeCode ?: "3550308" + + locationKeysDao.insertCityKey( + LocationKeyEntity( + locationId = location.id, + cityKey = resolvedCode + ) + ) + + return resolvedCode + } + + private suspend fun resolveNearestStationCode(location: Location): String? { + val stations = getStations() ?: return null + return stations.findNearestStation(location.latitude, location.longitude)?.cdEstacao + } + + private suspend fun getStations(): List? { + cachedStations?.let { return it } + return try { + val result = safeApiCall { observationApi.fetchStations() } + val stations = result.getOrNull() + ?.filter { it.cdSituacao == "Operante" && it.tpEstacao == "Automatica" } + ?.filter { it.vlLatitude.toDoubleOrNull() != null && it.vlLongitude.toDoubleOrNull() != null } + stations?.let { cachedStations = it } + stations + } catch (e: Exception) { + null + } + } + + private fun inferUfFromState(location: Location): String? { + val stateLower = location.state.lowercase().trim() + val stateMap = mapOf( + "acre" to "AC", "alagoas" to "AL", "amapá" to "AP", "amazonas" to "AM", + "bahia" to "BA", "ceará" to "CE", "distrito federal" to "DF", + "espírito santo" to "ES", "goiás" to "GO", "maranhão" to "MA", + "mato grosso" to "MT", "mato grosso do sul" to "MS", + "minas gerais" to "MG", "pará" to "PA", "paraíba" to "PB", + "paraná" to "PR", "pernambuco" to "PE", "piauí" to "PI", + "rio de janeiro" to "RJ", "rio grande do norte" to "RN", + "rio grande do sul" to "RS", "rondônia" to "RO", "roraima" to "RR", + "santa catarina" to "SC", "são paulo" to "SP", "sergipe" to "SE", + "tocantins" to "TO" + ) + return stateMap[stateLower] ?: stateMap.entries.firstOrNull { + stateLower.contains(it.key) || it.key.contains(stateLower) + }?.value + } + + private fun List.findNearestStation( + lat: Double, + lon: Double + ): InmetStationJson? { + return minByOrNull { station -> + val stationLat = station.vlLatitude.toDoubleOrNull() ?: return@minByOrNull Double.MAX_VALUE + val stationLon = station.vlLongitude.toDoubleOrNull() ?: return@minByOrNull Double.MAX_VALUE + haversineDistance(lat, lon, stationLat, stationLon) + } + } + + private fun List.bestMatch( + target: String + ): IbgeMunicipioJson? { + val normalizedTarget = stripAccents(target.lowercase().trim()) + return firstOrNull { stripAccents(it.nome.lowercase().trim()) == normalizedTarget } + ?: firstOrNull { stripAccents(it.nome.lowercase().trim()).contains(normalizedTarget) } + ?: firstOrNull { normalizedTarget.contains(stripAccents(it.nome.lowercase().trim())) } + ?: minByOrNull { + levenshteinDistance(normalizedTarget, stripAccents(it.nome.lowercase().trim())) + } + } + + private fun haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6371.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2) * sin(dLon / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return r * c + } + + private fun stripAccents(input: String): String { + val normalized = java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD) + return normalized.replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "") + } + + private fun levenshteinDistance(a: String, b: String): Int { + val dp = Array(a.length + 1) { IntArray(b.length + 1) } + for (i in 0..a.length) dp[i][0] = i + for (j in 0..b.length) dp[0][j] = j + for (i in 1..a.length) { + for (j in 1..b.length) { + dp[i][j] = minOf( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1 + ) + } + } + return dp[a.length][b.length] + } +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetWeatherConditionMap.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetWeatherConditionMap.kt new file mode 100644 index 000000000..c3006c4d5 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/InmetWeatherConditionMap.kt @@ -0,0 +1,72 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet + +import com.pranshulgg.weather_master_app.core.model.weather.WeatherCondition + +object InmetWeatherConditionMap { + + fun getCondition(codIcone: String?, resumo: String?): WeatherCondition { + val byCode = codIcone?.toIntOrNull()?.let { mapByCode(it) } + if (byCode != null && byCode != WeatherCondition.NO_CONDITION_FOUND) return byCode + + val byText = resumo?.let { mapByText(it) } + if (byText != null) return byText + + return WeatherCondition.NO_CONDITION_FOUND + } + + private fun mapByCode(code: Int): WeatherCondition { + return when (code) { + 1, 25 -> WeatherCondition.CLEAR_SKY + 2, 29, 30 -> WeatherCondition.PARTLY_CLOUDY + 3 -> WeatherCondition.OVERCAST + 4 -> WeatherCondition.OVERCAST + 5, 6 -> WeatherCondition.RAIN + 7 -> WeatherCondition.THUNDERSTORM + 8 -> WeatherCondition.THUNDERSTORM + 9, 11 -> WeatherCondition.FOG_HAZE + 10 -> WeatherCondition.SNOW + 12 -> WeatherCondition.HAIL + 13 -> WeatherCondition.LIGHT_SNOW + else -> WeatherCondition.NO_CONDITION_FOUND + } + } + + private fun mapByText(text: String): WeatherCondition? { + val lower = text.lowercase().trim() + + if (lower.contains("tempestade") || lower.contains("trovoada") || + lower.contains("raio") || lower.contains("relâmpago") + ) return WeatherCondition.THUNDERSTORM + + if (lower.contains("granizo")) return WeatherCondition.HAIL + if (lower.contains("neve")) return WeatherCondition.SNOW + if (lower.contains("geada")) return WeatherCondition.LIGHT_SNOW + + if (lower.contains("chuva forte") || lower.contains("pancada forte") || + lower.contains("temporada") + ) return WeatherCondition.HEAVY_RAIN + + if (lower.contains("chuva fraca") || lower.contains("leve chuva") || + lower.contains("garoa") + ) return WeatherCondition.LIGHT_RAIN + + if (lower.contains("chuva") || lower.contains("pancada")) return WeatherCondition.RAIN + + if (lower.contains("nevoeiro") || lower.contains("névoa") || + lower.contains("neblina") || lower.contains("fumaça") + ) return WeatherCondition.FOG_HAZE + + if (lower.contains("encoberto")) return WeatherCondition.OVERCAST + if (lower.contains("nublado")) return WeatherCondition.OVERCAST + + if (lower.contains("poucas nuvens") || lower.contains("parcialmente nublado") || + lower.contains("sol entre nuvens") + ) return WeatherCondition.PARTLY_CLOUDY + + if (lower.contains("céu claro") || lower.contains("ceu claro") || + lower.contains("sol") || lower.contains("limpo") + ) return WeatherCondition.CLEAR_SKY + + return null + } +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetForecastJson.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetForecastJson.kt new file mode 100644 index 000000000..620117c9d --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetForecastJson.kt @@ -0,0 +1,30 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json + +import com.google.gson.annotations.SerializedName + +data class InmetDayJson( + val uf: String? = null, + val entidade: String? = null, + val resumo: String? = null, + val tempo: String? = null, + @SerializedName("temp_max") val tempMax: Int? = null, + @SerializedName("temp_min") val tempMin: Int? = null, + @SerializedName("dir_vento") val dirVento: String? = null, + @SerializedName("int_vento") val intVento: String? = null, + @SerializedName("cod_icone") val codIcone: String? = null, + val manha: InmetPeriodJson? = null, + val tarde: InmetPeriodJson? = null, + val noite: InmetPeriodJson? = null +) + +data class InmetPeriodJson( + val uf: String? = null, + val entidade: String? = null, + val resumo: String? = null, + val tempo: String? = null, + @SerializedName("temp_max") val tempMax: Int? = null, + @SerializedName("temp_min") val tempMin: Int? = null, + @SerializedName("dir_vento") val dirVento: String? = null, + @SerializedName("int_vento") val intVento: String? = null, + @SerializedName("cod_icone") val codIcone: String? = null +) diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetIbgeJson.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetIbgeJson.kt new file mode 100644 index 000000000..368248077 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetIbgeJson.kt @@ -0,0 +1,20 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json + +import com.google.gson.annotations.SerializedName + +data class IbgeMunicipioJson( + val id: Int, + val nome: String, + val microrregiao: IbgeMicrorregiaoJson? = null +) + +data class IbgeMicrorregiaoJson( + val nome: String? = null, + val uf: IbgeUfJson? = null +) + +data class IbgeUfJson( + val id: Int? = null, + val sigla: String? = null, + val nome: String? = null +) diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetStationJson.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetStationJson.kt new file mode 100644 index 000000000..344b88215 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/inmet/json/InmetStationJson.kt @@ -0,0 +1,32 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json + +import com.google.gson.annotations.SerializedName + +data class InmetStationJson( + @SerializedName("CD_ESTACAO") val cdEstacao: String, + @SerializedName("DC_NOME") val dcNome: String, + @SerializedName("VL_LATITUDE") val vlLatitude: String, + @SerializedName("VL_LONGITUDE") val vlLongitude: String, + @SerializedName("SG_ESTADO") val sgEstado: String, + @SerializedName("VL_ALTITUDE") val vlAltitude: String?, + @SerializedName("CD_SITUACAO") val cdSituacao: String?, + @SerializedName("TP_ESTACAO") val tpEstacao: String?, + @SerializedName("FL_CAPITAL") val flCapital: String? +) + +data class InmetHourlyEntryJson( + @SerializedName("CD_ESTACAO") val cdEstacao: String? = null, + @SerializedName("DT_MEDICAO") val dtMedicao: String? = null, + @SerializedName("HR_MEDICAO") val hrMedicao: String? = null, + @SerializedName("TEMP_AR") val tempAr: String? = null, + @SerializedName("TEMP_MIN_HORA") val tempMinHora: String? = null, + @SerializedName("TEMP_MAX_HORA") val tempMaxHora: String? = null, + @SerializedName("UMID_REL_AR") val umidRelAr: String? = null, + @SerializedName("UMID_MIN_HORA") val umidMinHora: String? = null, + @SerializedName("VENT_VEL") val ventVel: String? = null, + @SerializedName("VENT_DIR") val ventDir: String? = null, + @SerializedName("VENT_RAJ_MAX") val ventRajMax: String? = null, + @SerializedName("PRESS_ATM_EST") val pressAtmEst: String? = null, + @SerializedName("PRECIPITACAO_TOTAL") val precipitacaoTotal: String? = null, + @SerializedName("PREV_CHUVA") val prevChuva: String? = null +) diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/inmet/InmetJsonMapper.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/inmet/InmetJsonMapper.kt new file mode 100644 index 000000000..6e9d78b2f --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/inmet/InmetJsonMapper.kt @@ -0,0 +1,412 @@ +package com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.inmet + +import com.pranshulgg.weather_master_app.core.model.astro.MoonPhase +import com.pranshulgg.weather_master_app.core.model.domain.location.Location +import com.pranshulgg.weather_master_app.core.model.domain.weather.Weather +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherCurrent +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherDaily +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherHourly +import com.pranshulgg.weather_master_app.core.model.weather.WindSpeedUnit +import com.pranshulgg.weather_master_app.core.model.weather.WeatherCondition +import com.pranshulgg.weather_master_app.core.model.weather.wind.WindDirection +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetWeatherConditionMap +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetDayJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetHourlyEntryJson +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.json.InmetPeriodJson +import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.normalizeToDay +import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId +import com.pranshulgg.weather_master_app.core.utils.weather.astronomy.getMoonTimings +import com.pranshulgg.weather_master_app.core.utils.weather.astronomy.getSunTimings +import com.pranshulgg.weather_master_app.core.utils.weather.calculations.computeApparentTemperature +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + + +data class InmetWeatherBundle( + val forecast: Map>, + val hourlyObservations: List? +) + + +fun InmetWeatherBundle.toDomain(location: Location): Weather { + + val ibgeCode = forecast.keys.firstOrNull() + val days = ibgeCode?.let { forecast[it] } ?: emptyMap() + + val zone = safeZoneId(location.timezone) + + val dailyEntries = mutableListOf() + val hourlyEntries = mutableListOf() + + val dayTimeMillis = mutableListOf() + + for ((dateStr, dayJson) in days) { + val dayMillis = parseInmetDate(dateStr, zone) + dayTimeMillis.add(dayMillis) + + val periods = listOfNotNull(dayJson.manha, dayJson.tarde, dayJson.noite) + + if (periods.isNotEmpty()) { + for ((periodJson, hourOffset) in listOf( + dayJson.manha to 9, + dayJson.tarde to 15, + dayJson.noite to 21 + )) { + periodJson?.let { + hourlyEntries.add( + createHourlyFromPeriod(it, dayMillis, hourOffset, zone) + ) + } + } + + val tempMin = periods.minOfOrNull { it.tempMin ?: Int.MAX_VALUE } + ?.takeIf { it != Int.MAX_VALUE }?.toDouble() + val tempMax = periods.maxOfOrNull { it.tempMax ?: Int.MIN_VALUE } + ?.takeIf { it != Int.MIN_VALUE }?.toDouble() + + val windSpeed = periods.mapNotNull { parseWindIntensityToKmh(it.intVento) }.averageOrNull() + val windDir = periods.firstNotNullOfOrNull { parseWindDirection(it.dirVento) } + + val condition = periods.firstNotNullOfOrNull { + InmetWeatherConditionMap.getCondition(it.codIcone, it.resumo) + } ?: WeatherCondition.NO_CONDITION_FOUND + + dailyEntries.add( + WeatherDaily( + temperatureMin = tempMin, + temperatureMax = tempMax, + windSpeed = windSpeed, + windDirection = windDir, + rainSum = 0.0, + snowfallSum = null, + uvIndexMax = null, + weatherCondition = condition, + time = dayMillis, + precipitationProbabilityMax = null, + pressureMsl = null, + visibility = null, + humidity = null, + dewPoint = null, + sunrise = -1L, + sunset = -1L, + moonrise = -1L, + moonset = -1L, + moonPhase = MoonPhase.NEW_MOON, + dawn = -1L, + dusk = -1L + ) + ) + } else { + val tempMin = dayJson.tempMin?.toDouble() + val tempMax = dayJson.tempMax?.toDouble() + val windSpeed = parseWindIntensityToKmh(dayJson.intVento) + val windDir = parseWindDirection(dayJson.dirVento) + val condition = InmetWeatherConditionMap.getCondition(dayJson.codIcone, dayJson.resumo) + + dailyEntries.add( + WeatherDaily( + temperatureMin = tempMin, + temperatureMax = tempMax, + windSpeed = windSpeed, + windDirection = windDir, + rainSum = 0.0, + snowfallSum = null, + uvIndexMax = null, + weatherCondition = condition, + time = dayMillis, + precipitationProbabilityMax = null, + pressureMsl = null, + visibility = null, + humidity = null, + dewPoint = null, + sunrise = -1L, + sunset = -1L, + moonrise = -1L, + moonset = -1L, + moonPhase = MoonPhase.NEW_MOON, + dawn = -1L, + dusk = -1L + ) + ) + } + } + + if (hourlyObservations != null && hourlyObservations.isNotEmpty()) { + hourlyEntries.clear() + hourlyEntries.addAll(hourlyObservations.map { createHourlyFromObservation(it, zone) }) + } + + val current = if (hourlyObservations != null && hourlyObservations.isNotEmpty()) { + createCurrentFromObservation(hourlyObservations.last(), location) + } else { + createCurrentFromForecast(days, zone, location) + } + + val sunTimings = getSunTimings( + dayTimeMillis, + location.timezone, + location.latitude, + location.longitude + ) + val moonTimings = getMoonTimings( + dayTimeMillis, + location.timezone, + location.latitude, + location.longitude + ) + + dailyEntries.forEachIndexed { index, day -> + if (index < sunTimings.size && index < moonTimings.size) { + val sun = sunTimings[index] + val moon = moonTimings[index] + dailyEntries[index] = day.copy( + sunrise = sun.sunrise ?: -1L, + sunset = sun.sunset ?: -1L, + moonrise = moon.moonrise ?: -1L, + moonset = moon.moonset ?: -1L, + moonPhase = moon.phase, + dawn = sun.dawn ?: -1L, + dusk = sun.dusk ?: -1L + ) + } + } + + return Weather( + location = location, + current = current, + hourly = hourlyEntries, + daily = dailyEntries + ) +} + + +private fun parseInmetDate(dateStr: String, zone: ZoneId): Long { + return try { + val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") + LocalDate.parse(dateStr.trim(), formatter) + .atStartOfDay(zone) + .toInstant() + .toEpochMilli() + } catch (e: Exception) { + System.currentTimeMillis().normalizeToDay(zone.id) + } +} + + +private fun parseObservationTimestamp(dateStr: String?, timeStr: String?, zone: ZoneId): Long { + return try { + val datePart = dateStr?.trim() ?: return System.currentTimeMillis() + val timePart = timeStr?.trim() ?: "0000" + + val hours = timePart.padStart(4, '0').substring(0, 2).toInt() + val minutes = timePart.padStart(4, '0').substring(2, 4).toInt() + + LocalDate.parse(datePart, DateTimeFormatter.ISO_LOCAL_DATE) + .atTime(hours, minutes) + .atZone(zone) + .toInstant() + .toEpochMilli() + } catch (e: Exception) { + System.currentTimeMillis() + } +} + + +private fun parseWindDirection(dirVento: String?): WindDirection? { + if (dirVento.isNullOrBlank()) return null + val firstDir = dirVento.split("-", "/").firstOrNull()?.trim() + return WindDirection.toWindDirectionFromString(firstDir) +} + + +private fun parseWindIntensityToKmh(intVento: String?): Double? { + if (intVento.isNullOrBlank()) return null + val lower = intVento.lowercase().trim() + return when { + lower.contains("calma") -> 0.0 + lower.contains("muito fraco") || lower.contains("muito fracas") -> 5.0 + lower.contains("fraco") || lower.contains("fracas") -> 10.0 + lower.contains("moderado") || lower.contains("moderadas") -> 20.0 + lower.contains("muito forte") || lower.contains("muito fortes") -> 45.0 + lower.contains("forte") || lower.contains("fortes") -> 30.0 + else -> null + } +} + + +private fun parseDoubleSafe(value: String?): Double? { + return value?.trim()?.replace(",", ".")?.toDoubleOrNull() +} + + +private fun createHourlyFromPeriod( + period: InmetPeriodJson, + dayMillis: Long, + hourOffset: Int, + zone: ZoneId +): WeatherHourly { + val timeMilli = try { + Instant.ofEpochMilli(dayMillis) + .atZone(zone) + .toLocalDate() + .atTime(hourOffset, 0) + .atZone(zone) + .toInstant() + .toEpochMilli() + } catch (e: Exception) { + dayMillis + } + + val temp = listOfNotNull(period.tempMax, period.tempMin).averageOrNull() + + return WeatherHourly( + temperature = temp, + windSpeed = parseWindIntensityToKmh(period.intVento), + windDirection = parseWindDirection(period.dirVento), + rain = 0.0, + snowfall = null, + uvIndex = null, + pressureMsl = null, + visibility = null, + humidity = null, + dewPoint = null, + weatherCondition = InmetWeatherConditionMap.getCondition(period.codIcone, period.resumo), + time = timeMilli, + precipitationProbability = null + ) +} + + +private fun createHourlyFromObservation( + entry: InmetHourlyEntryJson, + zone: ZoneId +): WeatherHourly { + val timeMilli = parseObservationTimestamp(entry.dtMedicao, entry.hrMedicao, zone) + val windMs = parseDoubleSafe(entry.ventVel) + val windKmh = windMs?.let { WindSpeedUnit.MPS.convert(it, WindSpeedUnit.KPH) } + val windDir = parseDoubleSafe(entry.ventDir)?.toInt()?.let { + WindDirection.toWindDirectionFromDegrees(it) + } + + return WeatherHourly( + temperature = parseDoubleSafe(entry.tempAr), + windSpeed = windKmh, + windDirection = windDir, + rain = parseDoubleSafe(entry.precipitacaoTotal) ?: 0.0, + snowfall = null, + uvIndex = null, + pressureMsl = parseDoubleSafe(entry.pressAtmEst), + visibility = null, + humidity = parseDoubleSafe(entry.umidRelAr), + dewPoint = null, + weatherCondition = WeatherCondition.NO_CONDITION_FOUND, + time = timeMilli, + precipitationProbability = null + ) +} + + +private fun createCurrentFromObservation( + entry: InmetHourlyEntryJson, + location: Location +): WeatherCurrent { + val zone = safeZoneId(location.timezone) + val timeMilli = parseObservationTimestamp(entry.dtMedicao, entry.hrMedicao, zone) + val temp = parseDoubleSafe(entry.tempAr) + val humidity = parseDoubleSafe(entry.umidRelAr) ?: 0.0 + val windMs = parseDoubleSafe(entry.ventVel) + val windKmh = windMs?.let { WindSpeedUnit.MPS.convert(it, WindSpeedUnit.KPH) } + val windDir = parseDoubleSafe(entry.ventDir)?.toInt()?.let { + WindDirection.toWindDirectionFromDegrees(it) + } + val pressure = parseDoubleSafe(entry.pressAtmEst) + + return WeatherCurrent( + temperature = temp, + humidity = humidity, + windSpeed = windKmh, + windDirection = windDir, + pressureMsl = pressure, + visibility = null, + cloudCover = null, + uvIndex = null, + weatherCondition = WeatherCondition.NO_CONDITION_FOUND, + feelsLike = computeApparentTemperature(temp, humidity, windMs), + time = timeMilli, + dewPoint = null, + utcOffsetSeconds = null, + lastUpdatedInMilli = System.currentTimeMillis() + ) +} + + +private fun createCurrentFromForecast( + days: Map, + zone: ZoneId, + location: Location +): WeatherCurrent { + val todayEntry = days.entries.firstOrNull() + val dayJson = todayEntry?.value + val dayMillis = todayEntry?.key?.let { parseInmetDate(it, zone) } ?: System.currentTimeMillis() + + val periods = listOfNotNull(dayJson?.manha, dayJson?.tarde, dayJson?.noite) + val nowHour = Instant.ofEpochMilli(System.currentTimeMillis()) + .atZone(zone) + .hour + + val currentPeriod = when { + periods.isEmpty() -> null + nowHour < 12 -> periods.firstOrNull() + nowHour < 18 -> periods.getOrNull(1) ?: periods.firstOrNull() + else -> periods.getOrNull(2) ?: periods.firstOrNull() + } + + val temp = currentPeriod?.let { + listOfNotNull(it.tempMax, it.tempMin).averageOrNull() + } ?: dayJson?.let { + listOfNotNull(it.tempMax, it.tempMin).averageOrNull() + } + + val humidity = 0.0 + val windKmh = currentPeriod?.intVento?.let { parseWindIntensityToKmh(it) } + ?: dayJson?.intVento?.let { parseWindIntensityToKmh(it) } + val windMs = windKmh?.let { WindSpeedUnit.KPH.convert(it, WindSpeedUnit.MPS) } + val windDir = currentPeriod?.dirVento?.let { parseWindDirection(it) } + ?: dayJson?.dirVento?.let { parseWindDirection(it) } + + val condition = currentPeriod?.let { + InmetWeatherConditionMap.getCondition(it.codIcone, it.resumo) + } ?: dayJson?.let { + InmetWeatherConditionMap.getCondition(it.codIcone, it.resumo) + } ?: WeatherCondition.NO_CONDITION_FOUND + + return WeatherCurrent( + temperature = temp, + humidity = humidity, + windSpeed = windKmh, + windDirection = windDir, + pressureMsl = null, + visibility = null, + cloudCover = null, + uvIndex = null, + weatherCondition = condition, + feelsLike = computeApparentTemperature(temp, humidity, windMs), + time = System.currentTimeMillis(), + dewPoint = null, + utcOffsetSeconds = null, + lastUpdatedInMilli = System.currentTimeMillis() + ) +} + + +@JvmName("averageDoubleOrNull") +private fun List.averageOrNull(): Double? { + return if (isEmpty()) null else average() +} + +@JvmName("averageIntOrNull") +private fun List.averageOrNull(): Double? { + return if (isEmpty()) null else average() +} diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt index 05914e589..052bf5ec6 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt @@ -11,6 +11,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccR import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdRepository +import com.pranshulgg.weather_master_app.core.network.sources.weather.inmet.InmetRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceRepository @@ -42,7 +43,8 @@ class WeatherRepositoryProvider @Inject constructor( private val aemetRepository: AemetRepository, private val imdRepository: ImdRepository, private val pirateWeatherRepository: PirateWeatherRepository, - private val cwaRepository: CwaRepository + private val cwaRepository: CwaRepository, + private val inmetRepository: InmetRepository ) { fun getRepository(source: WeatherSource): WeatherRepository { @@ -66,6 +68,7 @@ class WeatherRepositoryProvider @Inject constructor( WeatherSource.IMD -> imdRepository WeatherSource.PIRATE_WEATHER -> pirateWeatherRepository WeatherSource.CWA -> cwaRepository + WeatherSource.INMET -> inmetRepository } } diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 148b41180..a02cf6b5a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -349,6 +349,7 @@ Rússia Espanha Taiwan + Brasil Formato de data Exibe a previsão por hora em vez da previsão diária Plano de fundo do widget diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 314df2d9d..1f84d8f94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,6 +426,7 @@ Russia Spain Taiwan + Brazil Date format Displays hourly instead of daily forecast