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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Appearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public enum SceneBackgroundPrefs {
public static let enabledKey = "noop.showDayCycleBackground"
}

/// Card-surface opacity as a PERCENT (0 = fully see-through, 100 = solid; default 100). `FrostedCardSurface`
/// reads it via `@AppStorage(CardAppearancePrefs.opacityKey)` and fades the whole glass by it, so cards
/// (Heart Rate, Key Metrics, Recovery Vitals, …) can be made see-through from Settings → Appearance; the
/// card content stays fully readable. Mirror in Kotlin via `NoopPrefs.cardOpacityPercent`.
public enum CardAppearancePrefs {
public static let opacityKey = "noop.cardOpacityPercent"
public static let defaultPercent = 100
}

// MARK: - Light-idiom helpers

/// An additive glow (ring blooms, sparkline heads, hero halos) only reads on a DARK canvas —
Expand Down
7 changes: 7 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public struct FrostedCardSurface: View {
public var cornerRadius: CGFloat
public var washStrength: Double
@Environment(\.colorScheme) private var scheme
// "Card transparency" setting (reactive): fades the whole glass surface toward the background. 100 =
// solid (default). Reading it here makes every card update live when the Settings slider moves.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent

public init(tint: Color? = nil, cornerRadius: CGFloat = 22, washStrength: Double = 1.0) {
self.tint = tint
Expand All @@ -39,6 +42,7 @@ public struct FrostedCardSurface: View {

public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
let op = max(0.0, min(1.0, Double(cardOpacityPercent) / 100.0))
// Base fill: tinted cards deepen into the 150° navy bevel (#15243C → #0B1424,
// = surfaceOverlay → cardFillBottom); neutral cards sit on the flat raised
// surface. The 150° axis ≈ top-trailing → bottom-leading.
Expand Down Expand Up @@ -71,6 +75,9 @@ public struct FrostedCardSurface: View {
radius: scheme == .light ? 10 : 0,
x: 0, y: scheme == .light ? 3 : 0
)
// "Card transparency": fade the whole glass surface. The card's content sits above this
// background, so it stays fully readable regardless.
.opacity(op)
}
}

Expand Down
10 changes: 10 additions & 0 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ struct LiquidTodayView: View {
private let liquidHeart = Color(.sRGB, red: 1, green: 107 / 255, blue: 129 / 255, opacity: 1)
/// Hero card fill: a translucent near-black so it floats over the sky (mock rgba(13,14,20,.78)).
private let heroFill = Color(.sRGB, red: 13 / 255, green: 14 / 255, blue: 20 / 255, opacity: 0.80)
/// "Card transparency" (0–100, default 100): fades every liquid card surface here — the hero, the
/// session-start row, the metric tiles and the `card` helper — in lockstep with the frosted cards.
/// Content sits above the surface so it stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

// MARK: - Day navigation (ported from classic Today: swipe + calendar, day-keyed reads)

Expand Down Expand Up @@ -391,6 +396,7 @@ struct LiquidTodayView: View {
.fill(heroFill)
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.11), lineWidth: 1))
.opacity(cardOpacity)
)
}
.buttonStyle(LiquidPressStyle())
Expand All @@ -414,6 +420,7 @@ struct LiquidTodayView: View {
.overlay(RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.11), lineWidth: 1))
.shadow(color: .black.opacity(0.6), radius: 30, y: 16)
.opacity(cardOpacity)
)
}

Expand Down Expand Up @@ -560,6 +567,7 @@ struct LiquidTodayView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}
.buttonStyle(LiquidPressStyle())
Expand Down Expand Up @@ -703,6 +711,7 @@ struct LiquidTodayView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down Expand Up @@ -790,6 +799,7 @@ struct LiquidTodayView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down
6 changes: 6 additions & 0 deletions Strand/Liquid/LiveSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ struct LiveSessionView: View {
@EnvironmentObject private var profile: ProfileStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion

/// "Card transparency" (0–100, default 100): fades the live-session cards in lockstep with the frosted
/// cards; content stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

/// One runner per presentation — created here, started on appear, never restarted.
@StateObject private var runner = LiveSessionRunner()
let onClose: () -> Void
Expand Down Expand Up @@ -368,6 +373,7 @@ struct LiveSessionSummarySheet: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down
6 changes: 6 additions & 0 deletions Strand/Screens/CoupledView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import Foundation
struct CoupledView: View {
@EnvironmentObject var repo: Repository

/// "Card transparency" (0–100, default 100): fades the coupled glance cards in lockstep with the
/// frosted cards; content stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

// Effort is stored 0–100; the coupled read is always the 0–21 Day-Strain axis regardless of the user's
// #268 display toggle, so the gauge reads like the classic coupled home. Display-only conversion.
private let strainScale: EffortScale = .whoop
Expand Down Expand Up @@ -639,6 +644,7 @@ struct CoupledView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down
6 changes: 6 additions & 0 deletions Strand/Screens/HydrationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ struct HydrationView: View {
@AppStorage(HydrationStore.customSizeKey) private var customSizeML = HydrationGoal.cupML
@State private var showCustomSizeSheet = false

/// "Card transparency" (0–100, default 100): fades the hydration cards in lockstep with the frosted
/// cards; content stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

private var goalML: Int { repo.hydrationGoalML(profileSex: profile.sex) }
private var fraction: Double { HydrationGoal.fraction(totalML: totalML, goalML: goalML) }
private var percent: Int { min(100, Int((fraction * 100).rounded(.towardZero))) }
Expand Down Expand Up @@ -129,6 +134,7 @@ struct HydrationView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down
8 changes: 8 additions & 0 deletions Strand/Screens/LiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ struct LiveView: View {
@AppStorage("selectedWhoopModel") private var selectedModelRaw = WhoopModel.whoop4.rawValue
private var selectedModel: WhoopModel { WhoopModel(rawValue: selectedModelRaw) ?? .whoop4 }

/// "Card transparency" (0–100, default 100): fades the live console cards in lockstep with the frosted
/// cards; content stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

/// Maps the picked strap model to the HRV-reading source so the spot caveat is honest (#537): a
/// WHOOP 5/MG's R-R is optical PPG (noisier), a WHOOP 4 is electrical R-R. Mirrors the Android
/// `LiveScreen` mapping.
Expand Down Expand Up @@ -148,6 +153,7 @@ struct LiveView: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down Expand Up @@ -1112,6 +1118,7 @@ private struct LiveLogCard: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
}

Expand Down Expand Up @@ -1195,6 +1202,7 @@ private struct SignalTrustTile: View {
.fill(StrandPalette.surfaceRaised)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
.opacity(cardOpacity)
)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(tile.title): \(tile.value). \(tile.detail)")
Expand Down
28 changes: 28 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ struct SettingsView: View {
// Day-cycle scene backdrop behind Today (#698). Default ON. Off swaps the scene for a plain dark
// canvas. TodayView reads the same key to gate its SceneScreenBackground.
@AppStorage(SceneBackgroundPrefs.enabledKey) private var showDayCycleBackground = true
// Card-surface opacity percent (100 = solid). Reactive — moving the slider live-updates every card.
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
// 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 @@ -689,6 +691,32 @@ struct SettingsView: View {
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)

// MARK: Card transparency — fade every frosted card's glass toward the background. Reactive
// @AppStorage, so all cards (incl. the ones on this screen) update live as you drag. The
// slider shows TRANSPARENCY (0 = solid, 100 = clear); we store the OPACITY percent.
HStack {
Text("Card transparency")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
Spacer()
Text("\(100 - cardOpacityPercent)%")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.accent)
}
Slider(
value: Binding(
get: { Double(100 - cardOpacityPercent) },
set: { cardOpacityPercent = 100 - Int($0.rounded()) }
),
in: 0...100, step: 1
)
.tint(StrandPalette.accent)
Text("How see-through the cards (Heart Rate, Key Metrics, Recovery Vitals, …) are. Left = solid, right = clear.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/java/com/noop/ui/BreatheScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,8 @@ fun BreatheScreen(viewModel: AppViewModel) {
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(LIQUID_HERO_RADIUS))
.background(LIQUID_HERO_FILL)
.border(1.dp, Color.White.copy(alpha = 0.11f), RoundedCornerShape(LIQUID_HERO_RADIUS))
.background(LIQUID_HERO_FILL.copy(alpha = LIQUID_HERO_FILL.alpha * CardAppearance.opacity))
.border(1.dp, Color.White.copy(alpha = 0.11f * CardAppearance.opacity), RoundedCornerShape(LIQUID_HERO_RADIUS))
.padding(24.dp),
) {
Column(
Expand Down
111 changes: 59 additions & 52 deletions android/app/src/main/java/com/noop/ui/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import android.content.Context
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
Expand Down Expand Up @@ -96,63 +98,68 @@ import kotlin.math.sin
* near-neutral gold wash. Drawn with `drawBehind` so the animation/recomposition of the card's
* content never reaches this surface subtree. Mirrors StrandDesign's FrostedCardSurface.
*/
/** App-wide card-surface opacity (0f = fully see-through, 1f = solid), driven by the "Card transparency"
* setting. Reactive (a mutableState) so the Settings slider live-previews; initialised from NoopPrefs at
* app start via [init]. Only the card SURFACE (fill + border + wash) fades — the card's CONTENT is drawn
* above and stays fully readable over the background. */
object CardAppearance {
var opacity by mutableStateOf(1f)
fun init(context: Context) {
opacity = (NoopPrefs.cardOpacityPercent(context) / 100f).coerceIn(0f, 1f)
}
}

fun Modifier.frostedCardSurface(
tint: Color? = null,
cornerRadius: Dp = Metrics.cardRadius,
washStrength: Float = 1f,
): Modifier = this
// Elevation idiom: DARK is flat (the hairline + hue carry the edge). LIGHT raises the white card
// off the warm-paper canvas with a soft drop shadow — the hairline alone is too faint on paper.
.then(
if (Palette.isLight)
Modifier.shadow(elevation = 6.dp, shape = RoundedCornerShape(cornerRadius), clip = false)
else Modifier
)
.drawBehind {
val radiusPx = cornerRadius.toPx()
val corner = androidx.compose.ui.geometry.CornerRadius(radiusPx, radiusPx)

if (tint == null) {
// NEUTRAL card (iOS FrostedCardSurface tint == nil): a FLAT raised surface — no vertical
// bevel gradient, no accent wash, and a PLAIN hairline border (no accent bias).
drawRoundRect(
color = Palette.surfaceRaised,
cornerRadius = corner,
)
drawRoundRect(
color = Palette.hairline,
cornerRadius = corner,
style = Stroke(width = 1.dp.toPx()),
)
} else {
// TINTED card (iOS parity, 2026-06-23 "synthesis has the old blue style"): a FLAT raised
// surface — the SAME WHOOP grey as the neutral card, NO navy bevel gradient — carrying only a
// whisper of the domain tint as a diagonal hue wash so it stays in the grey family.
// 1) Flat raised fill — identical to the neutral card.
drawRoundRect(
color = Palette.surfaceRaised,
cornerRadius = corner,
)
// 2) Faint diagonal accent hue wash over the flat fill (matches iOS FrostedCardSurface ~0.05).
drawRoundRect(
brush = Brush.linearGradient(
colorStops = arrayOf(
0.0f to tint.copy(alpha = 0.05f * washStrength),
0.5f to tint.copy(alpha = 0.015f * washStrength),
1.0f to Color.Transparent,
),
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
),
cornerRadius = corner,
): Modifier = composed {
// "Card transparency" setting: scale the whole glass surface (fill + border + wash) by the user's
// opacity so cards fade toward the background. Reading the reactive value here makes the slider
// live-preview. Content drawn above the surface is unaffected, so numbers/labels stay readable.
val op = CardAppearance.opacity
this
// Elevation idiom: DARK is flat (the hairline + hue carry the edge). LIGHT raises the white card
// off the warm-paper canvas with a soft drop shadow — the hairline alone is too faint on paper.
.then(
if (Palette.isLight)
Modifier.shadow(elevation = (6f * op).dp, shape = RoundedCornerShape(cornerRadius), clip = false)
else Modifier
)
// 3) Plain 1px hairline (no accent bias) — matches the neutral card.
drawRoundRect(
color = Palette.hairline,
cornerRadius = corner,
style = Stroke(width = 1.dp.toPx()),
)
}
.drawBehind {
val radiusPx = cornerRadius.toPx()
val corner = androidx.compose.ui.geometry.CornerRadius(radiusPx, radiusPx)
val fill = Palette.surfaceRaised.copy(alpha = Palette.surfaceRaised.alpha * op)
val border = Palette.hairline.copy(alpha = Palette.hairline.alpha * op)

if (tint == null) {
// NEUTRAL card (iOS FrostedCardSurface tint == nil): a FLAT raised surface — no vertical
// bevel gradient, no accent wash, and a PLAIN hairline border (no accent bias).
drawRoundRect(color = fill, cornerRadius = corner)
drawRoundRect(color = border, cornerRadius = corner, style = Stroke(width = 1.dp.toPx()))
} else {
// TINTED card (iOS parity, 2026-06-23 "synthesis has the old blue style"): a FLAT raised
// surface — the SAME WHOOP grey as the neutral card, NO navy bevel gradient — carrying only
// a whisper of the domain tint as a diagonal hue wash so it stays in the grey family.
// 1) Flat raised fill — identical to the neutral card.
drawRoundRect(color = fill, cornerRadius = corner)
// 2) Faint diagonal accent hue wash over the flat fill (matches iOS FrostedCardSurface ~0.05).
drawRoundRect(
brush = Brush.linearGradient(
colorStops = arrayOf(
0.0f to tint.copy(alpha = 0.05f * washStrength * op),
0.5f to tint.copy(alpha = 0.015f * washStrength * op),
1.0f to Color.Transparent,
),
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
),
cornerRadius = corner,
)
// 3) Plain 1px hairline (no accent bias) — matches the neutral card.
drawRoundRect(color = border, cornerRadius = corner, style = Stroke(width = 1.dp.toPx()))
}
}
}

// MARK: - NoopCard — the one card surface (Titanium & Gold frosted card, 16dp radius)
Expand Down
Loading
Loading