diff --git a/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift b/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift index 7875e1fe56..c964e46217 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift @@ -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 @@ -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. } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift index 4c2d8205ff..0d41a64925 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift @@ -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 @@ -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 } diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index c98e651c5d..feff580b4d 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -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. @@ -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, @@ -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 @@ -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 @@ -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 @@ -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 + @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 {