Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ android {
applicationId = "com.sameerasw.overcast"
minSdk = 26
targetSdk = 37
versionCode = 2
versionName = "1.1"
versionCode = 3
versionName = "1.2"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -86,6 +86,7 @@ android {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.window)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.foundation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,11 @@ class SettingsRepository(
const val KEY_WEATHER_ICON_STYLE = "weather_icon_style"
const val KEY_COMPATIBILITY_MODE = "compatibility_mode"
const val KEY_AMBIENT_FORECAST = "ambient_show_forecast"
const val KEY_AMBIENT_TIMELINE = "ambient_timeline_mode"
const val KEY_SCREENSAVER_AUTO_ANIMATE = "screensaver_auto_animate"
const val KEY_WEATHER_REFRESH_MINUTES = "weather_refresh_minutes"
const val KEY_WEATHER_EFFECTS = "weather_effects"
const val KEY_WEATHER_HAPTICS = "weather_haptics"
const val KEY_ISLAND_MODE = "island_mode"
const val KEY_ISLAND_PEEK_ALERTS = "island_peek_alerts"
const val ISLAND_MODE_BRIEF = "brief"
const val ISLAND_MODE_COMPACT = "compact"
const val ISLAND_MODE_ALERTS = "alerts"
const val KEY_DEBUG_SIMULATED_WEATHER = "debug_simulated_weather"
const val KEY_DEBUG_SIMULATED_ALERT = "debug_simulated_alert"
const val KEY_DEBUG_SIMULATED_TIME = "debug_simulated_time"
Expand Down Expand Up @@ -86,11 +83,7 @@ class SettingsRepository(
fun isDeveloperModeEnabled(): Boolean = getBoolean(KEY_DEVELOPER_MODE_ENABLED, false)
fun setDeveloperModeEnabled(enabled: Boolean) = putBoolean(KEY_DEVELOPER_MODE_ENABLED, enabled)

fun getIslandMode(): String = getString(KEY_ISLAND_MODE, ISLAND_MODE_COMPACT) ?: ISLAND_MODE_COMPACT
fun setIslandMode(mode: String) = putString(KEY_ISLAND_MODE, mode)

fun isIslandPeekAlertsEnabled(): Boolean = getBoolean(KEY_ISLAND_PEEK_ALERTS, true)
fun setIslandPeekAlertsEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_PEEK_ALERTS, enabled)

fun registerChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) =
prefs.registerOnSharedPreferenceChangeListener(listener)
Expand Down Expand Up @@ -175,6 +168,14 @@ class SettingsRepository(

fun isAmbientForecastEnabled(): Boolean = getBoolean(KEY_AMBIENT_FORECAST, true)

fun isAmbientTimelineEnabled(): Boolean = getBoolean(KEY_AMBIENT_TIMELINE, false)

fun setAmbientTimelineEnabled(enabled: Boolean) = putBoolean(KEY_AMBIENT_TIMELINE, enabled)

fun isScreensaverAutoAnimateEnabled(): Boolean = getBoolean(KEY_SCREENSAVER_AUTO_ANIMATE, false)

fun setScreensaverAutoAnimateEnabled(enabled: Boolean) = putBoolean(KEY_SCREENSAVER_AUTO_ANIMATE, enabled)

fun setAmbientForecastEnabled(enabled: Boolean) = putBoolean(KEY_AMBIENT_FORECAST, enabled)

fun isCompatibilityMode(): Boolean =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.sameerasw.overcast.ui.features.weather

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos

internal fun DrawScope.drawMoonPhase(center: Offset, radius: Float, phase: Double, color: Color, litAlpha: Float, darkAlpha: Float) {
drawCircle(color.copy(alpha = darkAlpha), radius = radius, center = center)
val waxing = phase < 0.5
val terminator = cos(2 * PI * phase).toFloat().let { if (waxing) it else -it }
val lit = Path().apply {
val disc = Rect(center - Offset(radius, radius), Size(radius * 2, radius * 2))
arcTo(disc, -90f, 180f, true)
val width = abs(terminator) * radius
val ellipse = Rect(center.x - width, center.y - radius, center.x + width, center.y + radius)
if (terminator >= 0f) arcTo(ellipse, 90f, -180f, false) else arcTo(ellipse, 90f, 180f, false)
close()
}
scale(if (waxing) 1f else -1f, 1f, pivot = center) {
drawPath(lit, color.copy(alpha = litAlpha))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
Expand All @@ -25,6 +25,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
Expand All @@ -40,6 +42,16 @@ import com.sameerasw.overcast.weather.effects.WeatherEffectSpec
import com.sameerasw.overcast.weather.effects.WeatherEffects
import com.sameerasw.overcast.weather.model.WeatherSnapshot
import kotlinx.coroutines.delay
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.drop
import com.sameerasw.overcast.utils.HapticUtil
import kotlin.math.roundToInt
import androidx.compose.runtime.setValue
import java.util.Calendar

internal val AMBIENT_EDGE_GAP = 16.dp
Expand All @@ -49,7 +61,7 @@ internal const val AMBIENT_SURFACE_PREFIX = "ambient-hourly"
@Composable
internal fun BoxScope.WeatherSkyBackground(
snapshot: WeatherSnapshot?,
now: Long,
now: () -> Long,
palette: WeatherPalette,
collapse: () -> Float,
rise: () -> Float = { 1f },
Expand All @@ -74,19 +86,19 @@ internal fun BoxScope.WeatherSkyBackground(
internal fun Modifier.weatherGlass(
spec: WeatherEffectSpec,
snapshot: WeatherSnapshot?,
now: Long,
now: () -> Long,
collapse: () -> Float,
intensityScale: Float = 1f,
enabled: Boolean = true,
rise: () -> Float = { 1f },
): Modifier {
val density = LocalDensity.current
val rain = spec.layers.filterIsInstance<WeatherEffectLayer.Rain>().maxByOrNull { it.intensity }.takeIf { enabled }
val sky = snapshot?.let { skyState(it, now) }
val skyHeightPx = with(density) { SKY_HEIGHT.toPx() }
val collapseShiftPx = with(density) { 60.dp.toPx() }
val fallbackYPx = with(density) { 150.dp.toPx() }
return rainOnGlass((rain?.intensity ?: 0f) * intensityScale, rain?.slant ?: 0f) {
val sky = snapshot?.let { skyState(it, now()) }
if (sky == null) {
Offset(0.5f, fallbackYPx - collapse() * collapseShiftPx)
} else {
Expand Down Expand Up @@ -138,14 +150,31 @@ internal fun AmbientWeatherScene(
modifier: Modifier = Modifier,
onDismiss: (() -> Unit)? = null,
animateIntro: Boolean = false,
autoAnimate: Boolean = false,
) {
val snapshot = presentation.snapshot
val spec = presentation.effectSpec
val minute by rememberMinuteClock()
// A slow drift keeps static text from sitting on the same pixels for hours.
val steps = Calendar.getInstance().apply { timeInMillis = minute }.get(Calendar.MINUTE) / 5
val clockPx = remember { mutableIntStateOf(0) }
val forecastPx = remember { mutableIntStateOf(0) }
val rainSurfaces = remember { mutableStateMapOf<String, RainSurfaceSource>() }
val cards = { rainSurfaces.entries.mapNotNull { (key, source) -> source.resolve(key) } }
val timeline = presentation.timeline
val scope = rememberCoroutineScope()
val stepPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() } / TIMELINE_STEPS_PER_SCREEN
val showReturn by remember(timeline) { derivedStateOf { (timeline?.value ?: 0f) > 0.05f } }
SideEffect { timeline?.loop = autoAnimate }
LaunchedEffect(timeline, autoAnimate) {
if (timeline == null || !autoAnimate) return@LaunchedEffect
while (true) {
delay(TIMELINE_IDLE_MS)
val end = timeline.maxPosition
animate(0f, end, animationSpec = tween((end * TIMELINE_STEP_MS).toInt(), easing = LinearEasing)) { v, _ -> timeline.set(v) }
timeline.set(0f)
}
}
val animate = animateIntro && !CompatibilityMode.enabled.value
val intro = remember { Animatable(if (animate) 0f else 1f) }
LaunchedEffect(animate) {
Expand All @@ -156,20 +185,21 @@ internal fun AmbientWeatherScene(
CompositionLocalProvider(LocalWeatherIconStyle provides presentation.iconStyle, LocalRainSurfaces provides rainSurfaces) {
BoxWithConstraints(
modifier.fillMaxSize()
.weatherGlass(spec, snapshot, presentation.now, { 0f }, rise = riseStage)
.dismissOnTouch(onDismiss),
.weatherGlass(spec, snapshot, presentation.clock, { 0f }, rise = riseStage)
.dismissOnTouch(onDismiss)
.then(if (timeline != null && !autoAnimate) Modifier.timelineGestures(timeline, scope, stepPx, onVerticalDrag = {}, onVerticalEnd = {}) else Modifier),
) {
WeatherSkyBackground(snapshot, presentation.now, presentation.palette, { 0f }, riseStage)
if (!spec.isEmpty) {
WeatherEffects(
spec = spec,
modifier = Modifier.matchParentSize(),
strength = 1.7f * stage(0.1f, 0.6f),
surfaces = cards,
cover = cards,
)
}
WeatherSkyBackground(snapshot, presentation.clock, presentation.palette, { 0f }, riseStage)
WeatherEffects(
spec = spec,
modifier = Modifier.matchParentSize(),
strength = 1.7f * stage(0.1f, 0.6f),
surfaces = cards,
cover = cards,
)
if (snapshot != null) {
val maxHeightPx = this@BoxWithConstraints.constraints.maxHeight
val forecastShown = showForecast && timeline == null && ambientForecastFits(maxHeightPx, clockPx.intValue, forecastPx.intValue)
Box(Modifier.fillMaxSize().offset(x = ((steps % 5) - 2).dp * 3, y = (((steps / 5) % 3) - 1).dp * 3)) {
Header(
snapshot,
Expand All @@ -179,21 +209,37 @@ internal fun AmbientWeatherScene(
1f,
Modifier
.padding(horizontal = SIDE_PADDING)
.ambientCenter(1f, this@BoxWithConstraints.constraints.maxHeight)
.ambientCenter(1f, maxHeightPx, rememberAnimatedAvailable(ambientAvailable(maxHeightPx, clockPx.intValue, if (forecastShown || timeline != null) forecastPx.intValue else 0)))
.graphicsLayer {
val reveal = stage(0.15f, 0.65f)
alpha = reveal
translationY = (1f - reveal) * 48.dp.toPx()
},
)
AmbientClock({ 0.4f + 0.6f * stage(0.3f, 0.75f) }, presentation.palette, Modifier.align(Alignment.TopCenter))
if (showForecast) {
AmbientClock(
{ 0.4f + 0.6f * stage(0.3f, 0.75f) },
presentation.palette,
Modifier.align(Alignment.TopCenter).onSizeChanged { clockPx.intValue = it.height },
timeMillis = presentation.clockLabel.takeIf { timeline != null },
showReturn = showReturn && !autoAnimate,
atNow = presentation.timelineAtNow,
onReturn = { timeline?.animateTo(scope, 0f) },
)
if (timeline != null) {
TimelineRain(
{ 0.4f + 0.6f * stage(0.45f, 0.95f) },
snapshot.chanceOfRain,
presentation.palette,
Modifier.align(Alignment.BottomCenter).onSizeChanged { forecastPx.intValue = it.height },
)
}
if (forecastShown) {
AmbientForecast(
{ 0.4f + 0.6f * stage(0.45f, 0.95f) },
snapshot,
presentation.units.temperature,
presentation.palette,
Modifier.align(Alignment.BottomCenter),
Modifier.align(Alignment.BottomCenter).onSizeChanged { forecastPx.intValue = it.height },
)
}
}
Expand All @@ -203,12 +249,13 @@ internal fun AmbientWeatherScene(
}

private const val AMBIENT_INTRO_MS = 2600
private const val TIMELINE_STEPS_PER_SCREEN = 4f
private const val TIMELINE_IDLE_MS = 14_000L
private const val TIMELINE_STEP_MS = 900

private fun Modifier.dismissOnTouch(onDismiss: (() -> Unit)?): Modifier =
if (onDismiss == null) {
this
} else {
this
.pointerInput(onDismiss) { detectTapGestures(onTap = { onDismiss() }) }
.pointerInput(onDismiss) { detectVerticalDragGestures { _, _ -> onDismiss() } }
this.pointerInput(onDismiss) { detectTapGestures(onTap = { onDismiss() }) }
}
Loading
Loading