diff --git a/Strand/Liquid/LiquidSky.swift b/Strand/Liquid/LiquidSky.swift index 260d4cb4a..d68399926 100644 --- a/Strand/Liquid/LiquidSky.swift +++ b/Strand/Liquid/LiquidSky.swift @@ -6,6 +6,24 @@ // no blur — clean and crisp, the atmosphere of the app's header. import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// PROTOTYPE (#weather): load an optional weather-overlay image from the asset catalog, or nil when the +/// asset isn't present. The weather layer draws this image (blended over the sky) when it exists and falls +/// back to the procedural draw otherwise — so dropping `weather_` art in upgrades the look with no +/// code change. Asset names: weather_hazy / weather_overcast / weather_rain / weather_fog / weather_snow. +private func loadWeatherImage(_ name: String) -> Image? { + #if canImport(UIKit) + if let ui = UIImage(named: name) { return Image(uiImage: ui) } + #elseif canImport(AppKit) + if let ns = NSImage(named: name) { return Image(nsImage: ns) } + #endif + return nil +} struct LiquidSkyStop { let h: Double @@ -20,6 +38,25 @@ private func hx(_ hex: UInt32) -> Color { blue: Double(hex & 0xff) / 255, opacity: 1) } +/// PROTOTYPE (#weather): an optional weather MOOD layered over the time-of-day gradient. Aesthetic only — +/// no real conditions, no location or network (NOOP stays offline). Every mode is procedural and tinted by +/// the CURRENT sky, so a cloud at dusk reads mauve and at noon reads white. `.clear` (default) = today's look. +enum LiquidWeather: String, CaseIterable, Identifiable { + case clear, hazy, overcast, rain, fog, snow + var id: String { rawValue } + static let storageKey = "liquid.weather" + var label: String { + switch self { + case .clear: return "Clear" + case .hazy: return "Hazy" + case .overcast: return "Overcast" + case .rain: return "Rain" + case .fog: return "Fog" + case .snow: return "Snow" + } + } +} + /// The ten keyframes mirror the real app's day-cycle scenes (SceneHeroBackground), /// as pure gradients rather than painted art. let liquidSkyKeys: [LiquidSkyStop] = [ @@ -65,21 +102,37 @@ struct LiquidSky: View { /// the atmosphere so the sky still reads under a full-height "sky behind cards" backdrop). var settleStrength: Double = 1 @Environment(\.colorScheme) private var scheme + /// PROTOTYPE (#weather): the selected weather mood; `.clear` by default (unchanged look). Reactive, so + /// flipping it in Settings live-updates the sky. + @AppStorage(LiquidWeather.storageKey) private var weatherRaw = LiquidWeather.clear.rawValue + + /// The theme-aware canvas colour the sky dissolves into (no hard seam where sky meets page). + private var settleColor: Color { + let dark = scheme == .dark + return Color(.sRGB, red: dark ? 18.0 / 255.0 : 242.0 / 255.0, + green: dark ? 21.0 / 255.0 : 242.0 / 255.0, + blue: dark ? 24.0 / 255.0 : 247.0 / 255.0, opacity: 1) + } + + /// PROTOTYPE (#weather): a shown weather IMAGE is static, so it doesn't need the per-frame animation — + /// and re-blitting a full-screen bitmap at 20fps behind a scrolling list stutters. Skip the TimelineView + /// when an image is present (it also hides the animated breath/stars underneath). Procedural moods and + /// the plain sky keep the live animation. + private var hasWeatherImage: Bool { + (LiquidWeather(rawValue: weatherRaw) ?? .clear) != .clear + && loadWeatherImage("weather_\(weatherRaw)") != nil + } var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { tl in - let now = liquidSeconds(tl.date) - let h = hour ?? liveHour() - // The sky must dissolve into the SAME canvas colour the body uses (theme-aware surfaceBase), - // so there is no hard seam where the sky meets the page — light mode made this glaring. - let dark = scheme == .dark - let settle = Color(.sRGB, - red: dark ? 18.0 / 255.0 : 242.0 / 255.0, - green: dark ? 21.0 / 255.0 : 242.0 / 255.0, - blue: dark ? 24.0 / 255.0 : 247.0 / 255.0, - opacity: 1) + if hasWeatherImage { Canvas { ctx, size in - render(ctx, size, hour: h, now: now, settle: settle) + render(ctx, size, hour: hour ?? liveHour(), now: 0, settle: settleColor) + } + } else { + TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { tl in + Canvas { ctx, size in + render(ctx, size, hour: hour ?? liveHour(), now: liquidSeconds(tl.date), settle: settleColor) + } } } } @@ -112,6 +165,9 @@ struct LiquidSky: View { with: .linearGradient(Gradient(colors: [warm.opacity(0), warm.opacity(S.warm * 0.10)]), startPoint: CGPoint(x: 0, y: h * 0.55), endPoint: CGPoint(x: 0, y: h))) } + // PROTOTYPE (#weather): the weather mood sits between the warm sheet and the stars, so it reads + // under the starfield and over the same sky. Image art when present, else the procedural draw. + drawWeatherLayer(&ctx, size: size, weather: LiquidWeather(rawValue: weatherRaw) ?? .clear, sky: S, now: now) // stars if S.stars > 0.01 { for s in liquidStars { @@ -193,3 +249,106 @@ struct LiquidSkyStatic: View { return Double(c.hour ?? 0) + Double(c.minute ?? 0) / 60 } } + +// MARK: - PROTOTYPE weather layers (#weather) +// +// Procedural weather moods over the gradient, tinted by the current sky `S` (so they read at any hour). +// Cheap Canvas fills only — soft radial "clouds", short falling streaks for rain, drifting specks for snow, +// gradient veils for haze/fog. Values are a FIRST PASS to tune on-device (SwiftUI Canvas can't be previewed +// off-device). No-op when `.clear`. + +/// PROTOTYPE (#weather): draw the weather as ART if a `weather_` image is in the asset catalog, +/// otherwise fall back to the procedural draw. The image is aspect-FILLED to the width, top-aligned (so the +/// sky shows), and blended so the gradient reads through it — `.screen` lets a light cloud image lighten the +/// sky at any hour; opacity + blend are the on-device tuning knobs once real art is in. +private func drawWeatherLayer(_ ctx: inout GraphicsContext, size: CGSize, weather: LiquidWeather, + sky S: (top: Color, mid: Color, hor: Color, stars: Double, warm: Double), + now: Double) { + guard weather != .clear else { return } + if let img = loadWeatherImage("weather_\(weather.rawValue)") { + var wctx = ctx + // Normal (source-over) is the universal default: an OPAQUE scene (e.g. a storm sky) covers the + // gradient, and a TRANSPARENT overlay shows the gradient through its alpha. opacity lets the sky + // bleed at the edges. TUNE per art: .plusLighter/.screen only suit art on a black field. (#weather) + wctx.opacity = 0.9 + wctx.draw(img, in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + return + } + drawLiquidWeather(&ctx, size: size, weather: weather, sky: S, now: now) // procedural fallback +} + +private func drawLiquidWeather(_ ctx: inout GraphicsContext, size: CGSize, weather: LiquidWeather, + sky S: (top: Color, mid: Color, hor: Color, stars: Double, warm: Double), + now: Double) { + guard weather != .clear else { return } + let w = size.width, h = size.height + switch weather { + case .clear: + break + case .hazy: + // A soft band of horizon-tinted haze low in the sky. + let band = S.hor + ctx.fill(Path(CGRect(x: 0, y: h * 0.5, width: w, height: h * 0.5)), + with: .linearGradient(Gradient(colors: [band.opacity(0), band.opacity(0.16), band.opacity(0.04)]), + startPoint: CGPoint(x: 0, y: h * 0.5), endPoint: CGPoint(x: 0, y: h))) + case .overcast: + drawLiquidClouds(&ctx, w: w, h: h, tint: S.mid, now: now, coverage: 0.7) + case .rain: + drawLiquidClouds(&ctx, w: w, h: h, tint: S.mid, now: now, coverage: 0.85) + drawLiquidRain(&ctx, w: w, h: h, now: now) + case .fog: + let fog = lerpColor(S.mid, .white, 0.45) + ctx.fill(Path(CGRect(x: 0, y: h * 0.35, width: w, height: h * 0.65)), + with: .linearGradient(Gradient(colors: [fog.opacity(0), fog.opacity(0.34)]), + startPoint: CGPoint(x: 0, y: h * 0.35), endPoint: CGPoint(x: 0, y: h))) + case .snow: + drawLiquidSnow(&ctx, w: w, h: h, now: now) + } +} + +/// A few soft radial "clouds" drifting slowly across, tinted toward the mid-sky so they read at any hour. +private func drawLiquidClouds(_ ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, tint: Color, + now: Double, coverage: Double) { + let cloud = lerpColor(tint, .white, 0.5) + // (x0, y, radiusFrac, driftSpeed) — x0/y in [0,1]; drifts right and wraps. + let blobs: [(x: Double, y: Double, r: Double, sp: Double)] = [ + (0.15, 0.24, 0.30, 0.006), (0.55, 0.18, 0.36, 0.004), + (0.85, 0.30, 0.26, 0.008), (0.38, 0.36, 0.32, 0.005), + ] + for b in blobs { + let x = CGFloat((b.x + now * b.sp).truncatingRemainder(dividingBy: 1.25) - 0.1) * w + let y = CGFloat(b.y) * h + let rx = CGFloat(b.r) * w, ry = CGFloat(b.r) * w * 0.5 + ctx.fill(Path(ellipseIn: CGRect(x: x - rx, y: y - ry, width: rx * 2, height: ry * 2)), + with: .radialGradient(Gradient(colors: [cloud.opacity(coverage * 0.5), cloud.opacity(0)]), + center: CGPoint(x: x, y: y), startRadius: 0, endRadius: rx)) + } +} + +/// Short diagonal streaks falling on a loop. +private func drawLiquidRain(_ ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, now: Double) { + for i in 0..<70 { + let sx = (Double(i) * 0.61803).truncatingRemainder(dividingBy: 1) + let x = CGFloat(sx) * w + let phase = (now * 1.1 + Double(i) * 0.137).truncatingRemainder(dividingBy: 1) + let y = CGFloat(phase) * h + var p = Path() + p.move(to: CGPoint(x: x, y: y)) + p.addLine(to: CGPoint(x: x - 4, y: y + 15)) + ctx.stroke(p, with: .color(.white.opacity(0.10)), lineWidth: 1) + } +} + +/// Drifting specks that sway as they fall — the starfield feel, brighter and moving. +private func drawLiquidSnow(_ ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, now: Double) { + for i in 0..<70 { + let sx = (Double(i) * 0.61803).truncatingRemainder(dividingBy: 1) + let sway = CGFloat(sin(now * 0.5 + Double(i))) * 8 + let phase = (now * 0.05 + Double(i) * 0.11).truncatingRemainder(dividingBy: 1) + let x = CGFloat(sx) * w + sway + let y = CGFloat(phase) * h + let sz = CGFloat(1.4 + sx * 1.6) + ctx.fill(Path(ellipseIn: CGRect(x: x, y: y, width: sz, height: sz)), + with: .color(.white.opacity(0.55))) + } +} diff --git a/Strand/Resources/Assets.xcassets/weather_rain.imageset/Contents.json b/Strand/Resources/Assets.xcassets/weather_rain.imageset/Contents.json new file mode 100644 index 000000000..f8bb007e2 --- /dev/null +++ b/Strand/Resources/Assets.xcassets/weather_rain.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "filename" : "weather_rain.png", "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Strand/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png b/Strand/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png new file mode 100755 index 000000000..5df53cad5 Binary files /dev/null and b/Strand/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png differ diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 34ee3c38d..35ce82287 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -82,6 +82,8 @@ struct SettingsView: View { @AppStorage(SkyBehindCardsPrefs.enabledKey) private var skyBehindCards = false // Card-surface opacity percent (100 = solid). Reactive — moving the slider live-updates every card. @AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent + // PROTOTYPE (#weather): optional weather mood over the day-cycle sky. Default clear = unchanged look. + @AppStorage(LiquidWeather.storageKey) private var weatherRaw = LiquidWeather.clear.rawValue // Hydration tracker (opt-in, MVP). Default OFF — when off the hydration dashboard card + detail are // hidden. Mirrors the Android pref so the toggle reads the same on both platforms. @AppStorage(HydrationStore.enabledKey) private var hydrationEnabled = false @@ -699,6 +701,23 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) + // PROTOTYPE (#weather): an optional weather MOOD over the day-cycle sky — aesthetic only + // (no real conditions/location/network), tinted to the time of day. Menu style so long + // labels never overflow (#43). Needs the day-cycle background on. + rowDivider + FormRow(label: "Weather (experimental)") { + Picker("Weather", selection: $weatherRaw) { + ForEach(LiquidWeather.allCases) { w in + Text(w.label).tag(w.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + .tint(StrandPalette.accent) + .accessibilityLabel("Weather") + } + .disabled(!showDayCycleBackground) + // MARK: Sky behind cards — extend the day-cycle sky behind the WHOLE Today scroll so the // Card-transparency slider reveals it under every card (not just the hero). Opt-in, off by // default; pairs with Card transparency below. diff --git a/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/Contents.json b/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/Contents.json new file mode 100644 index 000000000..f8bb007e2 --- /dev/null +++ b/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "filename" : "weather_rain.png", "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png b/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png new file mode 100755 index 000000000..5df53cad5 Binary files /dev/null and b/StrandiOS/Resources/Assets.xcassets/weather_rain.imageset/weather_rain.png differ diff --git a/android/app/src/main/java/com/noop/ui/LiquidSky.kt b/android/app/src/main/java/com/noop/ui/LiquidSky.kt index 2fc685a78..44c5277ec 100644 --- a/android/app/src/main/java/com/noop/ui/LiquidSky.kt +++ b/android/app/src/main/java/com/noop/ui/LiquidSky.kt @@ -4,9 +4,13 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import android.content.Context +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -15,6 +19,13 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -60,6 +71,49 @@ private fun hx(hex: Long): Color = Color( /** The ten keyframes mirror the real app's day-cycle scenes (SceneHeroBackground), as pure * gradients rather than painted art. Hex values are byte-identical to the iOS source. */ +// MARK: - PROTOTYPE weather (#weather) — mirrors the iOS LiquidWeather +// +// An optional weather MOOD layered over the time-of-day gradient. Aesthetic only — no real conditions, +// location or network (NOOP stays offline). Procedural + tinted by the CURRENT sky, so a cloud at dusk +// reads mauve and at noon white. CLEAR (default) = the unchanged look. `raw` matches the iOS rawValues +// (clear/hazy/overcast/rain/fog/snow) so the persisted value is identical across platforms. + +enum class LiquidWeather(val label: String) { + CLEAR("Clear"), HAZY("Hazy"), OVERCAST("Cloud"), RAIN("Rain"), FOG("Fog"), SNOW("Snow"); + + val raw: String get() = name.lowercase() + + companion object { + const val STORAGE_KEY = "liquid.weather" + fun fromRaw(s: String?): LiquidWeather = entries.firstOrNull { it.raw == s } ?: CLEAR + } +} + +/** Reactive holder so the Settings picker live-updates the sky (mirrors [CardAppearance]). Initialised + * from NoopPrefs at app start via [init]; the Settings picker writes both this and the pref. */ +object LiquidWeatherState { + var mode by mutableStateOf(LiquidWeather.CLEAR) + fun init(context: Context) { mode = NoopPrefs.weather(context) } +} + +/** PROTOTYPE (#weather): resolve the optional `weather_` drawable to an [ImageBitmap], or null when + * the asset isn't in res/drawable (→ procedural fallback). getIdentifier returns 0 for a missing name, so + * dropping the art in later upgrades the look with no code change. Decoded once per mood via remember. */ +@Composable +private fun rememberWeatherImage(weather: LiquidWeather): ImageBitmap? { + if (weather == LiquidWeather.CLEAR) return null + val context = LocalContext.current + return remember(weather) { + @Suppress("DiscouragedApi") + val resId = context.resources.getIdentifier("weather_${weather.raw}", "drawable", context.packageName) + if (resId == 0) return@remember null + // #weather PERF: decode at half res (inSampleSize=2, ~750px). The sky is a soft, stretched wash, so + // full res buys nothing visible but quadruples the texture memory the scrolling cards composite over. + val opts = BitmapFactory.Options().apply { inSampleSize = 2 } + BitmapFactory.decodeResource(context.resources, resId, opts)?.asImageBitmap() + } +} + val liquidSkyKeys: List = listOf( LiquidSkyStop(h = 0.0, top = hx(0x05060f), mid = hx(0x0b0e22), hor = hx(0x1a1440), stars = 1.0, warm = 0.0), LiquidSkyStop(h = 5.0, top = hx(0x0a0d24), mid = hx(0x1c1a4a), hor = hx(0x4a2a6a), stars = 0.6, warm = 0.0), @@ -148,6 +202,11 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.renderLiquidSky( // How fully the sky dissolves into the canvas at the bottom (1 = the default seamless fade to the flat // page; <1 holds the atmosphere so the sky still reads under a full-height "sky behind cards" backdrop). settleStrength: Float = 1f, + // PROTOTYPE (#weather): the weather mood layered over the gradient. CLEAR = no-op. + weather: LiquidWeather = LiquidWeather.CLEAR, + // PROTOTYPE (#weather): optional `weather_` art. When present it's drawn (blended) instead of the + // procedural draw; null → procedural fallback. Resolved at the composable level (rememberWeatherImage). + weatherImage: ImageBitmap? = null, ) { val s = liquidSkyAt(hour) val w = size.width @@ -203,6 +262,28 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.renderLiquidSky( ) } + // PROTOTYPE (#weather): weather sits between the warm wash and the stars. Image ART when present + // (aspect-filled to width, top-aligned, blended so the sky reads through), else the procedural draw. + // `now` is 0 on the static sky, so procedural rain/snow are frozen there but still visible. + if (weather != LiquidWeather.CLEAR && weatherImage != null) { + // Fill the WHOLE sky canvas (stretch), matching the iOS draw — an aspect-fit-to-width draw left a + // short 3:2 image with a hard bottom edge partway down the tall sky band, which read as "not + // stretched" and (split by a floating card) as "doubled". The settle fade below dissolves the lower + // part into the page. Normal (SrcOver): an OPAQUE scene covers the gradient; a TRANSPARENT overlay + // shows the gradient through its alpha. (#weather) + drawImage( + image = weatherImage, + srcOffset = IntOffset.Zero, + srcSize = IntSize(weatherImage.width, weatherImage.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize(w.toInt().coerceAtLeast(1), h.toInt().coerceAtLeast(1)), + alpha = 0.9f, // TUNE per the art + blendMode = BlendMode.SrcOver, + ) + } else { + drawLiquidWeather(weather = weather, s = s, now = now) + } + // Stars. Animated sky twinkles (phase-driven pow(sin,6) flare); static sky draws the base alpha only. if (s.stars > 0.01) { for (star in liquidStars) { @@ -240,6 +321,101 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.renderLiquidSky( ) } +// MARK: - PROTOTYPE weather layers (#weather) — mirrors the iOS drawLiquidWeather +// +// Procedural weather over the gradient, tinted by the current sky [s] so it reads at any hour. Cheap +// DrawScope fills only. Values are a FIRST PASS to tune on-device. No-op when CLEAR. + +private fun DrawScope.drawLiquidWeather(weather: LiquidWeather, s: LiquidSkyResolved, now: Double) { + if (weather == LiquidWeather.CLEAR) return + val w = size.width + val h = size.height + when (weather) { + LiquidWeather.CLEAR -> Unit + LiquidWeather.HAZY -> { + // A soft band of horizon-tinted haze low in the sky. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(s.hor.copy(alpha = 0f), s.hor.copy(alpha = 0.16f), s.hor.copy(alpha = 0.04f)), + startY = h * 0.5f, endY = h, + ), + topLeft = Offset(0f, h * 0.5f), size = Size(w, h * 0.5f), + ) + } + LiquidWeather.OVERCAST -> drawLiquidClouds(tint = s.mid, now = now, coverage = 0.7f) + LiquidWeather.RAIN -> { + drawLiquidClouds(tint = s.mid, now = now, coverage = 0.85f) + drawLiquidRain(now = now) + } + LiquidWeather.FOG -> { + val fog = lerp(s.mid, Color.White, 0.45f) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(fog.copy(alpha = 0f), fog.copy(alpha = 0.34f)), + startY = h * 0.35f, endY = h, + ), + topLeft = Offset(0f, h * 0.35f), size = Size(w, h * 0.65f), + ) + } + LiquidWeather.SNOW -> drawLiquidSnow(now = now) + } +} + +/** A few soft radial "clouds" drifting slowly across, tinted toward the mid-sky so they read at any hour. */ +private fun DrawScope.drawLiquidClouds(tint: Color, now: Double, coverage: Float) { + val cloud = lerp(tint, Color.White, 0.5f) + val w = size.width + val h = size.height + // (x0, y, radiusFrac, driftSpeed) — x0/y in [0,1]; drifts right and wraps. + val blobs = listOf( + doubleArrayOf(0.15, 0.24, 0.30, 0.006), doubleArrayOf(0.55, 0.18, 0.36, 0.004), + doubleArrayOf(0.85, 0.30, 0.26, 0.008), doubleArrayOf(0.38, 0.36, 0.32, 0.005), + ) + for (b in blobs) { + val x = (((b[0] + now * b[3]) % 1.25) - 0.1).toFloat() * w + val y = b[1].toFloat() * h + val rx = b[2].toFloat() * w + drawCircle( + brush = Brush.radialGradient( + colors = listOf(cloud.copy(alpha = coverage * 0.5f), cloud.copy(alpha = 0f)), + center = Offset(x, y), radius = rx, + ), + radius = rx, center = Offset(x, y), + ) + } +} + +/** Short diagonal streaks falling on a loop. */ +private fun DrawScope.drawLiquidRain(now: Double) { + val w = size.width + val h = size.height + for (i in 0 until 70) { + val sx = (i * 0.61803) % 1.0 + val x = sx.toFloat() * w + val phase = (now * 1.1 + i * 0.137) % 1.0 + val y = phase.toFloat() * h + drawLine( + color = Color.White.copy(alpha = 0.10f), + start = Offset(x, y), end = Offset(x - 4f, y + 15f), strokeWidth = 1f, + ) + } +} + +/** Drifting specks that sway as they fall — the starfield feel, brighter and moving. */ +private fun DrawScope.drawLiquidSnow(now: Double) { + val w = size.width + val h = size.height + for (i in 0 until 70) { + val sx = (i * 0.61803) % 1.0 + val sway = (sin(now * 0.5 + i) * 8).toFloat() + val phase = (now * 0.05 + i * 0.11) % 1.0 + val x = sx.toFloat() * w + sway + val y = phase.toFloat() * h + val sz = (1.4 + sx * 1.6).toFloat() + drawCircle(color = Color.White.copy(alpha = 0.55f), radius = sz, center = Offset(x, y)) + } +} + /** The canvas colour the sky dissolves into — the active theme's surfaceBase (dark #121518 / light * #EAE3D4), read live so a theme flip re-resolves it. Replaces the iOS hard-coded settle literals. */ private val liquidSettleColor: Color @@ -260,10 +436,15 @@ fun LiquidSky(hour: Double? = null, modifier: Modifier = Modifier) { val reduced = rememberReduceMotion() val settle = liquidSettleColor val h = hour ?: liquidLiveHour() + val weather = LiquidWeatherState.mode // #weather: reactive so the picker live-updates + val weatherImage = rememberWeatherImage(weather) - if (reduced) { - // No frame loop under Reduce Motion — pose the static picture once. - Canvas(modifier = modifier) { renderLiquidSky(hour = h, now = 0.0, settle = settle, animate = false) } + // #weather PERF: pose the sky ONCE (no per-frame loop) under Reduce Motion OR when a weather IMAGE is + // shown — re-blitting a full-screen bitmap every frame behind a scrolling list stutters, and the image + // is static (it hides the animated breath/stars underneath anyway). Cached in the scaffold's + // graphicsLayer, so scroll composites a texture, not a redraw. + if (reduced || weatherImage != null) { + Canvas(modifier = modifier) { renderLiquidSky(hour = h, now = 0.0, settle = settle, animate = false, weather = weather, weatherImage = weatherImage) } return } @@ -281,7 +462,7 @@ fun LiquidSky(hour: Double? = null, modifier: Modifier = Modifier) { } Canvas(modifier = modifier) { - renderLiquidSky(hour = h, now = seconds, settle = settle, animate = true) + renderLiquidSky(hour = h, now = seconds, settle = settle, animate = true, weather = weather, weatherImage = weatherImage) } } @@ -299,8 +480,10 @@ fun LiquidSky(hour: Double? = null, modifier: Modifier = Modifier) { fun LiquidSkyStatic(hour: Double? = null, modifier: Modifier = Modifier, settleStrength: Float = 1f) { val settle = liquidSettleColor val h = hour ?: liquidLiveHour() + val weather = LiquidWeatherState.mode // #weather + val weatherImage = rememberWeatherImage(weather) Canvas(modifier = modifier) { - renderLiquidSky(hour = h, now = 0.0, settle = settle, animate = false, settleStrength = settleStrength) + renderLiquidSky(hour = h, now = 0.0, settle = settle, animate = false, settleStrength = settleStrength, weather = weather, weatherImage = weatherImage) } } diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index 8a56d0f3b..9de6f9a6c 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -50,6 +50,8 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) // Load the saved "Card transparency" so every frosted card renders at the chosen opacity from launch. CardAppearance.init(this) + // PROTOTYPE (#weather): load the saved weather mood so the day-cycle sky shows it from launch. + LiquidWeatherState.init(this) // Demo build only: preload a full synthetic dataset so every screen is populated // out of the box (no strap, no import). No-op once seeded; never runs on the full app. @@ -477,6 +479,15 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_SHOW_DAY_CYCLE_BACKGROUND, enabled).apply() } + /** PROTOTYPE (#weather): the day-cycle sky's weather mood (default clear). Stored as the shared raw + * string so it matches the iOS value. */ + fun weather(context: Context): LiquidWeather = + LiquidWeather.fromRaw(of(context).getString(LiquidWeather.STORAGE_KEY, null)) + + fun setWeather(context: Context, mode: LiquidWeather) { + of(context).edit().putString(LiquidWeather.STORAGE_KEY, mode.raw).apply() + } + /** Card-surface opacity as a PERCENT (0 = fully see-through, 100 = solid; default 100). Drives the * "Card transparency" setting — every frosted card (Heart Rate, Key Metrics, Recovery Vitals, …) * reads it via [CardAppearance]. Only the glass surface fades; the card content stays readable. */ diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index aac614b6f..c3346ce93 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -959,6 +959,27 @@ fun SettingsScreen( ) } + // PROTOTYPE (#weather): an optional weather MOOD over the day-cycle sky — aesthetic only (no + // real conditions/location/network), tinted to the time of day. Own row (label above, pills + // below full-width) so the six options don't crowd a side-by-side label. Needs day-cycle on. + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space8)) { + Text( + "Weather (experimental)", + style = NoopType.subhead, + color = if (showDayCycleBackground) Palette.textPrimary else Palette.textTertiary, + ) + SegmentedPillControl( + items = LiquidWeather.entries.toList(), + selection = LiquidWeatherState.mode, + label = { it.label }, + enabled = { showDayCycleBackground }, + onSelect = { mode -> + LiquidWeatherState.mode = mode // live-updates the sky app-wide + NoopPrefs.setWeather(context, mode) + }, + ) + } + // Sky behind cards (opt-in): extend the day-cycle sky behind the WHOLE Today scroll so the Card // transparency slider reveals it under every card, not just the hero. Off = the sky stays a top // band and lower cards fade toward the flat canvas. Needs the day-cycle background to be on. diff --git a/android/app/src/main/res/drawable-nodpi/weather_rain.png b/android/app/src/main/res/drawable-nodpi/weather_rain.png new file mode 100755 index 000000000..5df53cad5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/weather_rain.png differ diff --git a/docs/WEATHER_ASSETS.md b/docs/WEATHER_ASSETS.md new file mode 100644 index 000000000..33f5a6560 --- /dev/null +++ b/docs/WEATHER_ASSETS.md @@ -0,0 +1,46 @@ +# Weather overlay art (PROTOTYPE, #weather) + +The day-cycle sky (`LiquidSky`) can draw an **image** for each weather mood, blended over the time-of-day +gradient. When the image for a mood is **absent, the sky falls back to the procedural draw** — so the app +works with zero art, and dropping a file in upgrades that mood with no code change. + +## Asset names (one PNG per mood; all optional) + +| Mood | Asset base name | +|----------|---------------------| +| Hazy | `weather_hazy` | +| Overcast | `weather_overcast` | +| Rain | `weather_rain` | +| Fog | `weather_fog` | +| Snow | `weather_snow` | + +(`clear` has no art by design.) + +## Where the files go + +- **iOS / macOS:** add each as an image set in `Strand/Resources/Assets.xcassets/` (and + `StrandiOS/Resources/Assets.xcassets/`), named exactly `weather_rain`, etc. Loaded via `UIImage(named:)` / + `NSImage(named:)`. +- **Android:** drop each PNG/WebP in `android/app/src/main/res/drawable-nodpi/` as `weather_rain.png` (etc.). + Resolved by name at runtime (`getIdentifier`), so no `R.drawable` reference is needed. + +## Art guidance + +- **Transparent PNG**, wide/landscape (the image is aspect-filled to the screen width, top-aligned), sky + content toward the top. The lower part fades into the page, so weather near the top reads best. +- Designed to **blend** over a coloured gradient (drawn with `.screen` at ~0.85 opacity by default), so the + sky still shows through and tints it. Light, semi-transparent cloud/precip art works best; avoid a baked-in + opaque sky colour (that would fight the gradient). +- The blend mode + opacity are the on-device tuning knobs — see the `TUNE:` comments in `LiquidSky.swift` / + `LiquidSky.kt`. + +## Licensing — REQUIRED before committing any art + +Unlike the inherited `scene1–10` backdrops (which carry no recorded source), **every weather image added here +must have its license recorded** — add a line per file below with the source and license. Only commit art you +have the right to ship (self-generated, commissioned, or a clearly-permissive/CC licence with attribution as +required). + +| File | Source | License | +|------|--------|---------| +| `weather_rain.png` (1500×1000) | ⚠️ **TO CONFIRM** — supplied for the prototype; provenance not yet recorded | ⚠️ **TO CONFIRM before this ships/merges** |