Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Overcast reads from the source you choose in settings. API keys are kept on the
| Visual Crossing | Yes | |
| OpenWeatherMap | Yes | |
| WeatherAPI.com | Yes | |
| Yandex Weather | Yes | Free key for personal, non-commercial use. Today and tomorrow only, 30 requests a day. No humidity or pressure. Yandex requires its attribution. |

Alert coverage depends on the source. Not every provider returns alerts for every region.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,7 @@ private class Detail(val slot: WeatherDetailSlot, val label: Int, val value: Str
private fun DetailsSection(snapshot: WeatherSnapshot, units: WeatherUnits, palette: WeatherPalette, modifier: Modifier) {
val extras = snapshot.extras
val details = buildList {
add(Detail(WeatherDetailSlot.HUMIDITY, R.string.weather_detail_humidity, "${snapshot.humidity}%"))
snapshot.humidity?.let { add(Detail(WeatherDetailSlot.HUMIDITY, R.string.weather_detail_humidity, "$it%")) }
add(
Detail(
WeatherDetailSlot.WIND,
Expand All @@ -1033,7 +1033,7 @@ private fun DetailsSection(snapshot: WeatherSnapshot, units: WeatherUnits, palet
),
)
extras?.windGustKph?.let { add(Detail(WeatherDetailSlot.GUSTS, R.string.weather_detail_gusts, WeatherFormat.wind(it, units.wind))) }
add(Detail(WeatherDetailSlot.RAIN_CHANCE, R.string.weather_detail_rain_chance, "${snapshot.chanceOfRain}%"))
snapshot.chanceOfRain?.let { add(Detail(WeatherDetailSlot.RAIN_CHANCE, R.string.weather_detail_rain_chance, "$it%")) }
extras?.precipitationMm?.let {
add(Detail(WeatherDetailSlot.PRECIPITATION, R.string.weather_detail_precipitation, WeatherFormat.precipitation(it, units.precipitation)))
}
Expand Down Expand Up @@ -1147,7 +1147,7 @@ private fun Footer(
verticalArrangement = Arrangement.Center,
) {
Text(
text = error?.let { errorLabel(context, it) } ?: WeatherProviders.byId(snapshot.providerId).displayName,
text = error?.let { errorLabel(context, it) } ?: providerLabel(snapshot.providerId),
color = palette.accent,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
Expand Down Expand Up @@ -1306,3 +1306,9 @@ internal fun AmbientClock(ambient: () -> Float, palette: WeatherPalette, modifie
},
)
}

@Composable
internal fun providerLabel(providerId: String): String {
val provider = WeatherProviders.byId(providerId)
return provider.attributionRes?.let { stringResource(it) } ?: provider.displayName
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import com.sameerasw.overcast.weather.WindSpeedUnit
import com.sameerasw.overcast.weather.model.WeatherError
import com.sameerasw.overcast.weather.provider.OpenMeteoModels
import com.sameerasw.overcast.weather.provider.WeatherProviders
import com.sameerasw.overcast.weather.work.WeatherScheduler
import kotlinx.coroutines.launch


Expand Down Expand Up @@ -130,6 +131,7 @@ fun WeatherSettingsUI(
onClick = {
if (option.id != providerId) {
settings.setWeatherProvider(option.id)
WeatherScheduler.schedule(context, settings.getWeatherRefreshMinutes())
providerId = option.id
savedKey = settings.getWeatherApiKey(option.id).orEmpty()
keyInput = savedKey
Expand Down Expand Up @@ -245,6 +247,7 @@ fun WeatherSettingsUI(
onClick = {
refreshMinutes = minutes
settings.setWeatherRefreshMinutes(minutes)
WeatherScheduler.schedule(context, minutes)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.location.Location
import androidx.annotation.Keep
import com.google.gson.Gson
import java.util.Locale
import com.sameerasw.overcast.data.repository.SettingsRepository
import com.sameerasw.overcast.weather.location.DeviceLocationSource
import com.sameerasw.overcast.weather.location.WeatherPlaces
Expand Down Expand Up @@ -45,6 +46,7 @@ object WeatherRepository {
val lastDeviceLocation: WeatherLocation? = null,
val lastDeviceLocationAt: Long? = null,
val notifiedAlertIds: Set<String>? = null,
val lastFetchKey: String? = null,
)

fun config(context: Context): WeatherConfig = SettingsWeatherConfig(SettingsRepository(context.applicationContext))
Expand All @@ -68,7 +70,8 @@ object WeatherRepository {

fun isStale(context: Context): Boolean {
val snapshot = _state.value.snapshot ?: return true
val interval = config(context).refreshIntervalMinutes * 60_000L
val settings = config(context)
val interval = maxOf(settings.refreshIntervalMinutes, WeatherProviders.byId(settings.providerId).minRefreshMinutes) * 60_000L
return System.currentTimeMillis() - snapshot.updatedAt >= interval
}

Expand Down Expand Up @@ -99,9 +102,16 @@ object WeatherRepository {
_state.update { it.copy(error = error, loading = false) }
return@withLock false
}
val fetchKey = "${provider.id}:${"%.2f".format(Locale.US, location.latitude)},${"%.2f".format(Locale.US, location.longitude)}"
if (provider.minRefreshMinutes > 0 && current != null && cache.lastFetchKey == fetchKey &&
System.currentTimeMillis() - current.updatedAt < provider.minRefreshMinutes * 60_000L
) {
_state.update { it.copy(loading = false) }
return@withLock false
}
try {
val snapshot = provider.fetch(location, config.apiKey)
cache = cache.copy(snapshot = snapshot)
cache = cache.copy(snapshot = snapshot, lastFetchKey = fetchKey)
persist(app)
_state.value = WeatherState(snapshot = snapshot)
WeatherWidgetUpdater.updateAll(app)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import com.sameerasw.overcast.weather.effects.WeatherEffects
import com.sameerasw.overcast.weather.model.WeatherAlert
import com.sameerasw.overcast.weather.model.WeatherError
import com.sameerasw.overcast.weather.model.WeatherSnapshot
import com.sameerasw.overcast.weather.provider.WeatherProviders
import com.sameerasw.overcast.ui.features.weather.providerLabel
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Date
Expand Down Expand Up @@ -151,9 +151,9 @@ fun WeatherIslandExpanded(
WeatherIslandPayload.severeAlert(snapshot)?.let { AlertCard(it) }
HourlyStrip(snapshot, units, iconStyle)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
DetailChip(R.drawable.rounded_water_drop_24, "${snapshot.humidity}%", Modifier.weight(1f))
DetailChip(R.drawable.rounded_water_drop_24, snapshot.humidity?.let { "$it%" } ?: "–", Modifier.weight(1f))
DetailChip(R.drawable.rounded_air_24, WeatherFormat.wind(snapshot.windKph, units.wind), Modifier.weight(1f))
DetailChip(R.drawable.rounded_rainy_24, "${snapshot.chanceOfRain}%", Modifier.weight(1f))
DetailChip(R.drawable.rounded_rainy_24, snapshot.chanceOfRain?.let { "$it%" } ?: "–", Modifier.weight(1f))
}
UpdatedRow(snapshot, state.loading, state.error, onKeepAlive, onRefresh)
}
Expand Down Expand Up @@ -280,7 +280,7 @@ private fun UpdatedRow(
).toString()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (error != null) errorText(error) else "${WeatherProviders.byId(snapshot.providerId).displayName} - $age",
text = if (error != null) errorText(error) else "${providerLabel(snapshot.providerId)} - $age",
style = body.copy(fontSize = 11.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ object WeatherIslandPayload {
onExpandedTap = IslandAction(IslandActionType.OPEN_APP),
),
)
val trailingId = if (alert != null) ICON_ALERT else RAIN_ICON.takeIf { snapshot.chanceOfRain > 0 }
val trailingId = if (alert != null) ICON_ALERT else RAIN_ICON.takeIf { (snapshot.chanceOfRain ?: 0) > 0 }
if (alert == null && trailingId != null) {
WeatherIslandIcons.bitmap(context, R.drawable.rounded_rainy_24)?.also { assets[RAIN_ICON] = it }
}
Expand All @@ -107,7 +107,7 @@ object WeatherIslandPayload {
iconId = iconId,
tone = if (style.tintable) IslandTone.ACCENT else IslandTone.NONE,
trailingIconId = trailingId,
trailingText = "${snapshot.chanceOfRain}%".takeIf { alert == null && trailingId != null },
trailingText = snapshot.chanceOfRain?.let { "$it%" }.takeIf { alert == null && trailingId != null },
trailingTone = if (alert != null) IslandTone.ALERT else IslandTone.ACCENT,
pageHeightDp = if (alert != null) HEIGHT_ALERT_DP else HEIGHT_DP,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ data class WeatherSnapshot(
val condition: WeatherCondition,
val conditionText: String,
val isDay: Boolean,
val humidity: Int,
val humidity: Int?,
val windKph: Double,
val chanceOfRain: Int,
val chanceOfRain: Int?,
val highC: Double,
val lowC: Double,
val hourly: List<HourlyForecast>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ internal object ProviderHttp {

fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")

suspend fun get(url: String, headers: Map<String, String> = emptyMap()): String = withContext(Dispatchers.IO) {
suspend fun get(url: String, headers: Map<String, String> = emptyMap()): String = request(url, headers, null)

suspend fun post(url: String, body: String, headers: Map<String, String> = emptyMap()): String = request(url, headers, body)

private suspend fun request(url: String, headers: Map<String, String>, body: String?): String = withContext(Dispatchers.IO) {
val connection = try {
URL(url).openConnection() as HttpURLConnection
} catch (e: IOException) {
Expand All @@ -27,6 +31,12 @@ internal object ProviderHttp {
connection.readTimeout = TIMEOUT_MS
connection.setRequestProperty("User-Agent", USER_AGENT)
headers.forEach { (k, v) -> connection.setRequestProperty(k, v) }
if (body != null) {
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val code = connection.responseCode
if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) {
throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.sameerasw.overcast.weather.provider

import androidx.annotation.StringRes
import com.sameerasw.overcast.weather.model.CityResult
import com.sameerasw.overcast.weather.model.WeatherLocation
import com.sameerasw.overcast.weather.model.WeatherSnapshot
Expand All @@ -9,6 +10,9 @@ interface WeatherProvider {
val displayName: String
val requiresApiKey: Boolean
val signupUrl: String?
// Minimum minutes between fetches for providers with a daily request cap; 0 means no limit.
val minRefreshMinutes: Int get() = 0
@get:StringRes val attributionRes: Int? get() = null

suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot

Expand All @@ -30,6 +34,7 @@ object WeatherProviders {
OpenWeatherMapProvider(),
WeatherApiProvider(),
AccuWeatherProvider(),
YandexWeatherProvider(),
)

val default: WeatherProvider get() = all.first()
Expand Down
Loading
Loading