add imd closes #935 closes #628 closes #391

This commit is contained in:
PranshulGG committed 2026-08-15 16:05:12 +05:30
1 parent b3d46603c9
commit 462c497d2e
9 files changed
+464 -1

No files matched your search

@@ -19,6 +19,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccApi
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.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
@@ -144,4 +145,8 @@ object ApiModule {
@Provides
@Singleton
fun provideMetOfficeApi(): MetOfficeApi = MetOfficeApi.create()
@Provides
@Singleton
fun provideImdApi(): ImdApi = ImdApi.create()
}
@@ -26,6 +26,8 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoApi
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.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
@@ -233,4 +235,12 @@ object WeatherRepositoryModule {
weatherDao: WeatherDao,
apiKeysDao: ApiKeysDao
): MetOfficeRepository = MetOfficeRepository(dao, weatherDao, api, apiKeysDao)
@Provides
@Singleton
fun provideImdRepository(
dao: LocationsDao,
weatherDao: WeatherDao,
api: ImdApi
): ImdRepository = ImdRepository(dao, weatherDao, api)
}
@@ -96,6 +96,12 @@ enum class WeatherSource(
requiresUserApiKey = true,
regionalButWorldwideSupport = true
),
IMD(
displayName = "IMD",
fullName = "India Meteorological Department",
displayLink = "https://mausam.imd.gov.in/",
countryNameRes = R.string.country_india
),
MET_NORWAY(
displayName = "Met Norway",
fullName = "Met Norway",
@@ -132,6 +138,7 @@ private val weatherSourcesByCountry = buildMap {
listOf("GB", "UK").forEach { put(it, listOf(WeatherSource.MET_OFFICE)) }
put("NO", listOf(WeatherSource.MET_NORWAY))
put("FR", listOf(WeatherSource.METEO_FRANCE))
put("IN", listOf(WeatherSource.IMD))
}
@@ -0,0 +1,51 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.imd
import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json.ImdForecastJson
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
interface ImdApi {
@GET("test4_mme.php")
suspend fun fetchForecast(
@Query("lat_gfs") latitude: Double,
@Query("lon_gfs") longitude: Double,
@Query("date") date: String,
): Response<ImdForecastJson>
@GET("{path}")
suspend fun fetchTimestamps(
@Path("path") path: String,
): Response<ResponseBody>
companion object {
const val BASE_URL = "https://mausamgram.imd.gov.in/"
fun create(): ImdApi {
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(ImdApi::class.java)
}
}
}
@@ -0,0 +1,111 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.imd
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
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.sources.weather.imd.model.ImdForecastModel
import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.json.bundle.MeteoamWeatherBundle
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.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherDao
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.imd.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.meteoam.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 javax.inject.Inject
class ImdRepository @Inject constructor(
val dao: LocationsDao,
val weatherDao: WeatherDao,
val api: ImdApi
) : WeatherRepository {
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)
when (shouldReturnCache) {
WeatherResultType.REFRESH_TOO_EARLY -> return@withContext WeatherResult.RefreshNotAvailable
WeatherResultType.SUCCESS -> return@withContext WeatherResult.Success(cache!!.toDomain())
else -> {}
}
return@withContext try {
val imdTimeFrames = listOf("1hr", "3hr", "6hr")
val timeStamps = imdTimeFrames.map {
api.fetchTimestamps("mmem_${it}.txt")
}
val timeStampsBody = timeStamps.map {
it.body()?.string()?.substringBefore(",")
}
val forecasts = timeStampsBody.mapIndexed { index, s ->
api.fetchForecast(
latitude = location.latitude,
longitude = location.longitude,
date = "${s}_${imdTimeFrames[index]}_0p125"
)
}
val final = ImdForecastModel(
forecast1hr = forecasts[0].body(),
forecast3hr = forecasts[1].body(),
forecast6hr = forecasts[2].body(),
timeStamp1 = timeStampsBody[0]!!,
timeStamp2 = timeStampsBody[1]!!,
timeStamp3 = timeStampsBody[2]!!
)
val domain = final.toDomain(location)
val mergedHourly = mergeHourlyWeather(
existing = existingHourly,
incoming = domain.hourly.toHourlyWeatherEntity(location.id)
)
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
)
}
}
}
@@ -0,0 +1,13 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json
import com.google.gson.annotations.SerializedName
data class ImdForecastJson(
@SerializedName("rh") val humidity: List<String?>,
@SerializedName("temp") val temperature: List<String?>,
@SerializedName("wdir") val windDirection: List<String?>,
@SerializedName("wspd") val windSpeedMs: List<String?>,
@SerializedName("apcp") val precipitation: List<String?>,
@SerializedName("tcdc") val cloudCover: List<String?>,
)
@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.imd.model
import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json.ImdForecastJson
data class ImdForecastModel(
val forecast1hr: ImdForecastJson?,
val forecast3hr: ImdForecastJson?,
val forecast6hr: ImdForecastJson?,
val timeStamp1: String,
val timeStamp2: String,
val timeStamp3: String,
)
@@ -0,0 +1,248 @@
package com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.imd
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.WeatherCondition
import com.pranshulgg.weather_master_app.core.model.weather.WindSpeedUnit
import com.pranshulgg.weather_master_app.core.model.weather.wind.WindDirection
import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.model.ImdForecastModel
import com.pranshulgg.weather_master_app.core.utils.formatters.getCurrentTimeFor
import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId
import com.pranshulgg.weather_master_app.core.utils.formatters.toSafeDouble
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 com.pranshulgg.weather_master_app.core.utils.weather.computing.computeDailyWeatherCondition
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findHourlyIndexForTime
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.isSameDay
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.util.Locale
import java.util.TimeZone
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.hours
fun ImdForecastModel.toDomain(location: Location): Weather {
val zoneId = location.timezone
val currentTime = getCurrentTimeFor(zoneId)
val formatter = SimpleDateFormat("yyyyMMddHH", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val hourlyTemps = mutableMapOf<Long, Double?>()
val hourlyPrecipitation = mutableMapOf<Long, Double?>()
val hourlyHumidity = mutableMapOf<Long, Double?>()
val hourlyWindSpeedMs = mutableMapOf<Long, Double?>()
val hourlyWindDirection = mutableMapOf<Long, Double?>()
val hourlyCloudCover = mutableMapOf<Long, Double?>()
var key: Long
val forecasts = listOf(this.forecast6hr, this.forecast3hr, this.forecast1hr)
val params = listOf(
mapOf(
"time" to formatter.parse(this.timeStamp3)?.time,
"interval" to 6.hours.inWholeMilliseconds,
"size" to 40
),
mapOf(
"time" to formatter.parse(this.timeStamp2)?.time,
"interval" to 3.hours.inWholeMilliseconds,
"size" to 40
),
mapOf(
"time" to formatter.parse(this.timeStamp1)?.time,
"interval" to 1.hours.inWholeMilliseconds,
"size" to 36
),
)
forecasts.forEachIndexed { index, forecast ->
val time = params[index]["time"] ?: return@forEachIndexed
val interval = params[index]["interval"] ?: return@forEachIndexed
val size = params[index]["size"] ?: return@forEachIndexed
for (i in 1..size.toInt()) {
key = time + (interval * i)
hourlyTemps[key] = forecast?.temperature?.getOrNull(i).toSafeDouble()
hourlyPrecipitation[key] = forecast?.precipitation?.getOrNull(i).toSafeDouble()
hourlyHumidity[key] = forecast?.humidity?.getOrNull(i).toSafeDouble()
hourlyWindSpeedMs[key] = forecast?.windSpeedMs?.getOrNull(i).toSafeDouble()
hourlyWindDirection[key] = forecast?.windDirection?.getOrNull(i).toSafeDouble()
hourlyCloudCover[key] = forecast?.cloudCover?.getOrNull(i).toSafeDouble()
}
}
val hourly = hourlyTemps.map {
WeatherHourly(
temperature = it.value,
rain = hourlyPrecipitation[it.key] ?: 0.0,
humidity = hourlyHumidity[it.key],
windSpeed = WindSpeedUnit.MPS.convert(hourlyWindSpeedMs[it.key], WindSpeedUnit.KPH),
windDirection = WindDirection.toWindDirectionFromDegrees(hourlyWindDirection[it.key]?.roundToInt()),
snowfall = null,
uvIndex = null,
pressureMsl = null,
visibility = null,
dewPoint = null,
weatherCondition = computeWeatherCondition(
hourlyPrecipitation[it.key],
hourlyCloudCover[it.key]
),
time = it.key,
precipitationProbability = null
)
}
val currentHourlyIndex = findHourlyIndexForTime(hourly.map { it.time }, currentTime)
val dailyDates =
hourly.map { Instant.ofEpochMilli(it.time).atZone(safeZoneId(zoneId)).toLocalDate() }
.distinct()
val dailyDateFormatter: (LocalDate) -> Long = {
it.atStartOfDay(safeZoneId(zoneId)).toInstant().toEpochMilli()
}
val sunTimings = getSunTimings(
dailyDates.map {
dailyDateFormatter(it)
},
location.timezone,
location.latitude,
location.longitude
)
val moonTimings = getMoonTimings(
dailyDates.map {
dailyDateFormatter(it)
},
location.timezone,
location.latitude,
location.longitude
)
return Weather(
location = location,
current = WeatherCurrent(
temperature = hourly[currentHourlyIndex].temperature,
humidity = hourly[currentHourlyIndex].humidity ?: 0.0,
windSpeed = hourly[currentHourlyIndex].windSpeed,
windDirection = hourly[currentHourlyIndex].windDirection,
pressureMsl = null,
visibility = null,
cloudCover = null,
uvIndex = null,
weatherCondition = hourly[currentHourlyIndex].weatherCondition,
feelsLike = computeApparentTemperature(
tempC = hourly[currentHourlyIndex].temperature,
humidity = hourly[currentHourlyIndex].humidity,
windMs = WindSpeedUnit.KPH.convert(
hourly[currentHourlyIndex].windSpeed,
WindSpeedUnit.MPS
),
),
time = currentTime,
dewPoint = null,
utcOffsetSeconds = null,
lastUpdatedInMilli = System.currentTimeMillis()
),
hourly = hourly,
daily = dailyDates.mapIndexed { index, date ->
val time = dailyDateFormatter(date)
val dayHourly = hourlyForDay(
hourly,
time,
zoneId
)
val temperatureMin = dayHourly.minOf { it.temperature ?: -1.0 }.takeIf { it != -1.0 }
val temperatureMax = dayHourly.maxOf { it.temperature ?: -1.0 }.takeIf { it != -1.0 }
val windSpeedAvg =
dayHourly.map { it.windSpeed ?: -1.0 }.average().takeIf { it != -1.0 }
val windDirection = dayHourly.mapNotNull { it.windDirection }.maxOrNull()
val rainSum = dayHourly.sumOf { it.rain }
val icon = dayHourly.map { it.weatherCondition }.groupingBy { it }
.eachCount().maxByOrNull { it.value }
val condition = computeDailyWeatherCondition(
dayHourly.map { it.weatherCondition },
icon!!.key
)
val humidityAvg = dayHourly.map { it.humidity ?: -1.0 }.average().takeIf { it != -1.0 }
WeatherDaily(
temperatureMin = temperatureMin,
temperatureMax = temperatureMax,
windSpeed = windSpeedAvg,
windDirection = windDirection,
rainSum = rainSum,
snowfallSum = null,
uvIndexMax = null,
weatherCondition = condition,
time = time,
precipitationProbabilityMax = null,
pressureMsl = null,
visibility = null,
humidity = humidityAvg,
dewPoint = null,
sunrise = sunTimings[index].sunrise ?: -0L,
sunset = sunTimings[index].sunset ?: -0L,
moonrise = moonTimings[index].moonrise ?: -0L,
moonset = moonTimings[index].moonset ?: -0L,
moonPhase = moonTimings[index].phase,
dawn = sunTimings[index].dawn ?: 0L,
dusk = sunTimings[index].dusk ?: 0L,
)
},
)
}
// Pretty barebones
private fun computeWeatherCondition(
precipitationMm: Double?,
cloudCover: Double?
): WeatherCondition {
if (precipitationMm != null && precipitationMm >= 1.0) {
return WeatherCondition.RAIN
}
return when {
cloudCover == null -> WeatherCondition.PARTLY_CLOUDY
cloudCover >= 70.0 -> WeatherCondition.OVERCAST
cloudCover >= 30.0 -> WeatherCondition.PARTLY_CLOUDY
cloudCover >= 10.0 -> WeatherCondition.MOSTLY_CLEAR
else -> WeatherCondition.CLEAR_SKY
}
}
private fun hourlyForDay(
data: List<WeatherHourly>,
time: Long,
zoneId: String
): List<WeatherHourly> {
val startIndex = data.indexOfFirst { it.time >= time }
.takeIf { it != -1 }
?: 0
val data = data.drop(maxOf(0, startIndex)).takeWhile { isSameDay(it.time, time, zoneId) }
return data
}
@@ -8,6 +8,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdRep
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccRepository
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.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
@@ -34,7 +35,8 @@ class WeatherRepositoryProvider @Inject constructor(
private val meteoamRepository: MeteoamRepository,
private val ipmaRepository: IpmaRepository,
private val gismeteoRepository: GismeteoRepository,
private val metOfficeRepository: MetOfficeRepository
private val metOfficeRepository: MetOfficeRepository,
private val imdRepository: ImdRepository
) {
fun getRepository(source: WeatherSource): WeatherRepository {
@@ -54,6 +56,7 @@ class WeatherRepositoryProvider @Inject constructor(
WeatherSource.IPMA -> ipmaRepository
WeatherSource.GISMETEO -> gismeteoRepository
WeatherSource.MET_OFFICE -> metOfficeRepository
WeatherSource.IMD -> imdRepository
}
}