add ECCC weather source and refactor weather source settings screen closes #875

This commit is contained in:
PranshulGG committed 2026-06-12 16:29:41 +05:30
1 parent cc026da135
commit 0fdb62ee64
11 files changed
+433 -88

No files matched your search

@@ -7,6 +7,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.search.geonames.Ge
import com.pranshulgg.weather_master_app.core.network.sources.search.geonames.timezone.GeoNamesTimezoneApi
import com.pranshulgg.weather_master_app.core.network.sources.search.openmeteo.OpenMeteoSearchApi
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.meteofrance.MeteoFranceApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.metnorway.MetNorwayApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.nws.NwsApi
@@ -69,4 +70,8 @@ object ApiModule {
@Provides
@Singleton
fun provideMeteoFranceApi(): MeteoFranceApi = MeteoFranceApi.create()
@Provides
@Singleton
fun provideEcccApi(): EcccApi = EcccApi.create()
}
@@ -4,6 +4,8 @@ import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmet
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.metnorway.MetNorwayApi
@@ -83,4 +85,12 @@ object WeatherRepositoryModule {
api: MeteoFranceApi,
weatherDao: WeatherDao
): MeteoFranceRepository = MeteoFranceRepository(dao, weatherDao, api)
@Provides
@Singleton
fun provideEcccRepository(
dao: LocationsDao,
api: EcccApi,
weatherDao: WeatherDao
): EcccRepository = EcccRepository(dao, weatherDao, api)
}
@@ -2,40 +2,47 @@ package com.pranshulgg.weather_master_app.core.model.sources
enum class WeatherSource(
val displayName: String,
val hourlyAggregationLimitHours: Int, // stupid
val displayLink: String
val hourlyAggregationLimitHours: Int = 24, // stupid and should be removed
val displayLink: String,
val fullName: String,
) {
OPEN_METEO(
displayName = "Open Meteo",
hourlyAggregationLimitHours = 24,
fullName = "Open Meteo",
displayLink = "https://open-meteo.com/"
),
NWS(
displayName = "National Weather Service",
hourlyAggregationLimitHours = 24,
displayName = "NWS (United States)",
fullName = "National Weather Service",
displayLink = "https://www.weather.gov/documentation/services-web-api"
),
SMHI(
displayName = "SMHI (Sweden)",
hourlyAggregationLimitHours = 24,
fullName = "Swedish Meteorological and Hydrological Institute",
displayLink = "https://opendata.smhi.se"
),
DWD(
displayName = "Bright Sky DWD (Germany)",
hourlyAggregationLimitHours = 24,
displayName = "DWD (Germany)",
fullName = "Bright Sky DWD",
displayLink = "https://brightsky.dev"
),
METEO_FRANCE(
displayName = "Météo-France",
hourlyAggregationLimitHours = 24,
fullName = "Météo-France",
displayLink = "https://meteofrance.com/"
),
ECCC(
displayName = "ECCC (Canada)",
fullName = "Environment and Climate Change Canada",
displayLink = "https://app.weather.gc.ca/",
),
MET_NORWAY(
displayName = "Met Norway",
hourlyAggregationLimitHours = 24,
"https://api.met.no/"
fullName = "Met Norway",
displayLink = "https://api.met.no/"
);
// Sources that provide snow/rain as precipitation
fun providesSnowFall(): Boolean {
return when (this) {
MET_NORWAY -> false
@@ -47,10 +54,12 @@ enum class WeatherSource(
// WE MAP EVERY WEATHER SOURCE HERE, AS THEY GET ADDED
private val weatherSourcesByCountry = mapOf(
"US" to listOf(WeatherSource.NWS),
"SE" to listOf(WeatherSource.SMHI),
"DE" to listOf(WeatherSource.DWD)
"DE" to listOf(WeatherSource.DWD),
"CA" to listOf(WeatherSource.ECCC)
)
fun getWeatherSourcesForCountry(countryCode: String?): List<WeatherSource> {
@@ -0,0 +1,55 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.eccc
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.json.EcccWeatherJson
import com.pranshulgg.weather_master_app.core.network.sources.weather.smhi.json.SmhiForecastJson
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 EcccApi {
@GET("v3/en/Location/{latitude},{longitude}")
suspend fun fetchWeather(
@Path("latitude") latitude: Double,
@Path("longitude") longitude: Double,
): Response<List<EcccWeatherJson>>
companion object {
const val BASE_URL = "https://app.weather.gc.ca/"
fun create(): EcccApi {
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header(
"User-Agent",
"WeatherMaster (com.pranshulgg.weathermaster; pranshul.devmain@gmail.com)"
)
.build()
chain.proceed(request)
}
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(EcccApi::class.java)
}
}
}
@@ -0,0 +1,22 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.eccc
import com.pranshulgg.weather_master_app.core.model.weather.WeatherCondition
object EcccConditionMap {
fun getCondition(icon: String?): WeatherCondition {
return when (icon) {
"00", "01", "30", "31" -> WeatherCondition.CLEAR_SKY
"02", "03", "04", "05", "22", "32", "33", "34", "35" -> WeatherCondition.PARTLY_CLOUDY
"10", "20", "21" -> WeatherCondition.OVERCAST
"06", "11", "12", "13", "28", "36" -> WeatherCondition.RAIN
"14", "27" -> WeatherCondition.SNOW // SHOULD BE HAIL
"07", "15", "37" -> WeatherCondition.SNOW // SHOULD BE SLEET
"08", "16", "17", "18", "26", "38" -> WeatherCondition.SNOW
"09", "19", "39", "46", "47" -> WeatherCondition.THUNDERSTORM
"23", "44", "45", "24" -> WeatherCondition.FOG_HAZE
"25", "40", "41", "42", "43", "48" -> WeatherCondition.CLEAR_SKY // SHOULD BE WIND
else -> WeatherCondition.NO_CONDITION_FOUND
}
}
}
@@ -0,0 +1,80 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.eccc
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.meteofrance.MeteoFranceApi
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.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.eccc.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.meteofrance.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.net.UnknownHostException
import javax.inject.Inject
class EcccRepository @Inject constructor(
val dao: LocationsDao,
val weatherDao: WeatherDao,
val api: EcccApi
) : 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)
when (shouldReturnCache) {
WeatherResultType.REFRESH_TOO_EARLY -> return@withContext WeatherResult.RefreshNotAvailable
WeatherResultType.SUCCESS -> return@withContext WeatherResult.Success(cache!!.toDomain())
else -> {}
}
return@withContext try {
val response = api.fetchWeather(location.latitude, location.longitude)
val body =
response.body()?.firstOrNull()
?: return@withContext WeatherResult.Error(exception = UnknownHostException())
val domain = body.toDomain(location)
weatherDao.insertWeather(
domain.current.toCurrentWeatherEntity(location.id),
domain.hourly.toHourlyWeatherEntity(location.id),
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,92 @@
package com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.json
import com.google.gson.annotations.SerializedName
data class EcccWeatherJson(
val observation: EcccCurrentWeatherJson,
val hourlyFcst: EcccHourlyWeatherJson,
val dailyFcst: EcccDailyWeatherJson,
)
// CURRENT
data class EcccCurrentWeatherJson(
val iconCode: String,
val timeStamp: String,
val temperature: EcccCurrentWeatherTemperatureJson,
val dewpoint: EcccCurrentWeatherDewPointJson,
val feelsLike: EcccCurrentWeatherFeelsLikeJson,
val pressure: EcccCurrentWeatherPressureJson,
val visibility: EcccCurrentWeatherVisibilityJson,
val humidity: String?,
val windDirection: String?,
val windSpeed: EcccCurrentWeatherWindSpeedJson
)
data class EcccCurrentWeatherTemperatureJson(
val metric: String?,
)
data class EcccCurrentWeatherDewPointJson(
val metric: String?,
)
data class EcccCurrentWeatherFeelsLikeJson(
val metric: String?,
)
data class EcccCurrentWeatherPressureJson(
val imperial: String?,
)
data class EcccCurrentWeatherVisibilityJson(
val metric: String?,
)
data class EcccCurrentWeatherWindSpeedJson(
val metric: String?,
)
// HOURLY
data class EcccHourlyWeatherJson(
val hourly: List<EcccHourlyWeatherItemJson>
)
data class EcccHourlyWeatherItemJson(
val epochTime: Long,
val windDir: String?,
val temperature: EcccHourlyWeatherItemTemperatureJson,
@SerializedName("precip") val precipProbability: String?,
val iconCode: String,
val windSpeed: EcccHourlyWeatherItemWindSpeedJson
)
data class EcccHourlyWeatherItemTemperatureJson(
val metric: String?,
)
data class EcccHourlyWeatherItemWindSpeedJson(
val metric: String?,
)
// DAILY
data class EcccDailyWeatherJson(
val daily: List<EcccDailyWeatherItemJson>
)
data class EcccDailyWeatherItemJson(
val date: String,
val temperature: EcccDailyWeatherItemTemperatureJson,
@SerializedName("precip") val precipProbability: String?,
val iconCode: String,
val periodLabel: String,
)
data class EcccDailyWeatherItemTemperatureJson(
val metric: String?,
)
@@ -42,13 +42,9 @@ abstract class WeatherMasterDatabase : RoomDatabase() {
abstract fun locationsDao(): LocationsDao
abstract fun weatherDao(): WeatherDao
abstract fun weatherUnitsDao(): WeatherUnitsDao
abstract fun weatherBlocksDao(): WeatherBlocksDao
abstract fun airQualityDao(): AirQualityDao
abstract fun nwsDao(): NwsDao
companion object {
@@ -0,0 +1,132 @@
package com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.eccc
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.DistanceUnit
import com.pranshulgg.weather_master_app.core.model.weather.PressureUnit
import com.pranshulgg.weather_master_app.core.model.weather.wind.WindDirection
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccConditionMap
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.json.EcccWeatherJson
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.iso8601TimestampToMilliseconds
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.normalizeToDay
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.secondsToMilliseconds
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 java.time.LocalDate
import java.time.Year
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.roundToInt
fun EcccWeatherJson.toDomain(location: Location): Weather {
val current = this.observation
val hourly = this.hourlyFcst.hourly
val dailyNight =
this.dailyFcst.daily.filter { it.periodLabel == "Night" || it.periodLabel == "Tonight" }
val daily =
this.dailyFcst.daily.filter { it.periodLabel != "Night" && it.periodLabel != "Tonight" }
.take(dailyNight.size)
val sunTimings = getSunTimings(
daily.map {
dateToMillis(it.date, location.timezone)
},
location.timezone,
location.latitude,
location.longitude
)
val moonTimings = getMoonTimings(
daily.map {
dateToMillis(it.date, location.timezone)
},
location.timezone,
location.latitude,
location.longitude
)
return Weather(
location = location,
current = WeatherCurrent(
temperature = current.temperature.metric?.toDoubleOrNull(),
humidity = current.humidity?.toDouble() ?: 0.0,
windSpeed = current.windSpeed.metric?.toDoubleOrNull(),
windDirection = WindDirection.toWindDirectionFromString(current.windDirection),
pressureMsl = PressureUnit.INHG.convert(
current.pressure.imperial?.toDoubleOrNull(),
PressureUnit.HPA
),
visibility = DistanceUnit.KM.convert(
current.visibility.metric?.toDouble(),
DistanceUnit.M
)?.roundToInt(),
cloudCover = null, // NOT USED IN THE APP
uvIndex = null,
weatherCondition = EcccConditionMap.getCondition(current.iconCode),
feelsLike = current.feelsLike.metric?.toDoubleOrNull(),
time = current.timeStamp.iso8601TimestampToMilliseconds(),
dewPoint = current.dewpoint.metric?.toDoubleOrNull(),
utcOffsetSeconds = null,
lastUpdatedInMilli = System.currentTimeMillis()
),
hourly = hourly.map {
WeatherHourly(
temperature = it.temperature.metric?.toDoubleOrNull(),
windSpeed = it.windSpeed.metric?.toDoubleOrNull(),
windDirection = WindDirection.toWindDirectionFromString(it.windDir),
rain = 0.0, // NULL
snowfall = null,
uvIndex = null,
pressureMsl = null,
visibility = null,
humidity = null,
dewPoint = null,
weatherCondition = EcccConditionMap.getCondition(it.iconCode),
time = it.epochTime.secondsToMilliseconds(),
precipitationProbability = it.precipProbability?.toIntOrNull()
)
},
daily = daily.mapIndexed { index, it ->
WeatherDaily(
temperatureMin = dailyNight[index].temperature.metric?.toDoubleOrNull(),
temperatureMax = it.temperature.metric?.toDoubleOrNull(),
windSpeed = null,
windDirection = null,
rainSum = 0.0,
snowfallSum = null,
uvIndexMax = null,
weatherCondition = EcccConditionMap.getCondition(it.iconCode),
time = dateToMillis(it.date, location.timezone),
precipitationProbabilityMax = it.precipProbability?.toIntOrNull(),
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
)
}
)
}
private fun dateToMillis(dateStr: String, zoneId: String): Long {
val dateWithYear = "$dateStr ${Year.now().value}"
val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy", Locale.ENGLISH)
val localDate = LocalDate.parse(dateWithYear, formatter)
return localDate.atStartOfDay(safeZoneId(zoneId)).toInstant().toEpochMilli()
}
@@ -2,6 +2,7 @@ package com.pranshulgg.weather_master_app.data.provider
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.metnorway.MetNorwayRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.nws.NwsRepository
@@ -16,7 +17,8 @@ class WeatherRepositoryProvider @Inject constructor(
private val metNorwayRepository: MetNorwayRepository,
private val smhiRepository: SmhiRepository,
private val dwdRepository: DwdRepository,
private val meteoFranceRepository: MeteoFranceRepository
private val meteoFranceRepository: MeteoFranceRepository,
private val ecccRepository: EcccRepository
) {
fun getRepository(source: WeatherSource): WeatherRepository {
@@ -27,6 +29,7 @@ class WeatherRepositoryProvider @Inject constructor(
WeatherSource.SMHI -> smhiRepository
WeatherSource.DWD -> dwdRepository
WeatherSource.METEO_FRANCE -> meteoFranceRepository
WeatherSource.ECCC -> ecccRepository
}
}
@@ -47,80 +47,21 @@ fun WeatherSourcesScreen(navController: NavController) {
modifier = Modifier.padding(horizontal = 16.dp)
)
Gap(12.dp)
SettingSection(
title = "Open Meteo (GLOBAL)",
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.OPEN_METEO.displayName,
description = WeatherSource.OPEN_METEO.displayLink,
onClick = { uriHandler.openUri(WeatherSource.OPEN_METEO.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
WeatherSource.entries.forEach {
SettingSection(
title = it.displayName,
tiles = listOf(
SettingTile.ActionTile(
title = it.fullName,
description = it.displayLink,
onClick = { uriHandler.openUri(it.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
)
Gap(10.dp)
SettingSection(
title = "Met Norway (GLOBAL)",
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.MET_NORWAY.displayName,
description = WeatherSource.MET_NORWAY.displayLink,
onClick = { uriHandler.openUri(WeatherSource.MET_NORWAY.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
Gap(10.dp)
SettingSection(
title = "NWS (United states)",
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.NWS.displayName,
description = WeatherSource.NWS.displayLink,
onClick = { uriHandler.openUri(WeatherSource.NWS.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
Gap(10.dp)
SettingSection(
title = "SMHI (Sweden)",
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.SMHI.displayName,
description = WeatherSource.SMHI.displayLink,
onClick = { uriHandler.openUri(WeatherSource.SMHI.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
Gap(10.dp)
SettingSection(
title = "DWD (Germany)",
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.DWD.displayName,
description = WeatherSource.DWD.displayLink,
onClick = { uriHandler.openUri(WeatherSource.DWD.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
Gap(10.dp)
SettingSection(
title = WeatherSource.METEO_FRANCE.displayName,
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.METEO_FRANCE.displayName,
description = WeatherSource.METEO_FRANCE.displayLink,
onClick = { uriHandler.openUri(WeatherSource.METEO_FRANCE.displayLink) },
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) }
)
)
)
Gap(10.dp)
}
Gap(WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + 30.dp)
}
}
}