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 7179ee9ba7..3d52fa3fc5 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. @@ -565,99 +561,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 +2265,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 @@ -2379,11 +2289,11 @@ private struct DebouncedSyncSignal: ViewModifier { private func apply(_ raw: Bool) { hideTask?.cancel() guard !raw else { - debounced = true // a sync is active — show at once + if !debounced { debounced = true } // a sync is active — show at once 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 @@ -2394,13 +2304,125 @@ private struct DebouncedSyncSignal: ViewModifier { } } -private extension View { +extension View { /// Drive `debounced` from the raw sync signal through the shared debounce above. func debouncedSyncSignal(_ raw: Bool, into debounced: Binding) -> some View { modifier(DebouncedSyncSignal(raw: raw, debounced: debounced)) } } +/// 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 { @@ -3097,29 +3119,30 @@ private extension View { /// card as the detailed view; `LiquidBatteryButton` above is the header's ambient at-a-glance signal. private struct LiquidSyncStatusRow: View { @EnvironmentObject var live: LiveState + @State private var syncing = false var body: some View { - if live.backfilling { - row(String(localized: "Strap history"), value: chunks, tone: StrandPalette.accent) - } else if let ts = live.lastSyncedAt { - row(String(localized: "Strap history"), - value: String(localized: "Synced \(relativeAgo(ts))"), tone: StrandPalette.textPrimary) + Group { + if syncing { + row(String(localized: "Strap history"), value: chunks, tone: StrandPalette.accent) + } else if let ts = live.lastSyncedAt { + row(String(localized: "Strap history"), + value: String(localized: "Synced \(relativeAgo(ts))"), tone: StrandPalette.textPrimary) + } else { + row(String(localized: "Strap history"), value: String(localized: "Not synced yet"), tone: StrandPalette.textTertiary) + } } + .debouncedSyncSignal(live.backfilling, into: $syncing) } - /// "Syncing…" alone reads as a spinner that might be stuck; the chunk count is the cheapest available - /// proof that the drain is actually moving. Suppressed at zero — a session that has pulled nothing yet - /// should not claim "0 chunks pulled" as if that were progress. private var chunks: String { - live.syncChunksThisSession > 0 - ? String(localized: "Syncing… \(live.syncChunksThisSession) chunks") - : String(localized: "Syncing…") + SyncActivityCopy.syncing(chunks: live.syncChunksThisSession, pagesBehind: nil).status } private func row(_ label: String, value: String, tone: Color) -> some View { HStack { Text(label).font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) Spacer() - Text(value).font(StrandFont.subhead).foregroundStyle(tone) + Text(value).font(StrandFont.subhead).foregroundStyle(tone).lineLimit(1) } .accessibilityElement(children: .combine) } diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 4935abfd9c..98c95e2bd4 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -303,10 +303,8 @@ struct TodayView: View { // the deferred set immediately (belt-and-braces alongside the coalesced refreshSeq bump). A bare boolean // that flips ~twice per offload, so it costs nothing like the per-tick chunk count would. @State private var liveBackfillingFlag = false - // #1164: mirror of `LiveState.historyPendingSync` (strap has banked records newer than our frontier). - // Bridged through the same `BackfillFlagBridge` as `liveBackfillingFlag` (no second LiveState observer). - // Drives the Today Rest "Pending sync" state so a provisional score isn't shown as final. - @State private var liveHistoryPendingSyncFlag = false + // Coalesced offload/history state shared by both Rest readouts; chunk gaps do not flash the hint. + @State private var livePendingSyncFlag = false // #755: have the history-wide reads ever populated this session? Used so the FIRST load always runs them // (even mid-offload, so a cold launch during a sync is never a blank dashboard), while later re-loads can // safely defer them during an active backfill. @@ -1323,6 +1321,8 @@ struct TodayView: View { .frame(minWidth: 320, minHeight: 360) } + pendingSyncIndicator + Spacer(minLength: 8) // Uniform 36pt circular icon set: recording-status light, updates bell, quick-add (+), menu. @@ -1495,10 +1495,7 @@ struct TodayView: View { // The "still building" and "new here?" prompts are about getting today's scores going, // so they stay anchored to today rather than reappearing on every navigated past day. if selectedDayOffset == 0 && repo.today?.recovery == nil { - // While the strap is mid-offload, say so, empty tiles read as final otherwise (#77). - // Its own subview observes LiveState (backfilling + chunk count tick during an offload) - // so it refreshes without re-rendering the rest of Today (scroll-stutter fix). - SyncingHistoryNoteIfBackfilling() + // Sync progress stays in the header and Data Sources; inserting a note moves the cards. if !scoresBuildingDismissed { DataPendingNote( title: "Live now. Your scores are building.", @@ -1556,7 +1553,7 @@ struct TodayView: View { // the boolean EDGE up. loadAll reads the flag to defer the heavy history-wide reads during an // active offload; the off→false edge below re-runs them as a safety net to the coalesced refresh. .background(BackfillFlagBridge(flag: $liveBackfillingFlag, - pendingSyncFlag: $liveHistoryPendingSyncFlag)) + pendingSyncFlag: $livePendingSyncFlag)) } // Reload when the data refreshes OR the selected day changes, the HR trend and Rest score are // day-scoped, so navigating must re-fetch them for the newly selected window. @@ -1592,6 +1589,7 @@ struct TodayView: View { } #if os(macOS) .toolbar { + ToolbarItem(placement: .primaryAction) { pendingSyncIndicator } // The Updates "ringer" on the TRAILING (top-right) edge of the window toolbar // (iOS hosts it in the compact top bar instead). ToolbarItem(placement: .primaryAction) { @@ -3215,9 +3213,7 @@ struct TodayView: View { // `provenanceKey` spells the same string the route does and stays a literal on purpose: it // asks which SOURCE won this day, not which catalog entry to open. See `HeroRingMetric`. heroRingColumn(section: .rest, domain: .rest, provenanceKey: "sleep_performance", - detailRoute: .metric(HeroRingMetric.rest), - caption: restIsPendingSync ? "Pending sync" : nil, - captionWidth: ring) { restRing(diameter: ring) } + detailRoute: .metric(HeroRingMetric.rest)) { restRing(diameter: ring) } } .frame(maxWidth: .infinity, alignment: .center) // Zero-impact width reader: a clear background that publishes the row's width up via preference. It @@ -3271,17 +3267,9 @@ struct TodayView: View { /// intrinsically diameter×diameter, so the column just centres it and stretches to an equal share /// of the row width. @ViewBuilder - /// `caption` is an optional one-line note under the domain label — currently Rest's "Pending sync". - /// - /// It lives HERE, under the label, rather than over the ring, for two reasons. It cannot cover the - /// score, which is what made the old overlay hide a number the user had every right to see. And it is - /// laid out at the COLUMN's width rather than the ring's, so it has room to render: the overlay was - /// measured against the circle and ellipsised its own explanation mid-word while spilling past the - /// ring's edge. Mirrors Android's `HeroRingColumn(caption:)`. private func heroRingColumn( section: ScoreSection, domain: DomainTheme, provenanceKey: String? = nil, - onOpenBreakdown: (() -> Void)? = nil, detailRoute: TabRoute? = nil, caption: String? = nil, - captionWidth: CGFloat = 98, + onOpenBreakdown: (() -> Void)? = nil, detailRoute: TabRoute? = nil, @ViewBuilder ring: () -> RingBody ) -> some View { VStack(spacing: 8) { @@ -3371,24 +3359,6 @@ struct TodayView: View { .accessibilityLabel("Source: \(label)") } } - // LAST in the column, below the provenance badge rather than above it. The badges sit at the - // same height across the three columns and a caption on one of them must not push that - // column's badge a line lower than its neighbours'. The row is top-aligned and self-sizing - // (#762), so a caption grows the row and leaves every ring where it was. - if let caption { - // Bounded to the RING's width, not left to size itself. Unlike Android, whose three hero - // columns are laid out at a fixed `col` width, these columns take the width of what is in - // them — so an unbounded caption would widen this one on a longer translation and tip the - // trio off centre. Two lines at the ring's width fits the longest of them; the shrink is - // the same allowance the domain label above it already uses. - Text(caption) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(2) - .minimumScaleFactor(0.7) - .multilineTextAlignment(.center) - .frame(maxWidth: captionWidth) - } } } @@ -3435,15 +3405,24 @@ struct TodayView: View { } } - /// Whether today's Rest is provisional because the strap still has records to send. Resolved once and - /// read by both surfaces that say so — the hero column's caption and the Rest tile's — so the two can - /// never disagree about the same moment. + /// Whether today's Rest is provisional because the strap still has records to send. private var restIsPendingSync: Bool { Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag, - historyPendingSync: liveHistoryPendingSyncFlag, + historyPendingSync: livePendingSyncFlag, isTodaySelected: selectedDayOffset == 0) } + private var pendingSyncIndicator: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .frame(width: NoopMetrics.space5, height: NoopMetrics.space5) + .opacity(restIsPendingSync ? 1 : 0) + .help("Pending sync · strap history still offloading") + .accessibilityLabel("Pending sync · strap history still offloading") + .accessibilityHidden(!restIsPendingSync) + } + /// Rest (sleep composite 0–100) hero ring. @ViewBuilder private func restRing(diameter: CGFloat) -> some View { @@ -3982,10 +3961,8 @@ struct TodayView: View { // Component 2: a scored day shows its duration/efficiency caption; an unscored TODAY shows // the "building" hint; a past day with no Rest falls to the honest "Needs the strap" rather // than a bare blank, so the tile always carries a state. - caption: restIsPendingSync - ? String(localized: "Pending sync · strap history still offloading") - : (restScore != nil ? restCaption(d) - : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption)), + caption: restScore != nil ? restCaption(d) + : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption), accent: restScore.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary, // The Rest composite (0–100) trend, not raw sleep minutes, tracks the score above (#614). sparkline: sparks["sleep_performance"], @@ -5627,28 +5604,16 @@ private struct RecordingStatusLight: View { } } -/// The "Syncing strap history…" note, shown only while a historical offload is running (#77). Owns the -/// `LiveState` observation so the chunk count ticks without re-rendering the rest of Today. -private struct SyncingHistoryNoteIfBackfilling: View { - @EnvironmentObject private var live: LiveState - var body: some View { - if live.backfilling { SyncingHistoryNote(chunks: live.syncChunksThisSession) } - } -} - /// #755: a zero-size leaf that mirrors `LiveState.backfilling` into a parent `@Binding` so TodayView can /// read the offload state to defer its heavy reads WITHOUT itself observing LiveState (which would re-flood /// the whole dashboard `body` on every ~1 Hz live tick, the scroll-stutter the rest of this file avoids). /// This leaf owns the observation but renders nothing and re-renders only itself; it pushes only the /// boolean EDGE up (not the per-tick chunk count), and writes the binding from `.onAppear`/`.onChange` /// (never during its own body evaluation). The parent's @State therefore flips ~twice per offload, not 1 Hz. -private struct BackfillFlagBridge: View { +struct BackfillFlagBridge: View { @EnvironmentObject private var live: LiveState @Binding var flag: Bool - /// #1164: optional mirror of `LiveState.historyPendingSync` (strap has banked records newer than our - /// frontier). Bridged through the SAME invisible leaf so a second LiveState observer isn't added to - /// the view tree (the 1 Hz flood isolation the top-of-type note describes). nil when the caller - /// doesn't need it. + /// Coalesced presentation state; the raw backfill flag above still controls deferred data reads. @Binding var pendingSyncFlag: Bool var body: some View { Color.clear @@ -5656,46 +5621,50 @@ private struct BackfillFlagBridge: View { .accessibilityHidden(true) .onAppear { if flag != live.backfilling { flag = live.backfilling } - if pendingSyncFlag != live.historyPendingSync { pendingSyncFlag = live.historyPendingSync } } .onChangeCompat(of: live.backfilling) { now in if flag != now { flag = now } } - .onChangeCompat(of: live.historyPendingSync) { now in if pendingSyncFlag != now { pendingSyncFlag = now } } + .debouncedSyncSignal(live.backfilling || live.historyPendingSync, into: $pendingSyncFlag) } } -/// Honest strap-sync outcome row for the Data Sources card (ports the Android Live line, ed6a31d): the -/// stalled-offload error when the last one died, else "History synced N ago". Hidden while an offload -/// runs, the SyncingHistoryNote already says so. The `TimelineView` re-renders the relative label each -/// minute. Owns the `LiveState` observation (scroll-stutter isolation). +/// Persistent sync progress/outcome in Data Sources; chunk transitions never insert or remove a row. +/// Owns LiveState and the minute clock so updates do not rebuild Today's charts. private struct StrapSyncRow: View { @EnvironmentObject private var live: LiveState + @State private var syncing = false var body: some View { - if !live.backfilling { - TimelineView(.periodic(from: .now, by: 60)) { context in - HStack(alignment: .top, spacing: 10) { - SourceBadge("Strap sync", - tint: live.lastSyncError != nil ? StrandPalette.statusWarning - : live.lastSyncedAt != nil ? StrandPalette.accent - : StrandPalette.textTertiary) - Spacer() - if let error = live.lastSyncError { - Text(error) - .font(StrandFont.captionNumber) - .foregroundStyle(StrandPalette.statusWarning) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } else if let at = live.lastSyncedAt { - Text("History synced \(relativeAgo(at, now: context.date.timeIntervalSince1970))") - .font(StrandFont.captionNumber) - .foregroundStyle(StrandPalette.textSecondary) - } else { - Text("Not synced yet") - .font(StrandFont.captionNumber) - .foregroundStyle(StrandPalette.textTertiary) - } + TimelineView(.periodic(from: .now, by: 60)) { context in + HStack(alignment: .top, spacing: NoopMetrics.rowSpacing) { + SourceBadge("Strap sync", + tint: syncing ? StrandPalette.accent + : live.lastSyncError != nil ? StrandPalette.statusWarning + : live.lastSyncedAt != nil ? StrandPalette.accent + : StrandPalette.textTertiary) + Spacer() + if syncing { + Text(SyncActivityCopy.syncing(chunks: live.syncChunksThisSession, pagesBehind: nil).status) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.accent) + .lineLimit(1) + } else if let error = live.lastSyncError { + Text(error) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.statusWarning) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } else if let at = live.lastSyncedAt { + Text("History synced \(relativeAgo(at, now: context.date.timeIntervalSince1970))") + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + } else { + Text("Not synced yet") + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) } } } + .debouncedSyncSignal(live.backfilling, into: $syncing) } } diff --git a/StrandTests/PendingSyncPresentationTests.swift b/StrandTests/PendingSyncPresentationTests.swift new file mode 100644 index 0000000000..82214573b5 --- /dev/null +++ b/StrandTests/PendingSyncPresentationTests.swift @@ -0,0 +1,48 @@ +import AppKit +import SwiftUI +import XCTest +@testable import Strand + +@MainActor +final class PendingSyncPresentationTests: XCTestCase { + func testChunkGapsKeepTheHintWhileRawBackfillAndPulseUpdatesRemainIndependent() async throws { + let live = LiveState() + var rawBackfill = false + var pending = false + var pendingChanges: [Bool] = [] + let view = BackfillFlagBridge( + flag: Binding(get: { rawBackfill }, set: { rawBackfill = $0 }), + pendingSyncFlag: Binding(get: { pending }, set: { + pending = $0 + pendingChanges.append($0) + })) + .environmentObject(live) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 20, height: 20), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = NSHostingView(rootView: view) + window.orderFrontRegardless() + defer { window.orderOut(nil) } + try await Task.sleep(nanoseconds: 150_000_000) + XCTAssertFalse(pending) + + live.backfilling = true + try await Task.sleep(nanoseconds: 150_000_000) + XCTAssertTrue(rawBackfill) + XCTAssertTrue(pending) + + live.backfilling = false + try await Task.sleep(nanoseconds: 350_000_000) + XCTAssertFalse(rawBackfill, "Data-read scheduling must see the raw end immediately") + XCTAssertTrue(pending, "A short inter-chunk gap must not hide the presentation") + live.historyPendingSync = true + for pulse in 70...80 { live.heartRate = pulse } + try await Task.sleep(nanoseconds: 3_150_000_000) + XCTAssertTrue(pending, "A new pending-history signal must cancel the scheduled hide") + XCTAssertEqual(pendingChanges, [true], "Repeated pending signals and pulses must publish only visibility edges") + + live.historyPendingSync = false + try await Task.sleep(nanoseconds: 3_200_000_000) + XCTAssertFalse(pending) + XCTAssertEqual(pendingChanges, [true, false]) + } +}