diff --git a/README.md b/README.md index 6f0b730..0a8de81 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d380f5..669308f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } @@ -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) diff --git a/app/src/main/java/com/sameerasw/overcast/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/overcast/data/repository/SettingsRepository.kt index a60c090..8abc939 100644 --- a/app/src/main/java/com/sameerasw/overcast/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/overcast/data/repository/SettingsRepository.kt @@ -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" @@ -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) @@ -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 = diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/MoonDrawing.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/MoonDrawing.kt new file mode 100644 index 0000000..8ebd093 --- /dev/null +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/MoonDrawing.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherAmbient.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherAmbient.kt index e38407c..28597c1 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherAmbient.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherAmbient.kt @@ -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 @@ -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 @@ -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 @@ -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 }, @@ -74,7 +86,7 @@ internal fun BoxScope.WeatherSkyBackground( internal fun Modifier.weatherGlass( spec: WeatherEffectSpec, snapshot: WeatherSnapshot?, - now: Long, + now: () -> Long, collapse: () -> Float, intensityScale: Float = 1f, enabled: Boolean = true, @@ -82,11 +94,11 @@ internal fun Modifier.weatherGlass( ): Modifier { val density = LocalDensity.current val rain = spec.layers.filterIsInstance().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 { @@ -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() } 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) { @@ -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, @@ -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 }, ) } } @@ -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() }) } } diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt index 52e19c0..d754d85 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt @@ -18,6 +18,10 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.util.lerp +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.runtime.mutableFloatStateOf @@ -83,6 +87,7 @@ import androidx.compose.material3.SplitButtonDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.MutableFloatState import androidx.compose.animation.core.spring @@ -106,6 +111,7 @@ import android.app.Activity import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -148,7 +154,19 @@ 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.weather.MoonPhase +import com.sameerasw.overcast.weather.moonPhaseAt import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop +import androidx.compose.runtime.SideEffect +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -177,10 +195,14 @@ internal class WeatherPresentation( val now: Long, val haptics: WeatherEffectHaptics?, val iconStyle: WeatherIconStyle, + val timeline: WeatherTimeline? = null, + val clock: () -> Long = { now }, + val clockLabel: Long = now, + val timelineAtNow: Boolean = true, ) @Composable -internal fun rememberWeatherPresentation(real: WeatherSnapshot?): WeatherPresentation { +internal fun rememberWeatherPresentation(real: WeatherSnapshot?, timeline: WeatherTimeline? = null): WeatherPresentation { val context = LocalContext.current val settings = remember { SettingsRepository(context) } var settingsVersion by remember { mutableIntStateOf(0) } @@ -195,7 +217,7 @@ internal fun rememberWeatherPresentation(real: WeatherSnapshot?): WeatherPresent val tempOverride = remember(settingsVersion) { settings.getSimulatedTempC() } val alertOverride = remember(settingsVersion) { settings.getSimulatedAlertId() } val simulated = real?.let { r -> simulation?.let { WeatherSimulation.apply(r, it) } ?: r } - val snapshot = simulated?.let { WeatherSimulation.withTimeOfDay(it, timeOverride) }?.let { s -> tempOverride?.let { s.copy(tempC = it) } ?: s } + val baseSnapshot = simulated?.let { WeatherSimulation.withTimeOfDay(it, timeOverride) }?.let { s -> tempOverride?.let { s.copy(tempC = it) } ?: s } ?.let { s -> WeatherSimulation.alertsFor(alertOverride, System.currentTimeMillis())?.let { s.copy(alerts = it) } ?: s } val units = remember(settingsVersion) { WeatherUnits.from(settings) } val iconStyle = remember(settingsVersion) { WeatherIconStyle.fromId(settings.getWeatherIconStyle()) } @@ -206,10 +228,32 @@ internal fun rememberWeatherPresentation(real: WeatherSnapshot?): WeatherPresent clock = System.currentTimeMillis() } } - val now = WeatherSimulation.timeFor(snapshot, timeOverride, clock) + val now = WeatherSimulation.timeFor(baseSnapshot, timeOverride, clock) + val timelineEnabled = remember(settingsVersion) { settings.isAmbientTimelineEnabled() } + val looping = timeline?.loop == true + val points = remember(baseSnapshot, now / 60_000L, looping) { baseSnapshot?.let { timelinePoints(it, now, looping) }.orEmpty() } + val timelineActive = timeline != null && timelineEnabled && points.size > 1 + SideEffect { timeline?.points = if (timelineEnabled) points else emptyList() } + val step by remember(timeline) { derivedStateOf { timeline?.value?.roundToInt() ?: 0 } } + val atNow by remember(timeline) { derivedStateOf { (timeline?.value ?: 0f) < 0.001f } } + val tempHalfDegrees by remember(timeline) { derivedStateOf { ((timeline?.tempAt(timeline.value) ?: 0.0) * 2).roundToInt() } } + val virtualQuarterHour by remember(timeline) { derivedStateOf { (timeline?.timeAt(timeline.value) ?: 0L) / QUARTER_HOUR_MS } } + val snapshot = if (!timelineActive || baseSnapshot == null || atNow) { + baseSnapshot + } else { + val point = points[step.coerceIn(0, points.lastIndex)] + baseSnapshot.copy( + tempC = tempHalfDegrees / 2.0, + condition = point.condition, + conditionText = point.conditionText ?: conditionLabel(context, point.condition), + isDay = point.isDay, + chanceOfRain = point.chanceOfRain ?: baseSnapshot.chanceOfRain, + ) + } + val paletteNow = if (timelineActive && !atNow) virtualQuarterHour * QUARTER_HOUR_MS else now val palette = animatedPalette( - remember(snapshot?.condition, snapshot?.isDay, snapshot?.extras?.sunriseMillis, snapshot?.extras?.sunsetMillis, now / 60_000L, timeOverride) { - WeatherPalette.from(snapshot, now, timeOverride) + remember(snapshot?.condition, snapshot?.isDay, snapshot?.extras?.sunriseMillis, snapshot?.extras?.sunsetMillis, paletteNow / 60_000L, timeOverride) { + WeatherPalette.from(snapshot, paletteNow, timeOverride) }, ) val effects = remember(settingsVersion) { settings.isWeatherEffectsEnabled() && !DeviceUtils.isPowerSaveMode(context) } @@ -219,7 +263,10 @@ internal fun rememberWeatherPresentation(real: WeatherSnapshot?): WeatherPresent val effectSpec = remember(effects, snapshot?.condition, snapshot?.isDay, snapshot?.windKph, simulation?.id) { snapshot?.takeIf { effects }?.let { simulation?.spec ?: WeatherEffectSpec.from(it) } ?: WeatherEffectSpec.None } - return WeatherPresentation(snapshot, palette, effectSpec, units, now, haptics, iconStyle) + val activeTimeline = timeline?.takeIf { timelineActive } + val clockProvider = remember(activeTimeline, now) { if (activeTimeline != null) ({ activeTimeline.timeAt(activeTimeline.value) }) else ({ now }) } + val clockLabel = if (timelineActive && step > 0) points[step.coerceIn(0, points.lastIndex)].timeMillis else now + return WeatherPresentation(snapshot, palette, effectSpec, units, now, haptics, iconStyle, activeTimeline, clockProvider, clockLabel, step == 0) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -230,7 +277,8 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { val scope = rememberCoroutineScope() val settings = remember { SettingsRepository(context) } val state by WeatherRepository.state.collectAsState() - val presentation = rememberWeatherPresentation(state.snapshot) + val timeline = remember { WeatherTimeline() } + val presentation = rememberWeatherPresentation(state.snapshot, timeline) val snapshot = presentation.snapshot val units = presentation.units val now = presentation.now @@ -320,6 +368,19 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { val ambient = remember { mutableFloatStateOf(0f) } val immersive = ambient.floatValue > 0.5f val rootHeightPx = remember { mutableIntStateOf(0) } + val rootWidthPx = remember { mutableIntStateOf(0) } + val ambientClockPx = remember { mutableIntStateOf(0) } + val ambientForecastPx = remember { mutableIntStateOf(0) } + val timelineActive = presentation.timeline != null + val showTimelineReturn by remember { derivedStateOf { timeline.value > 0.05f } } + LaunchedEffect(immersive) { + if (!immersive) timeline.animateTo(scope, 0f) + } + LaunchedEffect(timeline) { + snapshotFlow { timeline.value.roundToInt() }.drop(1).collect { + if (ambient.floatValue > 0.5f) HapticUtil.performLightHaptic(view) + } + } BackHandler(enabled = immersive) { scope.launch { settleAmbient(ambient, 0f, compatibility) } } val window = remember(view) { view.context.findWindow() } DisposableEffect(immersive) { @@ -331,8 +392,8 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { DisposableEffect(Unit) { onDispose { window?.let { WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars()) } } } - Box(Modifier.fillMaxSize().onSizeChanged { rootHeightPx.intValue = it.height }.weatherGlass(effectSpec, snapshot, now, { collapse.floatValue }, effectRamp, !sheetOpen)) { - WeatherSkyBackground(snapshot, now, palette, collapse = { collapse.floatValue }) + Box(Modifier.fillMaxSize().onSizeChanged { rootHeightPx.intValue = it.height; rootWidthPx.intValue = it.width }.weatherGlass(effectSpec, snapshot, presentation.clock, { collapse.floatValue }, effectRamp, !sheetOpen)) { + WeatherSkyBackground(snapshot, presentation.clock, palette, collapse = { collapse.floatValue }) val topFadePx = if (headerBottom.floatValue > 0f) { val expandedFade = with(density) { 36.dp.toPx() } val collapsedFade = headerBottom.floatValue + with(density) { 40.dp.toPx() } @@ -340,7 +401,7 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { } else { 0f } - if (!effectSpec.isEmpty && effectRamp > 0f) { + if (effectRamp > 0f) { val effectsBottomFadePx = WindowInsets.navigationBars.getBottom(density) + with(density) { 20.dp.toPx() } WeatherEffects( spec = effectSpec, @@ -374,9 +435,15 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { ) } val scrollState = rememberScrollState() + val wide = LocalConfiguration.current.screenWidthDp >= TWO_PANE_MIN_WIDTH_DP + val fold by rememberVerticalFold() + val rootWidth = rootWidthPx.intValue + val foldSplit = fold?.takeIf { rootWidth > 0 && it.left > 0 && it.right < rootWidth } + val leftFraction = foldSplit?.let { it.left.toFloat() / rootWidth } ?: TWO_PANE_LEFT_WEIGHT + val hingeFraction = foldSplit?.let { (it.right - it.left).toFloat() / rootWidth } ?: 0f val maxCollapsePx = with(density) { COLLAPSE_RANGE.toPx() } val ambientRangePx = with(density) { AMBIENT_RANGE_DP.dp.toPx() } - val connection = remember(maxCollapsePx, ambientRangePx, compatibility) { + val connection = remember(maxCollapsePx, ambientRangePx, compatibility, wide) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (available.y != 0f) scrollTick.intValue++ @@ -386,7 +453,7 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { ambient.floatValue = next return Offset(0f, used) } - if (available.y >= 0f || collapse.floatValue >= 1f) return Offset.Zero + if (wide || available.y >= 0f || collapse.floatValue >= 1f) return Offset.Zero val next = (collapse.floatValue - available.y / maxCollapsePx).coerceIn(0f, 1f) val consumed = -(next - collapse.floatValue) * maxCollapsePx collapse.floatValue = next @@ -490,39 +557,106 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { .progressiveBlur(blurRadius = 40f, height = topFadePx, direction = BlurDirection.TOP, showGradientOverlay = false) .progressiveBlur(blurRadius = 14f, height = bottomFadePx, direction = BlurDirection.BOTTOM, showGradientOverlay = false), ) { - Column( + if (!wide) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(top = with(density) { topPx.toDp() } + 28.dp) + .navigationBarsPadding() + .padding(bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + AlertsSection(snapshot.activeAlerts().sortedByDescending { it.severity.ordinal }, palette) + HourlySection(snapshot, units.temperature, palette) + snapshot.upcomingDays().takeIf { it.isNotEmpty() } + ?.let { DailySection(it, units.temperature, palette, Modifier.padding(horizontal = SIDE_PADDING)) } + DetailsSection(snapshot, units, palette, Modifier.padding(horizontal = SIDE_PADDING)) + SunSection(snapshot, palette, Modifier.padding(horizontal = SIDE_PADDING)) + Footer( + modifier = Modifier.padding(horizontal = SIDE_PADDING).rainSurface("footer"), + snapshot = snapshot, + error = state.error, + palette = palette, + refreshing = state.loading, + onRefresh = { + HapticUtil.performVirtualKeyHaptic(view) + scope.launch { WeatherRepository.refresh(context, force = true) } + }, + onOpenSettings = onOpenSettings, + ) + } + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .navigationBarsPadding() + .padding(bottom = 40.dp), + ) { + Row(Modifier.fillMaxWidth()) { + Column( + Modifier + .weight(leftFraction) + .padding(top = with(density) { topPx.toDp() } + 28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + AlertsSection(snapshot.activeAlerts().sortedByDescending { it.severity.ordinal }, palette) + snapshot.upcomingDays().takeIf { it.isNotEmpty() } + ?.let { DailySection(it, units.temperature, palette, Modifier.padding(horizontal = SIDE_PADDING)) } + SunSection(snapshot, palette, Modifier.padding(horizontal = SIDE_PADDING)) + } + if (hingeFraction > 0f) Spacer(Modifier.weight(hingeFraction)) + Column( + Modifier + .weight((1f - leftFraction - hingeFraction).coerceAtLeast(0.05f)) + .statusBarsPadding() + .padding(top = 28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + HourlySection(snapshot, units.temperature, palette) + DetailsSection(snapshot, units, palette, Modifier.padding(horizontal = SIDE_PADDING)) + Footer( + modifier = Modifier.padding(horizontal = SIDE_PADDING).rainSurface("footer"), + snapshot = snapshot, + error = state.error, + palette = palette, + refreshing = state.loading, + onRefresh = { + HapticUtil.performVirtualKeyHaptic(view) + scope.launch { WeatherRepository.refresh(context, force = true) } + }, + onOpenSettings = onOpenSettings, + ) + } + } + } + } + } + if (timelineActive && immersive) { + Box( Modifier .fillMaxSize() - .verticalScroll(scrollState) - .padding(top = with(density) { topPx.toDp() } + 28.dp) - .navigationBarsPadding() - .padding(bottom = 40.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - AlertsSection(snapshot.activeAlerts().sortedByDescending { it.severity.ordinal }, palette) - HourlySection(snapshot, units.temperature, palette) - snapshot.daily.orEmpty().takeIf { it.isNotEmpty() } - ?.let { DailySection(it, units.temperature, palette, Modifier.padding(horizontal = SIDE_PADDING)) } - DetailsSection(snapshot, units, palette, Modifier.padding(horizontal = SIDE_PADDING)) - SunSection(snapshot, palette, Modifier.padding(horizontal = SIDE_PADDING)) - Footer( - modifier = Modifier.padding(horizontal = SIDE_PADDING).rainSurface("footer"), - snapshot = snapshot, - error = state.error, - palette = palette, - refreshing = state.loading, - onRefresh = { - HapticUtil.performVirtualKeyHaptic(view) - scope.launch { WeatherRepository.refresh(context, force = true) } - }, - onOpenSettings = onOpenSettings, - ) - } + .timelineGestures( + timeline = timeline, + scope = scope, + stepPx = (rootWidthPx.intValue / TIMELINE_STEPS_PER_SCREEN).coerceAtLeast(1f), + onVerticalDrag = { dy -> ambient.floatValue = (ambient.floatValue + dy / ambientRangePx).coerceIn(0f, 1f) }, + onVerticalEnd = { + scope.launch { settleAmbient(ambient, if (ambient.floatValue > 0.5f) 1f else 0f, compatibility) } + }, + ), + ) } + val showAmbientForecast = settings.isAmbientForecastEnabled() && !timelineActive && + ambientForecastFits(rootHeightPx.intValue, ambientClockPx.intValue, ambientForecastPx.intValue) Column( Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() + .align(if (wide) Alignment.TopStart else Alignment.TopCenter) + .then(if (wide) Modifier.fillMaxWidth(lerp(leftFraction, 1f, ambient.floatValue)) else Modifier.fillMaxWidth()) + .graphicsLayer { + if (wide && ambient.floatValue == 0f) translationY = -scrollState.value.toFloat() + } .onSizeChanged { if (ambient.floatValue == 0f) topPx = it.height.toFloat() } .statusBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, @@ -541,13 +675,38 @@ fun WeatherScreen(onOpenSettings: () -> Unit = {}) { ambient.floatValue, Modifier .padding(horizontal = SIDE_PADDING) - .ambientCenter(ambient.floatValue, (rootHeightPx.intValue - ambientInset * 2).coerceAtLeast(0)), + .ambientCenter( + ambient.floatValue, + (rootHeightPx.intValue - ambientInset * 2).coerceAtLeast(0), + rememberAnimatedAvailable( + ambientAvailable(rootHeightPx.intValue, ambientClockPx.intValue, if (showAmbientForecast || timelineActive) ambientForecastPx.intValue else 0), + ), + ), ) } if (ambient.floatValue > 0f) { - AmbientClock({ ambient.floatValue }, palette, Modifier.align(Alignment.TopCenter)) - if (settings.isAmbientForecastEnabled()) { - AmbientForecast({ ambient.floatValue }, snapshot, units.temperature, palette, Modifier.align(Alignment.BottomCenter)) + AmbientClock( + { ambient.floatValue }, + palette, + Modifier.align(Alignment.TopCenter).onSizeChanged { ambientClockPx.intValue = it.height }, + timeMillis = presentation.clockLabel.takeIf { timelineActive }, + showReturn = timelineActive && showTimelineReturn, + atNow = presentation.timelineAtNow, + onReturn = { + HapticUtil.performVirtualKeyHaptic(view) + timeline.animateTo(scope, 0f) + }, + ) + if (timelineActive) { + TimelineRain( + { ambient.floatValue }, + snapshot?.chanceOfRain, + palette, + Modifier.align(Alignment.BottomCenter).onSizeChanged { ambientForecastPx.intValue = it.height }, + ) + } + if (showAmbientForecast) { + AmbientForecast({ ambient.floatValue }, snapshot, units.temperature, palette, Modifier.align(Alignment.BottomCenter).onSizeChanged { ambientForecastPx.intValue = it.height }) } } } @@ -577,17 +736,19 @@ internal fun skyState(snapshot: WeatherSnapshot, now: Long): Pair Float, rise: () -> Float = { 1f }) { - val (t, isSun) = skyState(snapshot, now) ?: return +internal fun SkyBody(snapshot: WeatherSnapshot, now: () -> Long, collapse: () -> Float, rise: () -> Float = { 1f }) { val hide = (1f - collapse() * 1.6f).coerceIn(0f, 1f) if (hide <= 0f) return - val color = if (isSun) Color(0xFFFFE2A8) else Color(0xFFE6ECFF) Canvas( Modifier .fillMaxWidth() .height(SKY_HEIGHT) .graphicsLayer { translationY = -collapse() * 60.dp.toPx() }, ) { + val time = now() + val (t, isSun) = skyState(snapshot, time) ?: return@Canvas + val color = if (isSun) Color(0xFFFFE2A8) else Color(0xFFE6ECFF) + val moonPhase = snapshot.moonPhaseAt(time) val shown = t * rise() val horizonFade = (minOf(shown, 1f - shown) / 0.1f).coerceIn(0f, 1f) val x = size.width * (0.9f - 0.8f * shown) @@ -603,7 +764,11 @@ internal fun SkyBody(snapshot: WeatherSnapshot, now: Long, collapse: () -> Float radius = glowRadius, center = Offset(x, y), ) - drawCircle(color.copy(alpha = alpha * 0.7f), radius = (if (isSun) 20.dp else 16.dp).toPx(), center = Offset(x, y)) + if (isSun) { + drawCircle(color.copy(alpha = alpha * 0.7f), radius = 20.dp.toPx(), center = Offset(x, y)) + } else { + drawMoonPhase(Offset(x, y), 20.dp.toPx(), moonPhase, color, litAlpha = alpha * 0.8f, darkAlpha = alpha * 0.16f) + } } } @@ -640,6 +805,32 @@ private const val EFFECTS_DELAY_MS = 450L private const val EFFECTS_FADE_MS = 1800 private const val REFRESH_DELAY_MS = 600L private val COLLAPSE_RANGE = 520.dp +private const val QUARTER_HOUR_MS = 15 * 60_000L +private const val TIMELINE_STEPS_PER_SCREEN = 4f +private val TIMELINE_RETURN_SIZE = 48.dp +private const val TWO_PANE_MIN_WIDTH_DP = 600 + +@Composable +private fun rememberVerticalFold(): State { + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + return produceState(null, activity) { + if (activity == null) return@produceState + WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info -> + value = info.displayFeatures + .filterIsInstance() + .firstOrNull { it.isSeparating && it.orientation == FoldingFeature.Orientation.VERTICAL } + ?.bounds + } + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} +private const val TWO_PANE_LEFT_WEIGHT = 0.42f private const val COMBINE_AT = 0.9f private const val HEADER_EXTRAS_DP = 170f @@ -670,7 +861,9 @@ internal fun Modifier.foldAway(fraction: Float): Modifier = this .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - layout(placeable.width, (placeable.height * (1f - fraction)).roundToInt()) { placeable.placeRelative(0, 0) } + layout(placeable.width, (placeable.height * (1f - fraction)).roundToInt()) { + if (fraction < 1f / 1.4f) placeable.placeRelative(0, 0) + } } .graphicsLayer { alpha = (1f - fraction * 1.4f).coerceIn(0f, 1f) } @@ -695,6 +888,9 @@ internal fun temperatureFont(widthAxis: Int, weightAxis: Int): FontFamily = @Composable internal fun Header(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: WeatherPalette, progress: Float, ambient: Float, modifier: Modifier) { val number = WeatherFormat.temperature(snapshot.tempC, unit).removeSuffix("°") + var previousNumber by remember { mutableStateOf(number) } + val numberRising = (number.toIntOrNull() ?: 0) >= (previousNumber.toIntOrNull() ?: 0) + SideEffect { previousNumber = number } val screenHeightDp = LocalConfiguration.current.screenHeightDp val fontScale = LocalDensity.current.fontScale @@ -719,23 +915,25 @@ internal fun Header(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: W digits = { Row(verticalAlignment = Alignment.Top, modifier = Modifier.heatHaze(hazeStrength)) { DegreeSign(size, font, Color.Transparent) - Text( + RollingClock( number, - color = palette.onBase, - fontFamily = font, - fontSize = size, - lineHeight = size * 0.86f, - letterSpacing = letterSpacing.sp, - maxLines = 1, - softWrap = false, + palette.onBase, + numberRising, + TextStyle(fontFamily = font, fontSize = size, lineHeight = size * 0.86f, letterSpacing = letterSpacing.sp), ) DegreeSign(size, font, palette.onBase) } }, condition = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - WeatherIcon(snapshot.condition, snapshot.isDay, palette.accent, Modifier.size(32.dp)) - Text(snapshot.conditionText, color = palette.onBase, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + AnimatedContent( + targetState = Triple(snapshot.condition, snapshot.isDay, snapshot.conditionText), + transitionSpec = { (slideInVertically { it } + fadeIn()) togetherWith (slideOutVertically { -it } + fadeOut()) }, + label = "headerCondition", + ) { (condition, isDay, text) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WeatherIcon(condition, isDay, palette.accent, Modifier.size(32.dp)) + Text(text, color = palette.onBase, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } }, ) @@ -743,7 +941,7 @@ internal fun Header(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: W Spacer(Modifier.height(6.dp)) Text( listOf( - stringResource(R.string.weather_high_low, WeatherFormat.temperature(snapshot.highC, unit), WeatherFormat.temperature(snapshot.lowC, unit)), + stringResource(R.string.weather_high_low, WeatherFormat.temperature(snapshot.todayHighC(), unit), WeatherFormat.temperature(snapshot.todayLowC(), unit)), stringResource(R.string.weather_feels_like, WeatherFormat.temperature(snapshot.feelsLikeC, unit)), ).joinToString(" · "), color = palette.onBaseMuted, @@ -934,10 +1132,11 @@ private fun AlertCard(alert: WeatherAlert, palette: WeatherPalette, modifier: Mo @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun HourlySection(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: WeatherPalette, surfacePrefix: String = "hourly") { - if (snapshot.hourly.isEmpty()) return val context = LocalContext.current - val hours = snapshot.hourly - val carouselState = rememberCarouselState { hours.size } + val now by rememberMinuteClock() + val hours = remember(snapshot, now) { snapshot.upcomingHours(now) } + if (hours.isEmpty()) return + val carouselState = key(hours.size) { rememberCarouselState { hours.size } } Column { HorizontalMultiBrowseCarousel( state = carouselState, @@ -1024,7 +1223,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, @@ -1033,7 +1232,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))) } @@ -1078,14 +1277,35 @@ private fun DetailTile(detail: Detail, palette: WeatherPalette, modifier: Modifi @Composable private fun SunSection(snapshot: WeatherSnapshot, palette: WeatherPalette, modifier: Modifier) { - val rise = snapshot.extras?.sunriseMillis ?: return - val set = snapshot.extras?.sunsetMillis ?: return + val rise = snapshot.extras?.sunriseMillis + val set = snapshot.extras?.sunsetMillis val context = LocalContext.current + val now by rememberMinuteClock() + val phase = snapshot.moonPhaseAt(now) Column(modifier) { RoundedCardContainer(modifier = Modifier.rainSurface("sun"), spacing = 2.dp, cornerRadius = 28.dp) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) { - SunTile(R.string.weather_detail_sunrise, formatTime(context, rise), palette, Modifier.weight(1f)) - SunTile(R.string.weather_detail_sunset, formatTime(context, set), palette, Modifier.weight(1f)) + if (rise != null && set != null) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) { + SunTile(R.string.weather_detail_sunrise, formatTime(context, rise), palette, Modifier.weight(1f)) + SunTile(R.string.weather_detail_sunset, formatTime(context, set), palette, Modifier.weight(1f)) + } + } + MoonTile(phase, palette, Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun MoonTile(phase: Double, palette: WeatherPalette, modifier: Modifier) { + Surface(color = palette.card, shape = MaterialTheme.shapes.extraSmall, modifier = modifier) { + Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Canvas(Modifier.size(40.dp)) { + drawMoonPhase(center, size.minDimension / 2f, phase, palette.onBase, litAlpha = 0.95f, darkAlpha = 0.18f) + } + Column { + Text(stringResource(R.string.weather_detail_moon), color = palette.onBaseMuted, style = MaterialTheme.typography.labelMedium) + Text(stringResource(MoonPhase.nameRes(phase)), color = palette.onBase, style = MaterialTheme.typography.headlineSmall) + Text("${MoonPhase.illuminationPercent(phase)}%", color = palette.onBaseMuted, style = MaterialTheme.typography.bodyMedium) } } } @@ -1147,7 +1367,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, @@ -1260,11 +1480,23 @@ private tailrec fun Context.findWindow(): Window? = when (this) { private const val AMBIENT_RANGE_DP = 260 private const val AMBIENT_SETTLE_VELOCITY = 1200f -internal fun Modifier.ambientCenter(fraction: Float, fullHeightPx: Int): Modifier = layout { measurable, constraints -> +internal fun ambientAvailable(rootPx: Int, clockPx: Int, forecastPx: Int): Int = + if (rootPx <= 0 || clockPx <= 0) 0 else (rootPx - 2 * maxOf(clockPx, forecastPx)).coerceAtLeast(1) + +internal fun ambientForecastFits(rootPx: Int, clockPx: Int, forecastPx: Int): Boolean = + rootPx <= 0 || ambientAvailable(rootPx, clockPx, forecastPx) >= rootPx / 3 + +internal fun Modifier.ambientCenter(fraction: Float, fullHeightPx: Int, availablePx: () -> Int = { 0 }): Modifier = layout { measurable, constraints -> val placeable = measurable.measure(constraints) val height = lerp(placeable.height.toFloat(), maxOf(fullHeightPx, placeable.height).toFloat(), fraction).roundToInt() + val available = availablePx() + val fit = if (available > 0 && placeable.height > available) available.toFloat() / placeable.height else 1f + val scale = lerp(1f, fit, fraction) layout(placeable.width, height) { - placeable.placeRelative(0, ((height - placeable.height) / 2f * fraction).roundToInt()) + placeable.placeRelativeWithLayer(0, ((height - placeable.height) / 2f * fraction).roundToInt()) { + scaleX = scale + scaleY = scale + } } } @@ -1279,7 +1511,15 @@ private suspend fun settleAmbient(state: MutableFloatState, target: Float, insta } @Composable -internal fun AmbientClock(ambient: () -> Float, palette: WeatherPalette, modifier: Modifier) { +internal fun AmbientClock( + ambient: () -> Float, + palette: WeatherPalette, + modifier: Modifier, + timeMillis: Long? = null, + showReturn: Boolean = false, + atNow: Boolean = true, + onReturn: () -> Unit = {}, +) { val context = LocalContext.current var now by remember { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(Unit) { @@ -1288,14 +1528,15 @@ internal fun AmbientClock(ambient: () -> Float, palette: WeatherPalette, modifie delay(60_000L - now % 60_000L) } } - val pattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "hh:mm" - val text = remember(now, pattern) { SimpleDateFormat(pattern, Locale.getDefault()).format(Date(now)) } - Text( - text = text, - color = palette.onBase, - fontFamily = temperatureFont(70, 200), - fontSize = 40.sp, - maxLines = 1, + val is24Hour = DateFormat.is24HourFormat(context) + val pattern = if (is24Hour) "HH:mm" else "h:mm" + val shown = timeMillis ?: now + val text = remember(shown, pattern) { SimpleDateFormat(pattern, Locale.getDefault()).format(Date(shown)) } + var previous by remember { mutableLongStateOf(shown) } + val forward = shown >= previous + SideEffect { previous = shown } + val clockStyle = TextStyle(fontFamily = temperatureFont(70, 200), fontSize = 40.sp) + Column( modifier = modifier .windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility) .padding(top = AMBIENT_EDGE_GAP) @@ -1304,5 +1545,80 @@ internal fun AmbientClock(ambient: () -> Float, palette: WeatherPalette, modifie alpha = reveal translationY = (1f - reveal) * 16.dp.toPx() }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (timeMillis != null) { + val dayLabel = if (atNow) context.getString(R.string.timeline_now) else timelineDayLabel(context, shown, now) + CounterText(dayLabel, palette.onBaseMuted, MaterialTheme.typography.titleMedium, forward = forward) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (timeMillis != null) { + Box(Modifier.size(TIMELINE_RETURN_SIZE), contentAlignment = Alignment.Center) { + val reveal by animateFloatAsState(if (showReturn) 1f else 0f, spring(stiffness = Spring.StiffnessMediumLow), label = "timelineReturn") + IconButton( + onClick = onReturn, + enabled = showReturn, + modifier = Modifier.graphicsLayer { + alpha = reveal + scaleX = 0.6f + 0.4f * reveal + scaleY = 0.6f + 0.4f * reveal + }, + ) { + Icon(painterResource(R.drawable.rounded_history_24), stringResource(R.string.timeline_back_to_now), tint = palette.onBase) + } + } + } + RollingClock(text, palette.onBase, forward, clockStyle) + if (timeMillis != null) { + Box(Modifier.width(TIMELINE_RETURN_SIZE).align(Alignment.Bottom).padding(start = 4.dp, bottom = 8.dp)) { + if (!is24Hour) { + val meridiem = remember(shown) { SimpleDateFormat("a", Locale.getDefault()).format(Date(shown)) } + CounterText(meridiem, palette.onBase, TextStyle(fontFamily = temperatureFont(70, 200), fontSize = 16.sp), forward = forward) + } + } + } + } + } +} + +private fun timelineDayLabel(context: Context, shown: Long, now: Long): String { + val zone = java.time.ZoneId.systemDefault() + val days = java.time.temporal.ChronoUnit.DAYS.between( + java.time.Instant.ofEpochMilli(now).atZone(zone).toLocalDate(), + java.time.Instant.ofEpochMilli(shown).atZone(zone).toLocalDate(), ) + return when (days) { + 0L -> context.getString(R.string.weather_detail_today) + 1L -> context.getString(R.string.timeline_tomorrow) + else -> SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(shown)) + } +} + +@Composable +internal fun TimelineRain(ambient: () -> Float, chance: Int?, palette: WeatherPalette, modifier: Modifier = Modifier) { + var last by remember { mutableIntStateOf(chance ?: 0) } + val shown = chance ?: last + val forward = shown >= last + SideEffect { last = shown } + Row( + modifier + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) + .padding(bottom = AMBIENT_EDGE_GAP) + .graphicsLayer { + val reveal = ((ambient() - 0.4f) / 0.6f).coerceIn(0f, 1f) + alpha = if (chance == null) 0f else reveal + translationY = (1f - reveal) * 16.dp.toPx() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(painterResource(R.drawable.rounded_rainy_24), null, tint = palette.accent, modifier = Modifier.size(28.dp)) + RollingClock("$shown%", palette.onBase, forward, MaterialTheme.typography.headlineMedium) + } +} + +@Composable +internal fun providerLabel(providerId: String): String { + val provider = WeatherProviders.byId(providerId) + return provider.attributionRes?.let { stringResource(it) } ?: provider.displayName } diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt index fc82404..e8af308 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt @@ -52,8 +52,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import com.sameerasw.island.sdk.HostGrantStore -import com.sameerasw.island.sdk.IslandIdentity import com.sameerasw.overcast.R import com.sameerasw.overcast.data.repository.SettingsRepository import com.sameerasw.overcast.ui.components.menus.SegmentedDropdownMenuItem @@ -73,6 +71,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 @@ -93,14 +92,12 @@ fun WeatherSettingsUI( var compatibility by remember { mutableStateOf(settings.isCompatibilityMode()) } var ambientForecast by remember { mutableStateOf(settings.isAmbientForecastEnabled()) } + var ambientTimeline by remember { mutableStateOf(settings.isAmbientTimelineEnabled()) } + var screensaverAutoAnimate by remember { mutableStateOf(settings.isScreensaverAutoAnimateEnabled()) } var unitsExpanded by remember { mutableStateOf(false) } var showCompatibilityInfo by remember { mutableStateOf(false) } var effects by remember { mutableStateOf(settings.isWeatherEffectsEnabled()) } var weatherHaptics by remember { mutableStateOf(settings.isWeatherHapticsEnabled()) } - var islandMode by remember { mutableStateOf(settings.getIslandMode()) } - var islandPeekAlerts by remember { mutableStateOf(settings.isIslandPeekAlertsEnabled()) } - val hostGrants = remember { HostGrantStore(context) } - var grantedHosts by remember { mutableStateOf(hostGrants.granted().keys.toList()) } var refreshMinutes by remember { mutableIntStateOf(settings.getWeatherRefreshMinutes()) } var savedKey by remember { mutableStateOf(settings.getWeatherApiKey(providerId).orEmpty()) } var keyInput by remember { mutableStateOf(savedKey) } @@ -130,6 +127,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 @@ -245,6 +243,7 @@ fun WeatherSettingsUI( onClick = { refreshMinutes = minutes settings.setWeatherRefreshMinutes(minutes) + WeatherScheduler.schedule(context, minutes) }, ) } @@ -391,53 +390,6 @@ fun WeatherSettingsUI( ) } - SectionTitle(R.string.weather_section_island) - RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { - val modes = listOf( - SettingsRepository.ISLAND_MODE_BRIEF to R.string.island_mode_brief, - SettingsRepository.ISLAND_MODE_COMPACT to R.string.island_mode_compact, - SettingsRepository.ISLAND_MODE_ALERTS to R.string.island_mode_alerts, - ) - ConfigPickerItem( - title = stringResource(R.string.island_mode_title), - selectedValue = stringResource(modes.first { it.first == islandMode }.second), - iconRes = R.drawable.rounded_dashboard_24, - modifier = Modifier.fillMaxWidth(), - ) { - modes.forEach { (id, label) -> - SegmentedDropdownMenuItem( - text = { Text(stringResource(label)) }, - onClick = { - islandMode = id - settings.setIslandMode(id) - }, - ) - } - } - grantedHosts.forEach { host -> - IconToggleItem( - iconRes = R.drawable.rounded_dashboard_24, - title = IslandIdentity.label(context, host), - isChecked = true, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - hostGrants.revoke(host) - grantedHosts = hostGrants.granted().keys.toList() - }, - ) - } - IconToggleItem( - iconRes = R.drawable.rounded_warning_24, - title = stringResource(R.string.island_peek_alerts_title), - isChecked = islandPeekAlerts, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - islandPeekAlerts = it - settings.setIslandPeekAlertsEnabled(it) - }, - ) - } - SectionTitle(R.string.weather_section_screensaver) RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { IconToggleItem( @@ -450,6 +402,26 @@ fun WeatherSettingsUI( settings.setAmbientForecastEnabled(it) }, ) + IconToggleItem( + iconRes = R.drawable.rounded_schedule_24, + title = stringResource(R.string.timeline_mode), + isChecked = ambientTimeline, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + ambientTimeline = it + settings.setAmbientTimelineEnabled(it) + }, + ) + IconToggleItem( + iconRes = R.drawable.rounded_schedule_24, + title = stringResource(R.string.screensaver_auto_animate), + isChecked = screensaverAutoAnimate, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + screensaverAutoAnimate = it + settings.setScreensaverAutoAnimateEnabled(it) + }, + ) } } diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherTimeline.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherTimeline.kt new file mode 100644 index 0000000..bcda5f0 --- /dev/null +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherTimeline.kt @@ -0,0 +1,254 @@ +package com.sameerasw.overcast.ui.features.weather + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.sameerasw.overcast.R +import com.sameerasw.overcast.weather.model.WeatherCondition +import com.sameerasw.overcast.weather.model.WeatherSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.roundToInt + +internal class TimelinePoint( + val timeMillis: Long, + val tempC: Double, + val condition: WeatherCondition, + val isDay: Boolean, + val chanceOfRain: Int?, + val conditionText: String?, +) + +internal fun timelinePoints(snapshot: WeatherSnapshot, now: Long, loop: Boolean = false): List { + val current = TimelinePoint(now, snapshot.tempC, snapshot.condition, snapshot.isDay, snapshot.chanceOfRain, snapshot.conditionText) + val later = snapshot.upcomingHours(now).filter { it.timeMillis > now } + .map { TimelinePoint(it.timeMillis, it.tempC, it.condition, it.isDay, it.chanceOfRain, null) } + if (!loop || later.isEmpty()) return listOf(current) + later + val days = ((later.last().timeMillis - now) / DAY_MS + 1).coerceAtLeast(1) + return listOf(current) + later + TimelinePoint(now + days * DAY_MS, snapshot.tempC, snapshot.condition, snapshot.isDay, snapshot.chanceOfRain, snapshot.conditionText) +} + +private const val DAY_MS = 24 * 60 * 60_000L + +@Stable +internal class WeatherTimeline { + var value by mutableFloatStateOf(0f) + private set + var points by mutableStateOf(emptyList()) + var loop by mutableStateOf(false) + + private var job: Job? = null + + val maxPosition: Float get() = (points.size - 1).coerceAtLeast(0).toFloat() + + fun set(position: Float) { + value = position.coerceIn(0f, maxPosition) + } + + fun dragBy(delta: Float) = set(value + delta) + + fun cancelAnimation() { + job?.cancel() + } + + fun animateTo(scope: CoroutineScope, target: Float, velocity: Float = 0f) { + job?.cancel() + job = scope.launch { + animate( + initialValue = value, + targetValue = target.coerceIn(0f, maxPosition), + initialVelocity = velocity, + animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessLow), + ) { v, _ -> set(v) } + } + } + + fun settle(scope: CoroutineScope, velocityPxPerSec: Float, stepPx: Float) { + val stepsPerSec = -velocityPxPerSec / stepPx + animateTo(scope, (value + stepsPerSec * 0.18f).roundToInt().toFloat(), stepsPerSec) + } + + fun timeAt(position: Float): Long = interpolate(position) { a, b, f -> lerp(a.timeMillis, b.timeMillis, f) } ?: 0L + + fun tempAt(position: Float): Double = interpolate(position) { a, b, f -> a.tempC + (b.tempC - a.tempC) * f } ?: 0.0 + + fun pointAt(position: Float): TimelinePoint? = points.getOrNull(position.roundToInt().coerceIn(0, (points.size - 1).coerceAtLeast(0))) + + private fun interpolate(position: Float, mix: (TimelinePoint, TimelinePoint, Float) -> T): T? { + if (points.isEmpty()) return null + val clamped = position.coerceIn(0f, maxPosition) + val low = floor(clamped).toInt() + val high = (low + 1).coerceAtMost(points.size - 1) + return mix(points[low], points[high], clamped - low) + } +} + +internal fun Modifier.timelineGestures( + timeline: WeatherTimeline, + scope: CoroutineScope, + stepPx: Float, + onVerticalDrag: (Float) -> Unit, + onVerticalEnd: () -> Unit, +): Modifier = pointerInput(timeline, stepPx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + timeline.cancelAnimation() + val tracker = VelocityTracker() + tracker.addPosition(down.uptimeMillis, down.position) + var horizontal = true + val first = awaitTouchSlopOrCancellation(down.id) { change, overSlop -> + horizontal = abs(overSlop.x) >= abs(overSlop.y) + change.consume() + } ?: return@awaitEachGesture + tracker.addPosition(first.uptimeMillis, first.position) + val firstDelta = first.positionChange() + if (horizontal) timeline.dragBy(-firstDelta.x / stepPx) else onVerticalDrag(firstDelta.y) + drag(first.id) { change -> + tracker.addPosition(change.uptimeMillis, change.position) + val delta = change.positionChange() + if (horizontal) timeline.dragBy(-delta.x / stepPx) else onVerticalDrag(delta.y) + change.consume() + } + if (horizontal) timeline.settle(scope, tracker.calculateVelocity().x, stepPx) else onVerticalEnd() + } +} + +@Composable +internal fun RollingClock( + text: String, + color: Color, + forward: Boolean?, + style: androidx.compose.ui.text.TextStyle, + modifier: Modifier = Modifier, +) { + val tracker = remember { DirectionTracker() } + val tracked = tracker.update(text) + val rising = forward ?: tracked + Row(modifier.clipToBounds()) { + text.forEachIndexed { index, char -> + key(text.length - index) { + AnimatedContent( + targetState = char, + transitionSpec = { + val slide = spring(dampingRatio = 0.8f, stiffness = 500f) + val sign = if (rising) 1 else -1 + (slideInVertically(slide) { sign * it } + fadeIn()) + .togetherWith(slideOutVertically(slide) { -sign * it } + fadeOut()) + .using(SizeTransform(clip = true)) + }, + label = "timelineDigit", + ) { digit -> + Text(digit.toString(), color = color, style = style, maxLines = 1, softWrap = false) + } + } + } + } +} + +@Composable +internal fun CounterText( + text: String, + color: Color, + style: androidx.compose.ui.text.TextStyle, + modifier: Modifier = Modifier, + forward: Boolean? = null, +) { + val tracker = remember { DirectionTracker() } + val tracked = tracker.update(text) + val rising = forward ?: tracked + AnimatedContent( + targetState = text, + modifier = modifier.clipToBounds(), + transitionSpec = { + val slide = spring(dampingRatio = 0.8f, stiffness = 500f) + val sign = if (rising) 1 else -1 + (slideInVertically(slide) { sign * it } + fadeIn()) + .togetherWith(slideOutVertically(slide) { -sign * it } + fadeOut()) + .using(SizeTransform(clip = true)) + }, + label = "counterText", + ) { value -> + Text(value, color = color, style = style, maxLines = 1, softWrap = false) + } +} + +private class DirectionTracker { + private var last: String? = null + private var rising = true + + fun update(text: String): Boolean { + val previous = last + if (previous != null && previous != text) { + val old = previous.filter(Char::isDigit).toLongOrNull() + val new = text.filter(Char::isDigit).toLongOrNull() + if (old != null && new != null && old != new) rising = new > old + } + last = text + return rising + } +} + +@Composable +internal fun rememberAnimatedAvailable(target: Int): () -> Int { + val animated = remember { Animatable(0f) } + LaunchedEffect(target) { + if (target <= 0) return@LaunchedEffect + if (animated.value <= 0f) animated.snapTo(target.toFloat()) else animated.animateTo(target.toFloat(), spring(stiffness = Spring.StiffnessMediumLow)) + } + return { animated.value.roundToInt() } +} + +@StringRes +internal fun conditionLabelRes(condition: WeatherCondition): Int = when (condition) { + WeatherCondition.CLEAR -> R.string.condition_clear + WeatherCondition.PARTLY_CLOUDY -> R.string.condition_partly_cloudy + WeatherCondition.CLOUDY -> R.string.condition_cloudy + WeatherCondition.FOG -> R.string.condition_fog + WeatherCondition.DRIZZLE -> R.string.condition_drizzle + WeatherCondition.RAIN -> R.string.condition_rain + WeatherCondition.HEAVY_RAIN -> R.string.condition_heavy_rain + WeatherCondition.SLEET -> R.string.condition_sleet + WeatherCondition.SNOW -> R.string.condition_snow + WeatherCondition.HAIL -> R.string.condition_hail + WeatherCondition.THUNDERSTORM -> R.string.condition_thunderstorm + WeatherCondition.UNKNOWN -> R.string.condition_unknown +} + +internal fun conditionLabel(context: Context, condition: WeatherCondition): String = context.getString(conditionLabelRes(condition)) diff --git a/app/src/main/java/com/sameerasw/overcast/ui/modifiers/ProgressiveBlurModifier.kt b/app/src/main/java/com/sameerasw/overcast/ui/modifiers/ProgressiveBlurModifier.kt index 532437c..357c7fe 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/modifiers/ProgressiveBlurModifier.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/modifiers/ProgressiveBlurModifier.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.sameerasw.overcast.utils.CompatibilityMode import com.sameerasw.overcast.utils.DeviceUtils +import com.sameerasw.overcast.weather.effects.EffectQuality enum class BlurDirection { TOP, @@ -52,7 +53,7 @@ fun Modifier.progressiveBlur( val blurRadiusDp = with(density) { blurRadius.toDp() } val blurModifier = - if (blurRadius > 0f && !isPowerSave && !CompatibilityMode.enabled.value && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (blurRadius > 0f && !isPowerSave && !CompatibilityMode.enabled.value && EffectQuality.blurEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Modifier.blur { this.edgeTreatment = edgeTreatment val sizeHeightPx = size.height.toPx() @@ -139,7 +140,7 @@ fun Modifier.progressiveBlur( val isPowerSave = remember(context) { DeviceUtils.isPowerSaveMode(context) } val blurModifier = - if (maxRadius > 0.dp && !isPowerSave && !CompatibilityMode.enabled.value && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (maxRadius > 0.dp && !isPowerSave && !CompatibilityMode.enabled.value && EffectQuality.blurEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Modifier.blur { this.edgeTreatment = edgeTreatment radius = diff --git a/app/src/main/java/com/sameerasw/overcast/ui/modifiers/RainOnGlassModifier.kt b/app/src/main/java/com/sameerasw/overcast/ui/modifiers/RainOnGlassModifier.kt index d18cffa..7ecd4de 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/modifiers/RainOnGlassModifier.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/modifiers/RainOnGlassModifier.kt @@ -4,8 +4,13 @@ import android.graphics.RenderEffect import android.graphics.RuntimeShader import android.os.Build import com.sameerasw.overcast.utils.CompatibilityMode +import com.sameerasw.overcast.weather.effects.EffectQuality import androidx.annotation.RequiresApi +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -18,14 +23,19 @@ import androidx.compose.ui.platform.LocalDensity @Composable fun Modifier.rainOnGlass(intensity: Float, slant: Float, light: () -> Offset): Modifier { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || CompatibilityMode.enabled.value || intensity <= 0f) return this - return RainOnGlassApi33.apply(this, intensity.coerceIn(0f, 1f), slant, LocalDensity.current.density, light) + val level = animateFloatAsState(intensity.coerceIn(0f, 1f), tween(GLASS_FADE_MS), label = "glassIntensity") + val lean = animateFloatAsState(slant, tween(GLASS_FADE_MS), label = "glassSlant") + val visible by remember { derivedStateOf { level.value > 0.002f } } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || CompatibilityMode.enabled.value || !EffectQuality.glassEnabled || !visible) return this + return RainOnGlassApi33.apply(this, { level.value }, { lean.value }, LocalDensity.current.density, light) } +private const val GLASS_FADE_MS = 1400 + @RequiresApi(Build.VERSION_CODES.TIRAMISU) private object RainOnGlassApi33 { @Composable - fun apply(modifier: Modifier, intensity: Float, slant: Float, density: Float, light: () -> Offset): Modifier { + fun apply(modifier: Modifier, intensity: () -> Float, slant: () -> Float, density: Float, light: () -> Offset): Modifier { val shader = remember { runCatching { RuntimeShader(SOURCE) }.getOrNull() } ?: return modifier val time = remember { mutableFloatStateOf(0f) } LaunchedEffect(Unit) { @@ -37,8 +47,8 @@ private object RainOnGlassApi33 { return modifier.graphicsLayer { shader.setFloatUniform("resolution", size.width, size.height) shader.setFloatUniform("time", time.floatValue) - shader.setFloatUniform("intensity", intensity) - shader.setFloatUniform("slant", slant) + shader.setFloatUniform("intensity", intensity()) + shader.setFloatUniform("slant", slant()) shader.setFloatUniform("density", density) val l = light() shader.setFloatUniform("lightPos", l.x * size.width, l.y) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/MoonPhase.kt b/app/src/main/java/com/sameerasw/overcast/weather/MoonPhase.kt new file mode 100644 index 0000000..7cb17c5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/overcast/weather/MoonPhase.kt @@ -0,0 +1,48 @@ +package com.sameerasw.overcast.weather + +import androidx.annotation.StringRes +import com.sameerasw.overcast.R +import com.sameerasw.overcast.weather.model.WeatherSnapshot +import kotlin.math.PI +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.roundToInt + +object MoonPhase { + private const val SYNODIC_DAYS = 29.530588853 + private const val SYNODIC_MS = SYNODIC_DAYS * 24 * 60 * 60_000.0 + private const val REFERENCE_NEW_MOON_MS = 947_182_440_000.0 + + fun compute(nowMillis: Long): Double = wrap((nowMillis - REFERENCE_NEW_MOON_MS) / SYNODIC_MS) + + fun fromAgeDays(age: Double): Double = wrap(age / SYNODIC_DAYS) + + fun fromIllumination(percent: Double, waxing: Boolean): Double { + val half = acos((1.0 - 2.0 * (percent / 100.0)).coerceIn(-1.0, 1.0)) / (2 * PI) + return if (waxing) half else wrap(1.0 - half) + } + + fun illumination(phase: Double): Double = (1.0 - cos(2 * PI * phase)) / 2.0 + + fun illuminationPercent(phase: Double): Int = (illumination(phase) * 100).roundToInt() + + @StringRes + fun nameRes(phase: Double): Int = when (((wrap(phase) * 8) + 0.5).toInt() % 8) { + 0 -> R.string.moon_new + 1 -> R.string.moon_waxing_crescent + 2 -> R.string.moon_first_quarter + 3 -> R.string.moon_waxing_gibbous + 4 -> R.string.moon_full + 5 -> R.string.moon_waning_gibbous + 6 -> R.string.moon_last_quarter + else -> R.string.moon_waning_crescent + } + + fun wrap(value: Double): Double = ((value % 1.0) + 1.0) % 1.0 +} + +fun WeatherSnapshot.moonPhaseAt(now: Long = System.currentTimeMillis()): Double { + val reported = extras?.moonPhase ?: return MoonPhase.compute(now) + val elapsedCycles = (now - updatedAt) / (29.530588853 * 24 * 60 * 60_000.0) + return MoonPhase.wrap(reported + elapsedCycles) +} diff --git a/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt b/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt index 5174042..9cc2a3e 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt @@ -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 @@ -45,6 +46,7 @@ object WeatherRepository { val lastDeviceLocation: WeatherLocation? = null, val lastDeviceLocationAt: Long? = null, val notifiedAlertIds: Set? = null, + val lastFetchKey: String? = null, ) fun config(context: Context): WeatherConfig = SettingsWeatherConfig(SettingsRepository(context.applicationContext)) @@ -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 } @@ -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) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/effects/WeatherEffects.kt b/app/src/main/java/com/sameerasw/overcast/weather/effects/WeatherEffects.kt index 2c70b06..5d04028 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/effects/WeatherEffects.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/effects/WeatherEffects.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -49,9 +51,22 @@ interface WeatherEffectHaptics { fun onStrike(intensity: Float) } -// Each particle is a pure function of its seed values and elapsed time, so there's no per-frame state to update. +object EffectQuality { + var level by mutableIntStateOf(2) + + val glassEnabled: Boolean get() = level >= 1 + val blurEnabled: Boolean get() = level >= 1 + + internal val particleScale: Float get() = when (level) { + 0 -> 0.35f + 1 -> 0.65f + else -> 1f + } +} + private class ParticleField(val x: FloatArray, val phase: FloatArray, val speed: FloatArray, val size: FloatArray) { - val count: Int get() = x.size + val capacity: Int get() = x.size + var count: Int = x.size companion object { fun create(count: Int, seed: Int): ParticleField { @@ -62,7 +77,94 @@ private class ParticleField(val x: FloatArray, val phase: FloatArray, val speed: } } -private class LayerState(val layer: WeatherEffectLayer, val field: ParticleField) +private class LayerState(var layer: WeatherEffectLayer, val field: ParticleField) { + var level = 1f + var targetLevel = 1f + var timeOffset = 0f + var intensity = layer.intensity + var slant = (layer as? WeatherEffectLayer.Rain)?.slant ?: 0f + val dropPath = Path() + var brushes: Array? = null + var brushWidth = 0f + var glow: Brush? = null + var glowWidth = 0f + + fun current(): WeatherEffectLayer = when (layer) { + is WeatherEffectLayer.Rain -> WeatherEffectLayer.Rain(intensity, slant) + is WeatherEffectLayer.Snow -> WeatherEffectLayer.Snow(intensity) + is WeatherEffectLayer.Hail -> WeatherEffectLayer.Hail(intensity) + is WeatherEffectLayer.Clouds -> WeatherEffectLayer.Clouds(intensity) + is WeatherEffectLayer.Fog -> WeatherEffectLayer.Fog(intensity) + is WeatherEffectLayer.SunGlow -> WeatherEffectLayer.SunGlow(intensity) + is WeatherEffectLayer.Stars -> WeatherEffectLayer.Stars(intensity) + is WeatherEffectLayer.Lightning -> WeatherEffectLayer.Lightning(intensity) + } + + val falling: Boolean get() = layer is WeatherEffectLayer.Rain || layer is WeatherEffectLayer.Snow || layer is WeatherEffectLayer.Hail +} + +private fun seedFor(layer: WeatherEffectLayer): Int = when (layer) { + is WeatherEffectLayer.Rain -> 17 + is WeatherEffectLayer.Snow -> 7936 + is WeatherEffectLayer.Hail -> 15855 + is WeatherEffectLayer.Clouds -> 23774 + is WeatherEffectLayer.Fog -> 31693 + is WeatherEffectLayer.SunGlow -> 39612 + is WeatherEffectLayer.Stars -> 47531 + is WeatherEffectLayer.Lightning -> 55450 +} + +private fun reconcile(states: MutableList, spec: WeatherEffectSpec, animateIn: Boolean, now: Float) { + states.forEach { it.targetLevel = 0f } + spec.layers.forEach { layer -> + val existing = states.firstOrNull { it.layer::class == layer::class } + if (existing != null) { + if (layer is WeatherEffectLayer.Rain) { + val ratio = rainSpeedFactor(layer.intensity) / rainSpeedFactor(existing.layer.intensity) + existing.timeOffset = (now + existing.timeOffset) / ratio - now + } + existing.layer = layer + existing.targetLevel = 1f + } else { + val full = LayerState(layer, ParticleField.create(particleCount(layer.withIntensity(1f)), seedFor(layer))) + if (animateIn) { + full.level = 0f + } + states.add(full) + } + } +} + +private fun rainSpeedFactor(intensity: Float): Float = 0.8f + 0.4f * intensity + +private fun WeatherEffectLayer.withIntensity(value: Float): WeatherEffectLayer = when (this) { + is WeatherEffectLayer.Rain -> copy(intensity = value) + is WeatherEffectLayer.Snow -> copy(intensity = value) + is WeatherEffectLayer.Hail -> copy(intensity = value) + is WeatherEffectLayer.Clouds -> copy(intensity = value) + is WeatherEffectLayer.Fog -> copy(intensity = value) + is WeatherEffectLayer.SunGlow -> copy(intensity = value) + is WeatherEffectLayer.Stars -> copy(intensity = value) + is WeatherEffectLayer.Lightning -> copy(intensity = value) +} + +private fun advance(states: MutableList, dt: Float) { + val fade = dt / FADE_S + val ease = (dt * INTENSITY_EASE).coerceAtMost(1f) + val scale = EffectQuality.particleScale + states.forEach { state -> + state.level = if (state.level < state.targetLevel) minOf(state.level + fade, state.targetLevel) else maxOf(state.level - fade, state.targetLevel) + if (state.targetLevel > 0f) { + state.intensity += (state.layer.intensity - state.intensity) * ease + val rain = state.layer as? WeatherEffectLayer.Rain + if (rain != null) state.slant += (rain.slant - state.slant) * ease + } + val wanted = particleCount(state.current()) + val scaled = if (state.layer is WeatherEffectLayer.Clouds || state.layer is WeatherEffectLayer.Fog) wanted else (wanted * scale).toInt().coerceAtLeast(4) + state.field.count = scaled.coerceIn(0, state.field.capacity) + } + states.removeAll { it.level <= 0f && it.targetLevel <= 0f } +} @Composable fun WeatherEffects( @@ -75,9 +177,8 @@ fun WeatherEffects( cover: () -> List = surfaces, scrollTick: () -> Int = { 0 }, ) { - if (spec.isEmpty) return if (CompatibilityMode.enabled.value) { - StillWeatherEffects(spec, modifier, strength, clearTop, cover, scrollTick) + if (!spec.isEmpty) StillWeatherEffects(spec, modifier, strength, clearTop, cover, scrollTick) return } val density = LocalDensity.current @@ -87,26 +188,53 @@ fun WeatherEffects( val currentSurfaces by rememberUpdatedState(surfaces) val currentCover by rememberUpdatedState(cover) val currentScrollTick by rememberUpdatedState(scrollTick) - val snowState = remember(spec) { SnowState() } + val snowState = remember { SnowState() } + val states = remember { mutableListOf() } val coverPath = remember { Path() } - val layers = remember(spec) { - spec.layers.mapIndexed { index, layer -> - val field = ParticleField.create(particleCount(layer), seed = index * 7919 + 17) - LayerState(layer, field) - } + var layerTick by remember { mutableIntStateOf(0) } + val first = remember { booleanArrayOf(true) } + val clock = remember { floatArrayOf(0f) } + remember(spec) { + reconcile(states, spec, animateIn = !first[0], now = clock[0]) + first[0] = false } + layerTick + if (states.isEmpty()) return - LaunchedEffect(layers) { + LaunchedEffect(Unit) { val start = withFrameNanos { it } + var previousFrame = start var previous = 0f + var windowStart = start + var windowFrames = 0 + var slowWindows = 0 while (true) { - val now = withFrameNanos { (it - start) / 1_000_000_000f } + val frame = withFrameNanos { it } + val now = (frame - start) / 1_000_000_000f + val dt = ((frame - previousFrame) / 1_000_000_000f).coerceIn(0f, 0.1f) + previousFrame = frame + windowFrames++ + if (frame - windowStart >= QUALITY_WINDOW_NS) { + val averageMs = (frame - windowStart) / 1_000_000f / windowFrames + slowWindows = if (averageMs > SLOW_FRAME_MS && now > QUALITY_GRACE_S) slowWindows + 1 else 0 + if (slowWindows >= SLOW_WINDOWS_TO_DOWNGRADE && EffectQuality.level > 0) { + EffectQuality.level-- + slowWindows = 0 + } + windowStart = frame + windowFrames = 0 + } + val before = states.size + advance(states, dt) + if (states.size != before) layerTick++ time.floatValue = now + clock[0] = now val h = canvasSize.height.toFloat() currentHaptics?.let { sink -> - if (h > 0f && now > previous) emitHaptics(sink, layers, previous, now, h, density) + if (h > 0f && now > previous) emitHaptics(sink, states, previous, now, h, density) } - val all = currentSurfaces() + val needsSurfaces = states.any { it.falling } + val all = if (needsSurfaces) currentSurfaces() else emptyList() val tick = currentScrollTick() if (tick != snowState.tick) { snowState.tick = tick @@ -115,10 +243,11 @@ fun WeatherEffects( } snowState.settled = now - snowState.lastMove >= SURFACE_SETTLE_S snowState.falling.removeAll { now - it.t0 > FALL_S } - val snow = layers.firstOrNull { it.layer is WeatherEffectLayer.Snow } + val snow = states.firstOrNull { it.layer is WeatherEffectLayer.Snow && it.targetLevel > 0f } if (snow != null && snowState.settled && now > previous && canvasSize.width > 0) { settleSnow(snowState.piles, all, snow.field, previous, now, canvasSize.width.toFloat(), h, density.density) } + if (snow == null && snowState.piles.isNotEmpty() && states.none { it.layer is WeatherEffectLayer.Snow }) snowState.piles.clear() previous = now } } @@ -126,34 +255,52 @@ fun WeatherEffects( Canvas( modifier .onSizeChanged { canvasSize = it } - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .graphicsLayer { if (clearTop > 0.dp) compositingStrategy = CompositingStrategy.Offscreen } .clearTopMask(clearTop), ) { val t = time.floatValue - // Falling particles are hidden behind UI cards; ambient layers (clouds, fog, glow, stars) stay visible through the translucent cards. + val needsSurfaces = states.any { it.falling } + val all = if (needsSurfaces) currentSurfaces() else emptyList() + val settledSurfaces = if (snowState.settled) all else emptyList() + val cards = if (!needsSurfaces) emptyList() else if (cover === surfaces) all else currentCover() coverPath.rewind() - currentCover().forEach { surface -> + cards.forEach { surface -> val r = min(surface.cornerRadius, min(surface.rect.width, surface.rect.height) / 2f) coverPath.addRoundRect(RoundRect(surface.rect, CornerRadius(r, r))) } - layers.forEach { state -> - when (val layer = state.layer) { - is WeatherEffectLayer.Rain -> clipPath(coverPath, ClipOp.Difference) { - drawRain(state, layer, t, strength, if (snowState.settled) surfaces() else emptyList()) - } - is WeatherEffectLayer.Snow -> clipPath(coverPath, ClipOp.Difference) { - drawSnow(state.field, t, layer.intensity, strength, if (snowState.settled) surfaces() else emptyList(), snowState) - } - is WeatherEffectLayer.Hail -> clipPath(coverPath, ClipOp.Difference) { - drawHail(state, layer.intensity, t, strength) + states.forEach { state -> + if (state.falling || state.layer is WeatherEffectLayer.Lightning) return@forEach + val layer = state.current() + val fade = strength * state.level + when (layer) { + is WeatherEffectLayer.Clouds -> drawClouds(state, t, layer.intensity, fade, fog = false) + is WeatherEffectLayer.Fog -> drawClouds(state, t, layer.intensity, fade, fog = true) + is WeatherEffectLayer.SunGlow -> drawSunGlow(state, t, layer.intensity, fade) + is WeatherEffectLayer.Stars -> drawStars(state.field, t, layer.intensity, fade) + else -> Unit + } + } + if (needsSurfaces) { + clipPath(coverPath, ClipOp.Difference) { + states.forEach { state -> + val layer = state.current() + val fade = strength * state.level + when (layer) { + is WeatherEffectLayer.Rain -> { + val target = state.layer as WeatherEffectLayer.Rain + val ratio = (0.12f + 0.2f * layer.intensity) / (0.12f + 0.2f * target.intensity) + drawRain(state, target.copy(slant = layer.slant), t + state.timeOffset, fade * ratio, settledSurfaces) + } + is WeatherEffectLayer.Snow -> drawSnow(state.field, t, layer.intensity, fade, settledSurfaces, snowState) + is WeatherEffectLayer.Hail -> drawHail(state, layer.intensity, t, fade) + else -> Unit + } } - is WeatherEffectLayer.Clouds -> drawClouds(state.field, t, layer.intensity, strength, fog = false) - is WeatherEffectLayer.Fog -> drawClouds(state.field, t, layer.intensity, strength, fog = true) - is WeatherEffectLayer.SunGlow -> drawSunGlow(t, layer.intensity, strength) - is WeatherEffectLayer.Stars -> drawStars(state.field, t, layer.intensity, strength) - is WeatherEffectLayer.Lightning -> drawLightning(t, layer.intensity, strength) } } + states.forEach { state -> + if (state.layer is WeatherEffectLayer.Lightning) drawLightning(t, state.intensity, strength * state.level) + } } } @@ -210,9 +357,9 @@ internal fun DrawScope.drawWeatherEffectsStill(spec: WeatherEffectSpec, strength is WeatherEffectLayer.Rain -> behindCards(cover) { drawRain(state, layer, STILL_TIME_S, strength, emptyList()) } is WeatherEffectLayer.Snow -> behindCards(cover) { drawSnow(state.field, STILL_TIME_S, layer.intensity, strength, emptyList(), snowState) } is WeatherEffectLayer.Hail -> behindCards(cover) { drawHail(state, layer.intensity, STILL_TIME_S, strength) } - is WeatherEffectLayer.Clouds -> drawClouds(state.field, STILL_TIME_S, layer.intensity, strength, fog = false) - is WeatherEffectLayer.Fog -> drawClouds(state.field, STILL_TIME_S, layer.intensity, strength, fog = true) - is WeatherEffectLayer.SunGlow -> drawSunGlow(STILL_TIME_S, layer.intensity, strength) + is WeatherEffectLayer.Clouds -> drawClouds(state, STILL_TIME_S, layer.intensity, strength, fog = false) + is WeatherEffectLayer.Fog -> drawClouds(state, STILL_TIME_S, layer.intensity, strength, fog = true) + is WeatherEffectLayer.SunGlow -> drawSunGlow(state, STILL_TIME_S, layer.intensity, strength) is WeatherEffectLayer.Stars -> drawStars(state.field, STILL_TIME_S, layer.intensity, strength) // A full-strength flash would wash the whole widget out, so the frozen frame is the flash at a gentle strength. is WeatherEffectLayer.Lightning -> drawLightning(lightningStrike(0) + 0.05f, layer.intensity, strength * 0.35f) @@ -221,6 +368,12 @@ internal fun DrawScope.drawWeatherEffectsStill(spec: WeatherEffectSpec, strength } private const val STILL_TIME_S = 4.2f +private const val FADE_S = 1.4f +private const val INTENSITY_EASE = 2.5f +private const val QUALITY_WINDOW_NS = 1_500_000_000L +private const val SLOW_FRAME_MS = 21f +private const val QUALITY_GRACE_S = 4f +private const val SLOW_WINDOWS_TO_DOWNGRADE = 3 private fun DrawScope.behindCards(cover: Path?, block: DrawScope.() -> Unit) { if (cover == null) block() else clipPath(cover, ClipOp.Difference, block) @@ -281,13 +434,15 @@ private fun emitHaptics( density: Density, ) { layers.forEach { state -> + if (state.targetLevel <= 0f) return@forEach + val offset = state.timeOffset when (val layer = state.layer) { is WeatherEffectLayer.Rain -> for (i in 0 until state.field.count) { val fall = rainFall(state.field, i, layer.intensity, h, density) - val before = progress(state.field, i, fall, previous) - val after = progress(state.field, i, fall, now) + val before = progress(state.field, i, fall, previous + offset) + val after = progress(state.field, i, fall, now + offset) if (crossed(before, after, fall.impact)) { - val cycle = cycleOf(state.field, i, fall, now) - if (after >= fall.impact) 0 else 1 + val cycle = cycleOf(state.field, i, fall, now + offset) - if (after >= fall.impact) 0 else 1 if (roll(i, cycle, HERO_SALT) < RAIN_HAPTIC_SHARE) sink.onDrop(layer.intensity, state.field.size[i]) } } @@ -359,7 +514,7 @@ private fun DrawScope.drawRain( val field = state.field val w = size.width val h = size.height - val dropPath = Path() + val dropPath = state.dropPath for (i in 0 until field.count) { val length = rainLength(field, i, layer.intensity, this) val fall = rainFall(field, i, layer.intensity, h, this) @@ -428,14 +583,7 @@ private fun DrawScope.drawDrop(path: Path, tailX: Float, tailY: Float, headX: Fl ) path.quadraticTo(mx - nx * radius * 0.3f, my - ny * radius * 0.3f, tailX, tailY) path.close() - drawPath( - path, - Brush.linearGradient( - colors = listOf(Color.White.copy(alpha = 0f), Color.White.copy(alpha = alpha)), - start = Offset(tailX, tailY), - end = Offset(headX, headY), - ), - ) + drawPath(path, Color.White.copy(alpha = alpha * 0.7f)) } private fun surfaceDepth(i: Int, cycle: Int): Int { @@ -654,40 +802,41 @@ private fun DrawScope.drawSnow( } } -private fun DrawScope.drawClouds(field: ParticleField, t: Float, intensity: Float, strength: Float, fog: Boolean) { +private fun DrawScope.drawClouds(state: LayerState, t: Float, intensity: Float, strength: Float, fog: Boolean) { + val field = state.field val w = size.width val h = size.height + val alpha = (if (fog) 0.14f else 0.1f) * intensity * strength + if (alpha <= 0f) return + if (state.brushes == null || state.brushWidth != w) { + state.brushes = Array(field.capacity) { i -> + val radius = (if (fog) 0.55f else 0.35f + 0.25f * field.size[i]) * w + Brush.radialGradient(listOf(Color.White, Color.Transparent), center = Offset.Zero, radius = radius) + } + state.brushWidth = w + } + val brushes = state.brushes!! for (i in 0 until field.count) { val drift = (field.x[i] + t * (0.008f + 0.012f * field.speed[i])) % 1f val cx = (drift * 1.6f - 0.3f) * w val cy = if (fog) (0.15f + 0.8f * field.phase[i]) * h else (0.02f + 0.35f * field.phase[i]) * h val radius = (if (fog) 0.55f else 0.35f + 0.25f * field.size[i]) * w - val alpha = (if (fog) 0.14f else 0.1f) * intensity * strength - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent), - center = Offset(cx, cy), - radius = radius, - ), - radius = radius, - center = Offset(cx, cy), - ) + translate(cx, cy) { + drawCircle(brushes[i], radius, Offset.Zero, alpha = alpha) + } } } -private fun DrawScope.drawSunGlow(t: Float, intensity: Float, strength: Float) { - val center = Offset(size.width * 0.85f, 0f) +private fun DrawScope.drawSunGlow(state: LayerState, t: Float, intensity: Float, strength: Float) { val radius = size.width * 0.9f + if (state.glow == null || state.glowWidth != size.width) { + state.glow = Brush.radialGradient(listOf(SUN_COLOR, Color.Transparent), center = Offset.Zero, radius = radius) + state.glowWidth = size.width + } val pulse = 0.85f + 0.15f * sin(t * 0.6f) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(SUN_COLOR.copy(alpha = 0.22f * intensity * pulse * strength), Color.Transparent), - center = center, - radius = radius, - ), - radius = radius, - center = center, - ) + translate(size.width * 0.85f, 0f) { + drawCircle(state.glow!!, radius, Offset.Zero, alpha = 0.22f * intensity * pulse * strength) + } } private fun DrawScope.drawStars(field: ParticleField, t: Float, intensity: Float, strength: Float) { diff --git a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt index bb748af..685a737 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt @@ -60,7 +60,8 @@ 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.RollingClock +import com.sameerasw.overcast.ui.features.weather.providerLabel import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Date @@ -105,14 +106,12 @@ fun WeatherIslandExpanded( .background(Color.Black) .clickable(onClick = onOpenApp), ) { - if (!effectSpec.isEmpty) { - WeatherEffects( - spec = effectSpec, - modifier = Modifier.matchParentSize(), - clearTop = clearTop, - haptics = haptics, - ) - } + WeatherEffects( + spec = effectSpec, + modifier = Modifier.matchParentSize(), + clearTop = clearTop, + haptics = haptics, + ) Column(Modifier.fillMaxWidth()) { Spacer(Modifier.height((clearTop - compactHeight).coerceAtLeast(0.dp))) Row( @@ -133,7 +132,7 @@ fun WeatherIslandExpanded( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - snapshot?.let { Text(WeatherFormat.temperature(it.tempC, units.temperature), style = title.copy(fontSize = 22.sp)) } + snapshot?.let { RollingClock(WeatherFormat.temperature(it.tempC, units.temperature), Color.Unspecified, null, title.copy(fontSize = 22.sp)) } } Spacer(Modifier.height(6.dp)) Column( @@ -151,9 +150,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) } @@ -170,8 +169,8 @@ private fun Summary(snapshot: WeatherSnapshot, units: WeatherUnits) { snapshot.conditionText, stringResource( R.string.weather_high_low, - WeatherFormat.temperature(snapshot.highC, units.temperature), - WeatherFormat.temperature(snapshot.lowC, units.temperature), + WeatherFormat.temperature(snapshot.todayHighC(), units.temperature), + WeatherFormat.temperature(snapshot.todayLowC(), units.temperature), ), stringResource(R.string.weather_feels_like, WeatherFormat.temperature(snapshot.feelsLikeC, units.temperature)), ).filter { it.isNotBlank() }.joinToString(" · "), @@ -220,7 +219,7 @@ private fun AlertCard(alert: WeatherAlert) { @Composable private fun HourlyStrip(snapshot: WeatherSnapshot, units: WeatherUnits, iconStyle: WeatherIconStyle) { val context = LocalContext.current - val hours = snapshot.hourly.take(6) + val hours = snapshot.upcomingHours().take(6) if (hours.isEmpty()) return Row( modifier = Modifier @@ -234,7 +233,7 @@ private fun HourlyStrip(snapshot: WeatherSnapshot, units: WeatherUnits, iconStyl Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(formatHour(context, hour.timeMillis), style = body.copy(fontSize = 11.sp)) WeatherIcon(hour.condition, hour.isDay, Color.White, Modifier.size(18.dp), iconStyle) - Text(WeatherFormat.temperature(hour.tempC, units.temperature), style = body.copy(color = Color.White)) + RollingClock(WeatherFormat.temperature(hour.tempC, units.temperature), Color.Unspecified, null, body.copy(color = Color.White)) } } } @@ -252,7 +251,7 @@ private fun DetailChip(icon: Int, text: String, modifier: Modifier = Modifier) { ) { Icon(painterResource(icon), null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) - Text(text, style = body.copy(color = Color.White, fontSize = 12.sp), maxLines = 1) + RollingClock(text, Color.Unspecified, null, body.copy(color = Color.White, fontSize = 12.sp)) } } @@ -280,7 +279,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, diff --git a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt index 0b8d2b1..42d650d 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt @@ -62,11 +62,6 @@ object WeatherIslandPayload { else -> IslandTone.NONE } - val compactVisible = when (settings.getIslandMode()) { - SettingsRepository.ISLAND_MODE_COMPACT -> true - SettingsRepository.ISLAND_MODE_ALERTS -> alert != null - else -> false - } val lineEnd = if (alert != null) { alert.expiresMillis?.let { context.getString(R.string.weather_alert_until, formatTime(context, it)) } ?: temperature } else { @@ -78,7 +73,6 @@ object WeatherIslandPayload { priority = PRIORITY, priorityOverride = PRIORITY_ALERT.takeIf { alert != null }, placement = IslandPlacement.DYNAMIC, - compactVisible = compactVisible, compact = listOf( IslandCellPayload(key = "weather.icon", iconId = iconId, tone = tone), IslandCellPayload(key = "weather.temp", text = temperature, tone = IslandTone.NONE), @@ -96,7 +90,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 } } @@ -107,7 +101,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, ) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandService.kt b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandService.kt index cbec322..c3b186d 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandService.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandService.kt @@ -90,7 +90,7 @@ class WeatherIslandService : ComposeIslandProviderService() { } private suspend fun announceAlerts(host: IslandHostHandle, snapshot: WeatherSnapshot?) { - if (snapshot == null || !settings.isIslandPeekAlertsEnabled()) return + if (snapshot == null) return val severe = snapshot.activeAlerts().filter { it.severity.isSevere } if (severe.isEmpty()) return val fresh = WeatherRepository.takeUnnotifiedAlerts(applicationContext, severe.map { it.id }) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt b/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt index e2c835a..41065ba 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt @@ -82,6 +82,7 @@ data class WeatherExtras( val precipitationMm: Double? = null, val sunriseMillis: Long? = null, val sunsetMillis: Long? = null, + val moonPhase: Double? = null, ) @Keep @@ -106,9 +107,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, @@ -119,6 +120,17 @@ data class WeatherSnapshot( val daily: List? = null, ) { fun activeAlerts(now: Long = System.currentTimeMillis()): List = alerts.filter { it.isActive(now) } + + fun upcomingHours(now: Long = System.currentTimeMillis()): List = hourly.filter { it.timeMillis + HOUR_MS > now } + + fun todayHighC(now: Long = System.currentTimeMillis()): Double = todayEntry(now)?.highC ?: highC + + fun todayLowC(now: Long = System.currentTimeMillis()): Double = todayEntry(now)?.lowC ?: lowC + + private fun todayEntry(now: Long): DailyForecast? = + upcomingDays(now).firstOrNull()?.takeIf { it.dayMillis <= now && updatedAt < it.dayMillis } + + fun upcomingDays(now: Long = System.currentTimeMillis()): List = daily.orEmpty().filter { it.dayMillis + DAY_MS > now } } sealed interface WeatherError { @@ -136,3 +148,6 @@ data class WeatherState( val loading: Boolean = false, val error: WeatherError? = null, ) + +private const val HOUR_MS = 60 * 60_000L +private const val DAY_MS = 24 * HOUR_MS diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/AccuWeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/AccuWeatherProvider.kt index f52116c..6cb968d 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/AccuWeatherProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/AccuWeatherProvider.kt @@ -1,5 +1,6 @@ package com.sameerasw.overcast.weather.provider +import com.sameerasw.overcast.weather.MoonPhase import com.sameerasw.overcast.weather.model.CityResult import com.sameerasw.overcast.weather.model.DailyForecast import com.sameerasw.overcast.weather.model.HourlyForecast @@ -139,6 +140,7 @@ class AccuWeatherProvider : WeatherProvider { precipitationMm = metric(current, "Precip1hr"), sunriseMillis = sun?.optLong("EpochRise", 0L)?.takeIf { it > 0 }?.times(1000L), sunsetMillis = sun?.optLong("EpochSet", 0L)?.takeIf { it > 0 }?.times(1000L), + moonPhase = today.optJSONObject("Moon")?.optDouble("Age")?.takeUnless { it.isNaN() }?.let { MoonPhase.fromAgeDays(it) }, ), daily = (0 until days.length()).map { i -> val d = days.getJSONObject(i) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/OpenMeteoProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/OpenMeteoProvider.kt index 63a6115..2c37c36 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/OpenMeteoProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/OpenMeteoProvider.kt @@ -44,7 +44,7 @@ class OpenMeteoProvider : WeatherProvider { "¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m," + "surface_pressure,wind_gusts_10m,wind_direction_10m,cloud_cover,precipitation,dew_point_2m" + "&hourly=temperature_2m,precipitation_probability,weather_code,is_day,visibility" + - "&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code,uv_index_max,sunrise,sunset" + + "&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code,uv_index_max,sunrise,sunset,moon_phase" + "&forecast_days=7&timezone=auto&wind_speed_unit=kmh&timeformat=unixtime" + (OpenMeteoModels.selected()?.let { "&models=$it" } ?: "") val json = JSONObject(ProviderHttp.get(url)) @@ -132,6 +132,7 @@ class OpenMeteoProvider : WeatherProvider { precipitationMm = optNumber("precipitation"), sunriseMillis = daily.optJSONArray("sunrise")?.takeIf { !it.isNull(0) }?.getLong(0)?.times(1000L), sunsetMillis = daily.optJSONArray("sunset")?.takeIf { !it.isNull(0) }?.getLong(0)?.times(1000L), + moonPhase = daily.optJSONArray("moon_phase")?.takeIf { !it.isNull(0) }?.getDouble(0), ), daily = dailyList, ) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/PirateWeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/PirateWeatherProvider.kt index 1406d21..a453ab2 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/PirateWeatherProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/PirateWeatherProvider.kt @@ -110,6 +110,7 @@ class PirateWeatherProvider : WeatherProvider { precipitationMm = num(current, "precipIntensity"), sunriseMillis = num(dailyToday, "sunriseTime")?.toLong()?.times(1000L), sunsetMillis = num(dailyToday, "sunsetTime")?.toLong()?.times(1000L), + moonPhase = num(dailyToday, "moonPhase"), ), daily = (0 until dailyArray.length()).map { i -> val d = dailyArray.getJSONObject(i) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt index 780e9e1..c21f6d8 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt @@ -16,7 +16,11 @@ internal object ProviderHttp { fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") - suspend fun get(url: String, headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { + suspend fun get(url: String, headers: Map = emptyMap()): String = request(url, headers, null) + + suspend fun post(url: String, body: String, headers: Map = emptyMap()): String = request(url, headers, body) + + private suspend fun request(url: String, headers: Map, body: String?): String = withContext(Dispatchers.IO) { val connection = try { URL(url).openConnection() as HttpURLConnection } catch (e: IOException) { @@ -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) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/VisualCrossingProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/VisualCrossingProvider.kt index d11e995..915d28f 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/VisualCrossingProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/VisualCrossingProvider.kt @@ -115,6 +115,7 @@ class VisualCrossingProvider : WeatherProvider { precipitationMm = current.optDouble("precip").takeUnless { it.isNaN() }, sunriseMillis = current.optLong("sunriseEpoch", 0L).takeIf { it > 0 }?.times(1000L), sunsetMillis = current.optLong("sunsetEpoch", 0L).takeIf { it > 0 }?.times(1000L), + moonPhase = today.optDouble("moonphase").takeUnless { it.isNaN() }, ), daily = (0 until days.length()).map { i -> val d = days.getJSONObject(i) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherApiProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherApiProvider.kt index 8a6d386..58ae8b8 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherApiProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherApiProvider.kt @@ -1,5 +1,6 @@ package com.sameerasw.overcast.weather.provider +import com.sameerasw.overcast.weather.MoonPhase import com.sameerasw.overcast.weather.model.AlertSeverity import com.sameerasw.overcast.weather.model.CityResult import com.sameerasw.overcast.weather.model.DailyForecast @@ -152,6 +153,7 @@ class WeatherApiProvider : WeatherProvider { windGustKph = current.optDouble("gust_kph").takeUnless { it.isNaN() }, windDirectionDeg = current.optDouble("wind_degree").takeUnless { it.isNaN() }, precipitationMm = current.optDouble("precip_mm").takeUnless { it.isNaN() }, + moonPhase = days.getJSONObject(0).optJSONObject("astro")?.let { moonPhaseFrom(it) }, ), daily = (0 until days.length()).map { i -> val d = days.getJSONObject(i) @@ -167,6 +169,13 @@ class WeatherApiProvider : WeatherProvider { ) } + private fun moonPhaseFrom(astro: JSONObject): Double? { + val illumination = astro.optString("moon_illumination").toDoubleOrNull() ?: return null + val name = astro.optString("moon_phase").lowercase() + if (name.isBlank()) return null + return MoonPhase.fromIllumination(illumination, waxing = !name.contains("waning") && !name.contains("last")) + } + private fun parseTime(value: String?): Long? { if (value.isNullOrBlank()) return null return try { diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt index a8949a4..cb2c15f 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt @@ -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 @@ -9,6 +10,8 @@ interface WeatherProvider { val displayName: String val requiresApiKey: Boolean val signupUrl: String? + val minRefreshMinutes: Int get() = 0 + @get:StringRes val attributionRes: Int? get() = null suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot @@ -30,6 +33,7 @@ object WeatherProviders { OpenWeatherMapProvider(), WeatherApiProvider(), AccuWeatherProvider(), + YandexWeatherProvider(), ) val default: WeatherProvider get() = all.first() diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt new file mode 100644 index 0000000..8bf1ee4 --- /dev/null +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt @@ -0,0 +1,207 @@ +package com.sameerasw.overcast.weather.provider + +import com.sameerasw.overcast.R +import com.sameerasw.overcast.weather.model.CityResult +import com.sameerasw.overcast.weather.model.DailyForecast +import com.sameerasw.overcast.weather.model.HourlyForecast +import com.sameerasw.overcast.weather.model.WeatherCondition +import com.sameerasw.overcast.weather.model.WeatherExtras +import com.sameerasw.overcast.weather.model.WeatherLocation +import com.sameerasw.overcast.weather.model.WeatherSnapshot +import org.json.JSONException +import org.json.JSONObject +import kotlin.math.roundToInt + +// Yandex Weather API v3 (GraphQL), the free tariff for personal, non-commercial use. +// Terms: https://yandex.ru/legal/apib2c_weather_agreement/ru/ (points 2.2, 2.4, 2.6, 2.7). +// Point 2.7 lists temperature, feels-like, wind, condition and icon. The owner also requests the other +// fields the key returns (rain chance, UV, gusts, wind direction, sunrise and sunset): see the commit message. +class YandexWeatherProvider : WeatherProvider { + override val id = "yandex" + override val displayName = "Yandex Weather" + override val requiresApiKey = true + override val signupUrl = "https://yandex.ru/pogoda/b2b/console/smarthome" + override val minRefreshMinutes = 50 + override val attributionRes = R.string.weather_attribution_yandex + + override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { + val key = apiKey?.trim().orEmpty() + if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) + val body = JSONObject().put("query", graphQuery(location)).toString() + val json = try { + JSONObject(ProviderHttp.post(BASE_URL, body, mapOf(KEY_HEADER to key))) + } catch (e: JSONException) { + throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) + } + throwOnApiErrors(json) + val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) + return try { + parseForecast(json.getJSONObject("data").getJSONObject("weatherByPoint"), place) + } catch (e: JSONException) { + throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) + } + } + + override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) + + private fun throwOnApiErrors(json: JSONObject) { + if (!json.isNull("data")) return + val message = json.optJSONArray("errors")?.optJSONObject(0)?.optString("message").orEmpty() + val reason = if (AUTH_ERROR.containsMatchIn(message)) { + WeatherProviderException.Reason.INVALID_KEY + } else { + WeatherProviderException.Reason.BAD_RESPONSE + } + throw WeatherProviderException(reason, message.ifBlank { null }) + } + + // Point 2.7 parameters plus precProbability (see the header). The forecast covers today and tomorrow. + private fun graphQuery(location: WeatherLocation) = """ + { + weatherByPoint(request: { lat: ${coordinate(location.latitude)}, lon: ${coordinate(location.longitude)} }) { + now { + temperature(unit: CELSIUS) + feelsLike(unit: CELSIUS) + windSpeed(unit: KILOMETERS_PER_HOUR) + windGust(unit: KILOMETERS_PER_HOUR) + windDirection + uvIndex + condition + icon(format: CODE) + } + forecast { + days(limit: 2) { + timestamp + sunriseTimestamp + sunsetTimestamp + hours { + timestamp + temperature(unit: CELSIUS) + condition + icon(format: CODE) + precProbability + } + } + } + } + } + """.trimIndent() + + // Two decimals is about 1 km, which keeps the exact position off the wire. + private fun coordinate(value: Double): Double = (value * 100).roundToInt() / 100.0 + + private fun parseForecast(weather: JSONObject, place: Pair?): WeatherSnapshot { + val now = weather.getJSONObject("now") + val days = weather.getJSONObject("forecast").getJSONArray("days") + val nowMillis = System.currentTimeMillis() + val daily = (0 until days.length()).mapNotNull { dayFrom(days.getJSONObject(it)) } + val today = daily.firstOrNull() + val extras = WeatherExtras( + uvIndex = now.optDouble("uvIndex").takeUnless { it.isNaN() }, + windGustKph = now.optDouble("windGust").takeUnless { it.isNaN() }, + windDirectionDeg = windDirectionDeg(now.optString("windDirection")), + sunriseMillis = days.getJSONObject(0).optLong("sunriseTimestamp").takeIf { it > 0 }?.times(1000L), + sunsetMillis = days.getJSONObject(0).optLong("sunsetTimestamp").takeIf { it > 0 }?.times(1000L), + ) + + val hourly = buildList { + for (d in 0 until days.length()) { + val hours = days.getJSONObject(d).getJSONArray("hours") + for (h in 0 until hours.length()) { + val hour = hours.getJSONObject(h) + val timestamp = hour.getLong("timestamp") + if ((timestamp + HOUR_S) * 1000L <= nowMillis) continue + add( + HourlyForecast( + timeMillis = timestamp * 1000L, + tempC = hour.getDouble("temperature"), + condition = conditionFor(hour.getString("condition")), + isDay = isDaytime(timestamp, days.getJSONObject(d), hour.optString("icon")), + chanceOfRain = percent(hour.optDouble("precProbability")), + ), + ) + } + } + }.take(HOURLY_COUNT) + + return WeatherSnapshot( + locationName = place?.first.orEmpty(), + region = place?.second.orEmpty(), + tempC = now.getDouble("temperature"), + feelsLikeC = now.getDouble("feelsLike"), + condition = conditionFor(now.getString("condition")), + conditionText = now.getString("condition"), + isDay = isDaytime(nowMillis / 1000L, days.getJSONObject(0), now.optString("icon")), + humidity = null, + windKph = now.getDouble("windSpeed"), + chanceOfRain = today?.chanceOfRain, + highC = today?.highC ?: now.getDouble("temperature"), + lowC = today?.lowC ?: now.getDouble("temperature"), + hourly = hourly, + alerts = emptyList(), + updatedAt = nowMillis, + providerId = id, + extras = extras, + daily = daily, + ) + } + + private fun dayFrom(day: JSONObject): DailyForecast? { + val hours = day.getJSONArray("hours") + if (hours.length() == 0) return null + val temps = (0 until hours.length()).map { hours.getJSONObject(it).getDouble("temperature") } + val midday = hours.getJSONObject(minOf(12, hours.length() - 1)) + return DailyForecast( + dayMillis = day.getLong("timestamp") * 1000L, + highC = temps.max(), + lowC = temps.min(), + condition = conditionFor(midday.getString("condition")), + chanceOfRain = (0 until hours.length()).maxOf { percent(hours.getJSONObject(it).optDouble("precProbability")) }, + ) + } + + private fun isDaytime(timestampS: Long, day: JSONObject, icon: String): Boolean { + val sunrise = day.optLong("sunriseTimestamp") + val sunset = day.optLong("sunsetTimestamp") + return if (sunrise > 0 && sunset > sunrise) timestampS in sunrise until sunset else !icon.endsWith("_n") + } + + // precProbability is a 0..1 chance; a missing field (NaN) counts as 0. + private fun percent(probability: Double): Int = if (probability.isNaN()) 0 else (probability * 100).roundToInt() + + // Yandex sends a compass point; CALM has no direction. + private fun windDirectionDeg(code: String): Double? = when (code) { + "NORTH" -> 0.0 + "NORTH_EAST" -> 45.0 + "EAST" -> 90.0 + "SOUTH_EAST" -> 135.0 + "SOUTH" -> 180.0 + "SOUTH_WEST" -> 225.0 + "WEST" -> 270.0 + "NORTH_WEST" -> 315.0 + else -> null + } + + // https://yandex.ru/dev/weather/doc/ru/concepts/spectaql.md (the Condition enum) + private fun conditionFor(code: String): WeatherCondition = when (code) { + "CLEAR" -> WeatherCondition.CLEAR + "PARTLY_CLOUDY" -> WeatherCondition.PARTLY_CLOUDY + "CLOUDY", "OVERCAST" -> WeatherCondition.CLOUDY + "LIGHT_RAIN" -> WeatherCondition.DRIZZLE + "RAIN", "SHOWERS" -> WeatherCondition.RAIN + "HEAVY_RAIN" -> WeatherCondition.HEAVY_RAIN + "SLEET" -> WeatherCondition.SLEET + "LIGHT_SNOW", "SNOW", "SNOWFALL" -> WeatherCondition.SNOW + "HAIL" -> WeatherCondition.HAIL + "THUNDERSTORM", "THUNDERSTORM_WITH_RAIN", "THUNDERSTORM_WITH_HAIL" -> WeatherCondition.THUNDERSTORM + else -> WeatherCondition.UNKNOWN + } + + private companion object { + const val BASE_URL = "https://api.weather.yandex.ru/graphql/query" + const val KEY_HEADER = "X-Yandex-Weather-Key" + const val HOUR_S = 60 * 60L + const val HOURLY_COUNT = 24 + val AUTH_ERROR = Regex("unauthori[sz]ed|forbidden|api key|invalid key|access denied", RegexOption.IGNORE_CASE) + } +} diff --git a/app/src/main/java/com/sameerasw/overcast/weather/screensaver/OvercastDreamService.kt b/app/src/main/java/com/sameerasw/overcast/weather/screensaver/OvercastDreamService.kt index d769122..8581a8c 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/screensaver/OvercastDreamService.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/screensaver/OvercastDreamService.kt @@ -21,6 +21,7 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.sameerasw.overcast.data.repository.SettingsRepository import com.sameerasw.overcast.ui.features.weather.AmbientWeatherScene +import com.sameerasw.overcast.ui.features.weather.WeatherTimeline import com.sameerasw.overcast.ui.features.weather.rememberWeatherPresentation import com.sameerasw.overcast.ui.theme.OvercastTheme import com.sameerasw.overcast.weather.WeatherRepository @@ -80,5 +81,6 @@ private fun DreamContent(onDismiss: () -> Unit) { WeatherRepository.ensureLoaded(context) if (WeatherRepository.isStale(context)) WeatherRepository.refresh(context) } - AmbientWeatherScene(rememberWeatherPresentation(state.snapshot), settings.isAmbientForecastEnabled(), onDismiss = onDismiss, animateIntro = true) + val timeline = remember { WeatherTimeline() } + AmbientWeatherScene(rememberWeatherPresentation(state.snapshot, timeline), settings.isAmbientForecastEnabled(), onDismiss = onDismiss, animateIntro = true, autoAnimate = settings.isScreensaverAutoAnimateEnabled()) } diff --git a/app/src/main/java/com/sameerasw/overcast/weather/widget/ForecastRow.kt b/app/src/main/java/com/sameerasw/overcast/weather/widget/ForecastRow.kt index 1c1ee88..e297047 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/widget/ForecastRow.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/widget/ForecastRow.kt @@ -25,11 +25,9 @@ import com.sameerasw.overcast.R import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import kotlin.math.abs private const val MIN_ITEM_DP = 52f private const val MAX_ITEMS = 14 -private const val HOUR_MS = 60 * 60_000L // One column of a forecast row: a label, an icon, a temperature and optionally a muted second temperature. internal class ForecastItem(val label: String, val icon: Int, val primary: String, val secondary: String? = null) @@ -38,12 +36,11 @@ internal fun hourlyItems(context: Context, weather: WidgetWeather): List now - HOUR_MS } + return weather.snapshot?.upcomingHours(now).orEmpty() .take(MAX_ITEMS) .map { ForecastItem( - label = if (abs(it.timeMillis - now) < HOUR_MS * 3 / 4) { + label = if (it.timeMillis <= now) { context.getString(R.string.widget_hourly_now) } else { format.format(Date(it.timeMillis)).lowercase(Locale.getDefault()) @@ -56,7 +53,7 @@ internal fun hourlyItems(context: Context, weather: WidgetWeather): List { val format = SimpleDateFormat("EEE", Locale.getDefault()) - return weather.snapshot?.daily.orEmpty().take(MAX_ITEMS).mapIndexed { index, day -> + return weather.snapshot?.upcomingDays().orEmpty().take(MAX_ITEMS).mapIndexed { index, day -> ForecastItem( label = if (index == 0) context.getString(R.string.weather_detail_today) else format.format(Date(day.dayMillis)), icon = weather.style.icon(day.condition, true), diff --git a/app/src/main/java/com/sameerasw/overcast/weather/widget/WeatherWidget.kt b/app/src/main/java/com/sameerasw/overcast/weather/widget/WeatherWidget.kt index 8c64d0f..9748ef3 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/widget/WeatherWidget.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/widget/WeatherWidget.kt @@ -139,8 +139,8 @@ private fun WeatherWidgetContent( val sectionGap = spacing * 0.5f // The forecast row only appears once the widget is tall enough to hold it alongside the temperature. - val showForecast = forecastItems.isNotEmpty() && size.height >= 140.dp - val forecastHeight = if (showForecast) (size.height * 0.36f).coerceIn(70.dp, 120.dp) else 0.dp + val showForecast = forecastItems.isNotEmpty() && size.height >= 100.dp + val forecastHeight = if (showForecast) (size.height * 0.36f).coerceIn(56.dp, 120.dp) else 0.dp val forecastGap = if (showForecast) sectionGap else 0.dp val topHeight = size.height - padding * 2 - forecastHeight - forecastGap val showSubtitle = topHeight >= 72.dp diff --git a/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt b/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt index 221e4db..54df838 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt @@ -8,7 +8,9 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.sameerasw.overcast.data.repository.SettingsRepository import com.sameerasw.overcast.weather.WeatherRepository +import com.sameerasw.overcast.weather.provider.WeatherProviders import java.util.concurrent.TimeUnit class WeatherRefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -22,7 +24,10 @@ object WeatherScheduler { private const val WORK_NAME = "weather_refresh" fun schedule(context: Context, intervalMinutes: Int) { - val request = PeriodicWorkRequestBuilder(intervalMinutes.coerceAtLeast(15).toLong(), TimeUnit.MINUTES) + // Yandex allows 30 requests a day, so its provider sets a longer minimum. + val provider = WeatherProviders.byId(SettingsRepository(context).getWeatherProvider()) + val minutes = maxOf(intervalMinutes, 15, provider.minRefreshMinutes) + val request = PeriodicWorkRequestBuilder(minutes.toLong(), TimeUnit.MINUTES) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 283cba4..08506f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,32 @@ Chance of rain Sunrise Sunset + Moon + Timeline mode + Auto scrub screensaver + Now + Tomorrow + Back to now + Clear + Partly cloudy + Cloudy + Fog + Drizzle + Rain + Heavy rain + Sleet + Snow + Hail + Thunderstorm + Unknown + New moon + Waxing crescent + First quarter + Waxing gibbous + Full moon + Waning gibbous + Last quarter + Waning crescent Today UV index Visibility @@ -43,12 +69,6 @@ Search for a city No cities found Display - Dynamic island - Show on island - Brief only - Always - Alerts only - Peek for alerts Weather Overcast wants to show live weather, alerts and forecasts on your island. Screensaver @@ -153,4 +173,6 @@ Finish Reset welcome Welcome will show again on next launch + + The data is provided by the Yandex Weather service. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b18fffb..0e5ba1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-window = { group = "androidx.window", name = "window", version = "1.5.0" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "composeUi" }