add imet integration

This commit is contained in:
Renan Bernordi committed 2026-08-18 16:10:39 -03:00
1 parent b229b37e93
commit 72c4ffbbf0
13 files changed
+949 -1

No files matched your search

@@ -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()
}
@@ -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)
}
@@ -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))
}
@@ -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<Map<String, Map<String, InmetDayJson>>>
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<List<InmetStationJson>>
@GET("estacao/{dataInicio}/{dataFim}/{stationCode}")
suspend fun fetchHourlyData(
@Path("dataInicio") dataInicio: String,
@Path("dataFim") dataFim: String,
@Path("stationCode") stationCode: String
): Response<List<InmetHourlyEntryJson>>
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<List<IbgeMunicipioJson>>
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)
}
}
}
@@ -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<InmetStationJson>? = 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<InmetStationJson>? {
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<InmetStationJson>.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<IbgeMunicipioJson>.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]
}
}
@@ -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
}
}
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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<String, Map<String, InmetDayJson>>,
val hourlyObservations: List<InmetHourlyEntryJson>?
)
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<WeatherDaily>()
val hourlyEntries = mutableListOf<WeatherHourly>()
val dayTimeMillis = mutableListOf<Long>()
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<String, InmetDayJson>,
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<Double>.averageOrNull(): Double? {
return if (isEmpty()) null else average()
}
@JvmName("averageIntOrNull")
private fun List<Int>.averageOrNull(): Double? {
return if (isEmpty()) null else average()
}
@@ -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
}
}
@@ -349,6 +349,7 @@
<string name="country_russia">Rússia</string>
<string name="country_spain">Espanha</string>
<string name="country_taiwan">Taiwan</string>
<string name="country_brazil">Brasil</string>
<string name="settings_date_format">Formato de data</string>
<string name="widget_settings_display_hourly_secondary">Exibe a previsão por hora em vez da previsão diária</string>
<string name="settings_widget_background">Plano de fundo do widget</string>
+1
View File
@@ -426,6 +426,7 @@
<string name="country_russia">Russia</string>
<string name="country_spain">Spain</string>
<string name="country_taiwan">Taiwan</string>
<string name="country_brazil">Brazil</string>
<string name="settings_date_format">Date format</string>
<string name="widget_settings_display_hourly_secondary">Displays hourly instead of daily forecast</string>