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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 171 additions & 12 deletions Strand/Liquid/LiquidSky.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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_<mood>` 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
Expand All @@ -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] = [
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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_<mood>` 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)))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"images" : [ { "filename" : "weather_rain.png", "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"images" : [ { "filename" : "weather_rain.png", "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading