weather alerts

This commit is contained in:
PranshulGG committed 2026-07-28 16:30:47 +05:30
1 parent c2757ea4b5
commit f36498c5c2
14 files changed
+482

No files matched your search

@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.core.model.domain.alerts
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
data class Alert(
val locationId: String,
val event: String,
val severity: AlertSeverity?,
val effective: Long,
val expires: Long,
val description: String,
val source: String?,
val lastUpdatedInMilli: Long
)
@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.core.model.weather.alerts
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
sealed class AlertResult {
data class Success(val alerts: List<Alert>) : AlertResult()
data class Error(val exception: Exception, val cacheAlerts: List<Alert> = emptyList()) :
AlertResult()
}
enum class AlertResultType {
RETURN_CACHE,
ERROR
}
@@ -0,0 +1,30 @@
package com.pranshulgg.weather_master_app.core.model.weather.alerts
import androidx.compose.ui.graphics.Color
import com.pranshulgg.weather_master_app.R
enum class AlertSeverity(
val label: Int,
val color: Color
) {
CRITICAL(
R.string.alert_severity_critical,
Color(0xFFD32F2F)
),
HIGH(
R.string.alert_severity_high,
Color(0xFFF57C00)
),
MODERATE(
R.string.alert_severity_medium,
Color(0xFFFBC02D)
),
LOW(
R.string.alert_severity_low,
Color(0xFF388E3C)
),
UNKNOWN(
R.string.alert_severity_unknown,
Color(0xFF757575)
)
}
@@ -0,0 +1,59 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu
import com.pranshulgg.weather_master_app.BuildConfig
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiForecastJson
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiJson
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json.AlertsAccuJson
import okhttp3.Interceptor
import okhttp3.OkHttpClient
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 AlertsAccuApi {
@GET("alerts/v1/{locationKey}")
suspend fun fetchAlerts(
@Path("locationKey") locationKey: String,
@Query("details") details: Boolean = true
): Response<List<AlertsAccuJson>>
companion object {
const val BASE_URL = "https://api.accuweather.com/"
fun create(): AlertsAccuApi {
val auth = Interceptor { chain ->
val original = chain.request()
val newUrl =
original.url.newBuilder().addQueryParameter("apikey", BuildConfig.ACCU_KEY)
.build()
val request = original.newBuilder().url(newUrl).build()
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(auth)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AlertsAccuApi::class.java)
}
}
}
@@ -0,0 +1,72 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu
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.alerts.AlertResult
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResultType
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.AccuApi
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnAlertsCache
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toEntity
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.repository.AlertRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class AlertsAccuRepository @Inject constructor(
private val api: AlertsAccuApi,
private val dao: AlertsDao,
private val accuDao: AccuDao,
private val accuApi: AccuApi,
) : AlertRepository {
override suspend fun getAlerts(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AlertResult = withContext(Dispatchers.IO) {
val cache = dao.getAlertsForLocation(location.id)
val shouldReturnCache = shouldReturnAlertsCache(cache, isManualRefresh, isForceRefresh)
when (shouldReturnCache) {
AlertResultType.RETURN_CACHE -> return@withContext AlertResult.Success(cache.map { it!!.toDomain() })
else -> {}
}
return@withContext try {
val locationKey = accuDao.getCityKeyForLocation(location.id)?.toDomain()?.cityKey
?: accuApi.getLocationKey("${location.latitude},${location.longitude}").body()?.key
?: return@withContext AlertResult.Error(exception = AppException.Unknown())
val response = api.fetchAlerts(locationKey)
val body = response.body()
?: return@withContext AlertResult.Error(exception = AppException.Unknown())
val domain = body.map { it.toDomain(location.id) }
accuDao.insertCityKey(
AccuEntity(
locationId = location.id,
cityKey = locationKey
)
)
dao.insertAlerts(
domain.map { it.toEntity(location.id) },
location.id
)
AlertResult.Success(domain)
} catch (e: Exception) {
AlertResult.Error(exception = e, cacheAlerts = cache.map { it!!.toDomain() })
}
}
}
@@ -0,0 +1,24 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
data class AlertsAccuJson(
@SerializedName("Description") val event: AlertsAccuEventJson,
@SerializedName("AlarmLevel") val alarmLevel: String?,
@SerializedName("Source") val source: String?,
@SerializedName("Area") val area: List<AlertsAccuAreaJson>,
)
data class AlertsAccuEventJson(
@SerializedName("English") val english: String,
@SerializedName("Localized") val localized: String
)
data class AlertsAccuAreaJson(
@SerializedName("EpochStartTime") val epochStartTimeSeconds: Long,
@SerializedName("EpochEndTime") val epochEndTimeSeconds: Long,
@SerializedName("Text") val text: String,
)
@@ -0,0 +1,38 @@
package com.pranshulgg.weather_master_app.core.utils.weather.cache
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResultType
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResultType
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.AUTO_REFRESH_MAX_MINUTES
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.MANUAL_REFRESH_MINUTES
import com.pranshulgg.weather_master_app.data.local.entity.airquality.AirQualityWithRelations
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
import java.util.concurrent.TimeUnit
fun shouldReturnAlertsCache(
cache: List<AlertEntity?>,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AlertResultType {
if (isForceRefresh) return AlertResultType.ERROR
if (cache.isEmpty() || cache[0] == null) {
return AlertResultType.ERROR
}
val cacheMilli = cache[0]!!.lastUpdatedInMilli
val ageMillis = System.currentTimeMillis() - cacheMilli
val ageMinutes = TimeUnit.MILLISECONDS.toMinutes(ageMillis)
val tooEarly = isManualRefresh && ageMinutes < MANUAL_REFRESH_MINUTES
val maxAge = if (isManualRefresh) MANUAL_REFRESH_MINUTES else AUTO_REFRESH_MAX_MINUTES
if (tooEarly) return AlertResultType.RETURN_CACHE
val shouldReturnCache = ageMinutes < maxAge
return if (shouldReturnCache) AlertResultType.RETURN_CACHE else AlertResultType.ERROR
}
@@ -0,0 +1,31 @@
package com.pranshulgg.weather_master_app.data.local.dao.alerts
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.pranshulgg.weather_master_app.data.local.entity.airquality.CurrentAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
@Dao
interface AlertsDao {
@Transaction
suspend fun insertAlerts(alerts: List<AlertEntity>, locationId: String) {
deleteAlertsForLocation(locationId)
insertAlertList(alerts)
}
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
suspend fun insertAlertList(alerts: List<AlertEntity>)
@Query("SELECT * FROM alerts WHERE locationId = :locationId")
suspend fun getAlertsForLocation(locationId: String): List<AlertEntity?>
@Query("DELETE FROM alerts WHERE locationId = :locationId")
suspend fun deleteAlertsForLocation(locationId: String)
}
@@ -0,0 +1,34 @@
package com.pranshulgg.weather_master_app.data.local.entity.alerts
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
@Entity(
tableName = "alerts",
foreignKeys = [
ForeignKey(
entity = WeatherLocationEntity::class,
parentColumns = ["id"],
childColumns = ["locationId"],
onDelete = ForeignKey.Companion.CASCADE
)
],
indices = [Index("locationId")],
)
data class AlertEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val locationId: String,
val event: String,
val severity: AlertSeverity?,
val effective: Long,
val expires: Long,
val description: String,
val source: String?,
val lastUpdatedInMilli: Long
)
@@ -0,0 +1,31 @@
package com.pranshulgg.weather_master_app.data.local.mapper.alerts
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
fun Alert.toEntity(locationId: String): AlertEntity {
return AlertEntity(
locationId = locationId,
event = event,
severity = severity,
effective = effective,
expires = expires,
description = description,
source = source,
lastUpdatedInMilli = lastUpdatedInMilli
)
}
fun AlertEntity.toDomain(): Alert {
return Alert(
locationId = locationId,
event = event,
severity = severity,
effective = effective,
expires = expires,
description = description,
source = source,
lastUpdatedInMilli = lastUpdatedInMilli
)
}
@@ -0,0 +1,29 @@
package com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.accu
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json.AlertsAccuJson
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.secondsToMilliseconds
fun AlertsAccuJson.toDomain(locationId: String): Alert {
return Alert(
locationId = locationId,
event = event.english,
severity = getSeverity(alarmLevel),
effective = area[0].epochStartTimeSeconds.secondsToMilliseconds(),
expires = area[0].epochEndTimeSeconds.secondsToMilliseconds(),
description = area[0].text,
source = source,
lastUpdatedInMilli = System.currentTimeMillis()
)
}
private fun getSeverity(severity: String?): AlertSeverity {
return when (severity) {
"Red" -> AlertSeverity.CRITICAL
"Orange" -> AlertSeverity.HIGH
"Yellow" -> AlertSeverity.MODERATE
else -> AlertSeverity.UNKNOWN
}
}
@@ -0,0 +1,21 @@
package com.pranshulgg.weather_master_app.data.provider
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.AlertsAccuRepository
import com.pranshulgg.weather_master_app.data.repository.AlertRepository
import javax.inject.Inject
class AlertsRepositoryProvider @Inject constructor(
private val alertsAccuRepository: AlertsAccuRepository
) {
fun getRepository(source: AlertSource): AlertRepository? {
return when (source) {
AlertSource.ACCU_WEATHER -> alertsAccuRepository
AlertSource.WEATHER_API -> alertsAccuRepository
AlertSource.NONE -> null
}
}
}
@@ -0,0 +1,12 @@
package com.pranshulgg.weather_master_app.data.repository
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult
interface AlertRepository {
suspend fun getAlerts(
location: Location,
isManualRefresh: Boolean = false,
isForceRefresh: Boolean
): AlertResult
}
@@ -0,0 +1,71 @@
package com.pranshulgg.weather_master_app.feature.main.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
import com.pranshulgg.weather_master_app.core.prefs.AppPrefsState
import com.pranshulgg.weather_master_app.core.ui.components.SettingSection
import com.pranshulgg.weather_master_app.core.ui.components.Symbol
import com.pranshulgg.weather_master_app.core.ui.theme.ShadowElevation
import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun AlertsSection(alerts: List<Alert>, prefs: AppPrefsState, zoneId: String) {
val pattern = if (prefs.is24HrTimeFormat) "MMM dd, HH:mm" else "MMM dd, hh:mm a"
val formatter: (Long) -> String = {
val formatter = DateTimeFormatter.ofPattern(pattern)
val instant = Instant.ofEpochMilli(it)
val dateTime = instant.atZone(safeZoneId(zoneId)).toLocalDateTime()
formatter.format(dateTime)
}
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.extraLarge,
shadowElevation = ShadowElevation.level2
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
alerts.forEach {
ListItem(
colors = ListItemDefaults.colors(
containerColor = Color.Transparent
),
leadingContent = {
Symbol(
R.drawable.warning_24px,
color = it.severity?.color ?: AlertSeverity.UNKNOWN.color,
size = 32.dp
)
},
headlineContent = { Text(it.event) },
supportingContent = {
Text("${formatter(it.effective)} • ${formatter(it.expires)}")
}
)
}
}
}
}