Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ private enum SyncRing {
static let ellipsisGlyphSize: CGFloat = 9
static let dimGlyphOpacity: Double = 0.5

/// The transient "Syncing" label's tone.
/// The "Syncing" label's tone.
static let labelOpacity: Double = 0.92

/// Stroke weight `eased` of the way from the battery ring to the spinner. The entry passes its own
Expand Down Expand Up @@ -428,20 +428,8 @@ public struct ChargeSyncIndicator: View {
showsLabel = true
}

try? await Task.sleep(
nanoseconds: StrandMotion.syncIndicatorLabelVisibilityNanoseconds
)
guard !Task.isCancelled else { return }
animate(poseStill ? nil : StrandMotion.syncIndicatorLabelOut) {
showsLabel = false
}
try? await Task.sleep(
nanoseconds: StrandMotion.syncIndicatorCollapseDelayNanoseconds
)
guard !Task.isCancelled else { return }
animate(poseStill ? nil : StrandMotion.syncIndicatorMorph) {
pillProgress = 0
}
// Keep the label and capsule stable until endSync; collapsing during an active sync
// shifts the neighbouring header controls while the strap is still handing over data.
}
}

Expand Down
9 changes: 3 additions & 6 deletions Packages/StrandDesign/Sources/StrandDesign/Motion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,8 @@ public enum StrandMotion {
/// distinct mid-sweep.
static let syncIndicatorTintBandTurns: Double = 0.10

/// Transient label fades after the capsule has settled.
/// Sync label fades after the capsule has settled.
static let syncIndicatorLabelIn = Animation.easeOut(duration: 0.26)
static let syncIndicatorLabelOut = Animation.easeInOut(duration: 0.26)

static let syncIndicatorFrameInterval = 1.0 / 60.0
static let syncIndicatorSpinPeriod = 1.25
Expand All @@ -123,13 +122,11 @@ public enum StrandMotion {

/// Grace period the SCREEN applies to the falling edge of the sync signal before it tells the
/// indicator to wind down. Public because the raw signal is the screen's to own: `backfilling` drops
/// between history chunks, and without this the control would flap back to the battery reading in the
/// middle of one logical sync.
/// between automatically continued offload sessions, and otherwise the control would flap back to
/// the battery reading in the middle of one logical sync.
public static let syncIndicatorSignalDebounceNanoseconds: UInt64 = 3_000_000_000

static let syncIndicatorLabelDelayNanoseconds: UInt64 = 650_000_000
static let syncIndicatorLabelVisibilityNanoseconds: UInt64 = 1_900_000_000
static let syncIndicatorCollapseDelayNanoseconds: UInt64 = 240_000_000
/// Steady spinner rate in degrees per second. Derived from the spin period rather than written out,
/// so the wind-down cannot drift out of step with the spin it has to hand off from.
static var syncIndicatorSpinRateDegrees: Double { 360 / syncIndicatorSpinPeriod }
Expand Down
231 changes: 127 additions & 104 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,6 @@ struct LiquidTodayView: View {
@State private var pullHaptic = 0
private let pullThreshold: CGFloat = 80

/// Measured width of the trailing header-control cluster, feeding the day title's fade mask. Seeded
/// with the design-system default so the first frame is not laid out against a reserve of zero.
@State private var headerControlsWidth = NoopMetrics.headerControlReserveWidth

/// Mock Vitality purple (#9b7bff) has no exact StrandPalette token in this theme.
private let liquidPurple = Color(.sRGB, red: 0x9b / 255, green: 0x7b / 255, blue: 0xff / 255, opacity: 1)
/// The liquid heart pink shared with the sync indicator and LiquidThread.
Expand Down Expand Up @@ -343,7 +339,8 @@ struct LiquidTodayView: View {

liquidRefreshIndicator // grows in the revealed space; a vessel filling with the pull

VStack(alignment: .leading, spacing: 12) {
// Materialise charts below the viewport only when they approach the visible area.
LazyVStack(alignment: .leading, spacing: 12) {
scene
// The strain/illness early-warning banner, dropped in the liquid Home rewrite. Liquid is
// the DEFAULT Today on both platforms (RootTabView.swift's liquidTodayEnabled = true,
Expand Down Expand Up @@ -565,99 +562,14 @@ struct LiquidTodayView: View {
// MARK: - Scene (sky title + controls + hero)

private var scene: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topTrailing) {
Button { showDayPicker = true } label: {
VStack(alignment: .leading, spacing: 2) {
Text(dayTitle)
.font(StrandFont.rounded(28))
.foregroundStyle(StrandPalette.textPrimary)
.shadow(color: .black.opacity(0.4), radius: 10, y: 1)
Text(dateLine)
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textSecondary)
.shadow(color: .black.opacity(0.35), radius: 8, y: 1)
}
.contentShape(Rectangle())
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.accessibilityLabel("\(dayTitle). Tap to pick a day, swipe to change day.")
.popover(isPresented: $showDayPicker) {
DatePicker("", selection: dayPickerBinding, in: ...Repository.logicalDay(Date()),
displayedComponents: [.date])
.datePickerStyle(.graphical)
.labelsHidden()
.padding(12)
.frame(minWidth: 320, minHeight: 360)
.liquidPopoverAdaptation()
}
// Long names fade beneath the trailing controls while an expanded transient control
// participates in layout and pushes its preceding siblings left. The reserve is the
// cluster's MEASURED width, not a constant: a constant is only ever right for the exact
// set of controls it was written against, and this row has already gained one (Customize,
// #1207) since. Measuring also means the fade tracks the sync capsule as it expands,
// which is the push-left behaviour rather than a separate approximation of it.
.headerTrailingControlFadeMask(reserving: headerControlsWidth)
HStack(spacing: headerClusterSpacing) {
// Profile pic (the one set in Settings) → opens Settings, matching the classic Today.
Button { showSettings = true } label: {
Color.clear.frame(
width: NoopMetrics.compactControlSize,
height: NoopMetrics.compactControlSize
)
}
.nativeLiquidGlassHeaderButton()
.overlay {
GeometryReader { proxy in
let diameter = min(proxy.size.width, proxy.size.height)
ProfileAvatarView(imageData: profile.avatarImageData, size: diameter)
.frame(width: diameter, height: diameter)
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
}
.allowsHitTesting(false)
}
.nativeLiquidGlassPhotoFinish()
.accessibilityLabel("Profile and settings")
LiquidAddButton()
LiquidBatteryButton()
// One entry point for section order/visibility and both nested card editors.
Button { customizationDestination = .today } label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(StrandPalette.textPrimary)
.frame(
width: NoopMetrics.compactControlSize,
height: NoopMetrics.compactControlSize
)
}
.nativeLiquidGlassHeaderButton()
.accessibilityLabel("Customize Today")
}
.background(
GeometryReader { proxy in
Color.clear.preference(
key: HeaderControlsWidthKey.self,
value: proxy.size.width
)
}
)
.zIndex(1)
}
.onPreferenceChange(HeaderControlsWidthKey.self) { measured in
// Ignore sub-point churn so a rounding wobble cannot re-render the mask every frame.
guard measured > 0, abs(measured - headerControlsWidth) > 0.5 else { return }
headerControlsWidth = measured
}
// Subtle NOOP wordmark in the sky between header and hero. Perfectly centred (a letter row has
// no trailing tracking gap the way `Text(...).tracking()` does), with a tap easter egg.
// #today-layout: the hero + Start-session row moved OUT of the scene into the reorderable
// section block below. The wordmark's bottom pad (10) + the section VStack's 12 spacing keeps
// the default hero-under-wordmark gap at the original 22.
LiquidWordmark()
.padding(.top, 30)
.padding(.bottom, 10)
}
LiquidTodayHeader(
dayTitle: dayTitle,
dateLine: dateLine,
dayPickerBinding: dayPickerBinding,
showDayPicker: $showDayPicker,
showSettings: $showSettings,
customizationDestination: $customizationDestination
)
}

/// One-tap Live Session start (silent guardian, beta) — sits directly under the hero scores, the
Expand Down Expand Up @@ -2354,11 +2266,10 @@ private struct LiquidRefreshIndicator: View {

/// The ONE debounce for the raw "a sync is happening" signal, for any surface that reflects it.
///
/// `live.backfilling` toggles false→true between EVERY offload chunk (`exitBackfilling` at each
/// HISTORY_END → auto-continue re-kick → `beginBackfill`), with a real BLE round-trip gap in between, and
/// a deep backlog is up to ~24 chunks in ONE connection (#594 raised the auto-continue cap 6→24). Bound
/// straight to that signal, an indicator strobes in and out on every chunk boundary. (The MenuBar header
/// pins a constant height for the same reason — see MenuBarContent.)
/// `live.backfilling` drops after HISTORY_COMPLETE (or a timeout), before an automatic continuation
/// starts another offload. Individual HISTORY_END chunks keep the session active. A deep backlog can
/// need several completed slices in one connection; binding directly to the raw signal can flash the
/// indicator between them. The MenuBar header pins a constant height for the same reason.
///
/// Rises INSTANTLY, and falls only after riding out `syncIndicatorSignalDebounceNanoseconds` with no new
/// chunk. Written once on purpose: this existed as two hand-rolled copies with the delay spelled two
Expand All @@ -2383,7 +2294,7 @@ private struct DebouncedSyncSignal: ViewModifier {
return
}
guard debounced else { return }
// Might just be the gap between two chunks — wait it out; a new chunk cancels this.
// An automatic continuation may follow this completed slice; a new sync cancels the delay.
hideTask = Task { @MainActor in
try? await Task.sleep(
nanoseconds: StrandMotion.syncIndicatorSignalDebounceNanoseconds
Expand All @@ -2401,6 +2312,118 @@ private extension View {
}
}

/// Keeps animated header-width measurements local so sync transitions do not rebuild Today's charts.
private struct LiquidTodayHeader: View {
@EnvironmentObject private var profile: ProfileStore
let dayTitle: String
let dateLine: String
let dayPickerBinding: Binding<Date>
@Binding var showDayPicker: Bool
@Binding var showSettings: Bool
@Binding var customizationDestination: TodayCustomizationDestination?

/// Measured width of the trailing header-control cluster, feeding the day title's fade mask. Seeded
/// with the design-system default so the first frame is not laid out against a reserve of zero.
@State private var headerControlsWidth = NoopMetrics.headerControlReserveWidth

var body: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topTrailing) {
Button { showDayPicker = true } label: {
VStack(alignment: .leading, spacing: 2) {
Text(dayTitle)
.font(StrandFont.rounded(28))
.foregroundStyle(StrandPalette.textPrimary)
.shadow(color: .black.opacity(0.4), radius: 10, y: 1)
Text(dateLine)
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textSecondary)
.shadow(color: .black.opacity(0.35), radius: 8, y: 1)
}
.contentShape(Rectangle())
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.accessibilityLabel("\(dayTitle). Tap to pick a day, swipe to change day.")
.popover(isPresented: $showDayPicker) {
DatePicker("", selection: dayPickerBinding, in: ...Repository.logicalDay(Date()),
displayedComponents: [.date])
.datePickerStyle(.graphical)
.labelsHidden()
.padding(12)
.frame(minWidth: 320, minHeight: 360)
.liquidPopoverAdaptation()
}
// Long names fade beneath the trailing controls while an expanded transient control
// participates in layout and pushes its preceding siblings left. The reserve is the
// cluster's MEASURED width, not a constant: a constant is only ever right for the exact
// set of controls it was written against, and this row has already gained one (Customize,
// #1207) since. Measuring also means the fade tracks the sync capsule as it expands,
// which is the push-left behaviour rather than a separate approximation of it.
.headerTrailingControlFadeMask(reserving: headerControlsWidth)
HStack(spacing: headerClusterSpacing) {
// Profile pic (the one set in Settings) → opens Settings, matching the classic Today.
Button { showSettings = true } label: {
Color.clear.frame(
width: NoopMetrics.compactControlSize,
height: NoopMetrics.compactControlSize
)
}
.nativeLiquidGlassHeaderButton()
.overlay {
GeometryReader { proxy in
let diameter = min(proxy.size.width, proxy.size.height)
ProfileAvatarView(imageData: profile.avatarImageData, size: diameter)
.frame(width: diameter, height: diameter)
.position(x: proxy.size.width / 2, y: proxy.size.height / 2)
}
.allowsHitTesting(false)
}
.nativeLiquidGlassPhotoFinish()
.accessibilityLabel("Profile and settings")
LiquidAddButton()
LiquidBatteryButton()
// One entry point for section order/visibility and both nested card editors.
Button { customizationDestination = .today } label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(StrandPalette.textPrimary)
.frame(
width: NoopMetrics.compactControlSize,
height: NoopMetrics.compactControlSize
)
}
.nativeLiquidGlassHeaderButton()
.accessibilityLabel("Customize Today")
}
.background(
GeometryReader { proxy in
Color.clear.preference(
key: HeaderControlsWidthKey.self,
value: proxy.size.width
)
}
)
.zIndex(1)
}
.onPreferenceChange(HeaderControlsWidthKey.self) { measured in
// Ignore sub-point churn so a rounding wobble cannot re-render the mask every frame.
guard measured > 0, abs(measured - headerControlsWidth) > 0.5 else { return }
headerControlsWidth = measured
}
// Subtle NOOP wordmark in the sky between header and hero. Perfectly centred (a letter row has
// no trailing tracking gap the way `Text(...).tracking()` does), with a tap easter egg.
// #today-layout: the hero + Start-session row moved OUT of the scene into the reorderable
// section block below. The wordmark's bottom pad (10) + the section VStack's 12 spacing keeps
// the default hero-under-wordmark gap at the original 22.
LiquidWordmark()
.padding(.top, 30)
.padding(.bottom, 10)
}
}

}

/// Carries the trailing header cluster's measured width out to the day title's fade mask, so the reserve
/// is whatever the controls actually occupy — including the sync capsule mid-expansion.
private struct HeaderControlsWidthKey: PreferenceKey {
Expand Down