diff --git a/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift b/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift new file mode 100644 index 0000000000..7773df2354 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift @@ -0,0 +1,942 @@ +import SwiftUI + +/// Ring geometry and glyph tones for the charge-to-sync control. +/// +/// Named rather than inline so the morph's two halves cannot drift apart: the entry and the wind-down +/// each interpolate between the SAME battery and spinner values, and a literal edited on one side only +/// is exactly the kind of asymmetry that reads as "one direction feels wrong" without being visible in a +/// diff. These are one component's internal drawing values — stroke weights and glyph tones — rather +/// than layout spacing, so they stay file-private instead of joining `NoopMetrics`, whose scale is for +/// margins BETWEEN things. The ring's inset is the exception and does live there, because the header +/// sizes its sibling controls against it. +private enum SyncRing { + /// Stroke weight of the resting battery arc, and of the spinner it becomes. + static let batteryWidth: CGFloat = 3.0 + static let spinnerWidth: CGFloat = 2.6 + /// The unfilled track sitting behind the spinner arc, and its tone. + static let trackWidth: CGFloat = 2.4 + static let trackOpacity: Double = 0.13 + + /// Point size of the central numeral. The battery percentage and the chunk tally share it so that + /// across the morph one appears to replace the other in place. + static let numberSize: CGFloat = 9 + /// Tone of that numeral, and the scale floor it recedes to as it crossfades out. + static let numberOpacity: Double = 0.9 + static let numberMinScale: Double = 0.92 + + /// The charging bolt: point size, and how far above centre it clears the ring. + static let boltSize: CGFloat = 7 + static let boltOffset: CGFloat = -10 + + /// Glyph sizes for the states that are a symbol rather than a reading, and their shared tone. The + /// bolt carries a real reading (we know it is charging) so it is drawn at full weight; the ellipsis + /// stands in for a reading we do not have yet, and sits at the numeral's size instead. + static let symbolGlyphSize: CGFloat = 11 + static let ellipsisGlyphSize: CGFloat = 9 + static let dimGlyphOpacity: Double = 0.5 + + /// The transient "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 + /// progress and the wind-down passes the inverse, so both traverse one identical ramp rather than + /// two hand-written ones that have to be kept in agreement. + static func width(eased: Double) -> CGFloat { + batteryWidth + (spinnerWidth - batteryWidth) * eased + } + + /// Numeral scale for a crossfade at `opacity`: full size when fully visible, easing down to + /// `numberMinScale` as it goes, so it recedes rather than simply vanishing. + static func numberScale(opacity: Double) -> Double { + numberMinScale + (1 - numberMinScale) * opacity + } +} + +/// Compact strap-battery chrome that morphs into an activity indicator while history is syncing. +/// +/// The indicator owns its visual transition so screens only provide honest battery and sync state. +/// Its expanded width participates in the surrounding layout, pushing earlier controls aside instead +/// of painting over them. Motion-saving modes replace the morph and rotation with a static state. +public struct ChargeSyncIndicator: View { + public enum BatteryState: Equatable { + case offline + case pending(charging: Bool) + case charge(percent: Double, charging: Bool) + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ObservedObject private var motion = NoopMotionState.shared + + private var poseStill: Bool { motion.poseStill(reduceMotion) } + + private let batteryState: BatteryState + private let syncing: Bool + /// Chunks acked this session. Rendered inside the spinner, in the spot the battery percentage + /// occupies when idle — 0 shows nothing, so a live-HR-only session spins with a bare ring. + private let chunks: Int + private let label: LocalizedStringKey + + /// The label's natural width, measured by `labelWidthReader`. 0 until the first layout pass, which + /// only leaves `expandedWidth` at its compact floor — and the capsule is compact then anyway. + @State private var labelWidth: CGFloat = 0 + @State private var visualProgress = 0.0 + @State private var pillProgress = 0.0 + @State private var endingSync = false + @State private var animationActive = false + @State private var showsLabel = false + @State private var spinStartedAt: Date? + /// Spin angle the CURRENT entry started from. The entry sweeps `(spin − this) · eased`, never the raw + /// `spin`: when a sync begins while the previous one is still winding down, `spinStartedAt` is not + /// reset, so the raw angle can already be thousands of degrees — and scaling THAT by the entry curve + /// swept every one of them in 1.25s, which is the "spins twenty times, very fast" on expansion. + @State private var spinBaseDegrees = 0.0 + @State private var exitStartDegrees = 0.0 + @State private var exitStartArc = 0.30 + /// The arc the battery ring draws, in turns. Mirrors `ChargeSyncMorph`'s own derivation so the exit + /// can pick up from exactly the angle the entry left the ring at. + private var currentBatteryArc: Double { + guard case .charge(let percent, _) = batteryState else { return 0 } + return max(0.02, min(1, percent / 100)) + } + @State private var announcementTask: Task? + @State private var settleTask: Task? + /// A sync that arrived while the wind-down was already drawing, held until the ring is back at rest. + /// See `beginSync` for why it waits rather than cutting in. + @State private var restartAfterSettle = false + + public init( + batteryState: BatteryState, + syncing: Bool, + chunks: Int = 0, + label: LocalizedStringKey = "Syncing" + ) { + self.batteryState = batteryState + self.syncing = syncing + self.chunks = chunks + self.label = label + } + + /// The capsule's width once fully expanded: the ring, a gap, the label at its natural width, then + /// the SAME gap trailing — so the padding reads even instead of dumping every spare point after the + /// text. A fixed width cannot do this, because one constant has to serve "Syncing" (43pt) and + /// "Sincronizzazione" (91pt) alike, and sizing for the latter leaves the former visibly lopsided. + /// + /// Capped at `syncIndicatorExpandedWidth`, which is exactly what the longest translations need at + /// `syncIndicatorMinimumLabelScale` — past that they shrink to fit, as they already did, rather than + /// widening the control and shoving the day title further aside on a phone-width header. + private var expandedWidth: CGFloat { + Self.expandedWidth(labelWidth: labelWidth) + } + + /// Reports the label's natural width. Lives in a `background`, so it never influences layout itself, + /// and carries the visible label's font and Dynamic Type cap so the number it reports is the width + /// that label would actually ask for before `minimumScaleFactor` shrinks it. + private var labelWidthReader: some View { + Text(label, bundle: .module) + .font(StrandFont.overline) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .dynamicTypeSize(...DynamicTypeSize.large) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: SyncLabelWidthKey.self, + value: proxy.size.width + ) + } + ) + .opacity(0) + .accessibilityHidden(true) + } + + public var body: some View { + ZStack(alignment: .leading) { + indicatorContents + .frame( + width: NoopMetrics.compactControlSize, + height: NoopMetrics.compactControlSize + ) + + // `bundle: .module` here and on the measuring reader: `Text(LocalizedStringKey)` resolves + // against `Bundle.main`, i.e. the APP, so without it the package's own catalog entry is never + // read and only the app happening to carry the same key makes this look translated. Every + // other string in StrandDesign passes `.module` for that reason (see `Package.swift`). + // + // `overline` supplies the face and weight, but NOT its `.tracking(1.4)` — the letter-spacing + // is what makes an overline read as one, and this is sentence-case status copy. + Text(label, bundle: .module) + .font(StrandFont.overline) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.labelOpacity)) + .lineLimit(1) + .allowsTightening(true) + .minimumScaleFactor(NoopMetrics.syncIndicatorMinimumLabelScale) + .dynamicTypeSize(...DynamicTypeSize.large) + .frame( + width: expandedWidth + - NoopMetrics.compactControlSize + - NoopMetrics.syncIndicatorLabelSpacing, + alignment: .leading + ) + .offset( + x: NoopMetrics.compactControlSize + + NoopMetrics.syncIndicatorLabelSpacing + ) + .opacity(showsLabel ? 1 : 0) + .accessibilityHidden(true) + } + // Measure ABOVE the animated frame. Attached below it, the reader's GeometryReader is proposed the + // width that is mid-animation, so it re-emits a preference every frame; that rewrites `labelWidth`, + // which recomputes `expandedWidth`, which moves the frame's own target — the spring re-targets on + // every frame of the expansion, which is what made the circle-to-pill glitch. Up here the proposal + // is the ZStack's natural size, which does not move while `pillProgress` animates. + .background(labelWidthReader, alignment: .leading) + .onPreferenceChange(SyncLabelWidthKey.self) { measured in + // Ignore sub-point churn, so a rounding wobble cannot restart the animation. + guard measured > 0, abs(measured - labelWidth) > 0.5 else { return } + labelWidth = measured + } + .frame( + width: NoopMetrics.compactControlSize + + (expandedWidth - NoopMetrics.compactControlSize) + * CGFloat(pillProgress), + height: NoopMetrics.compactControlSize, + alignment: .leading + ) + // No surface of its own. The host supplies the chrome — on the Today header that is + // `nativeLiquidGlassSyncButton()`, the same way every sibling control in the cluster gets its + // material from `nativeLiquidGlassHeaderButton()`. Drawing a fixed near-black capsule here + // instead would force every glyph onto the fixed `onDark*` tones to stay legible over it, which + // is the trap #1013 describes and which #1160 already backed the Liquid hero out of. + .clipShape(Capsule(style: .continuous)) + .contentShape(Capsule(style: .continuous)) + .onAppear { updateSyncState(syncing) } + .onChangeCompat(of: syncing) { updateSyncState($0) } + .onDisappear { + announcementTask?.cancel() + settleTask?.cancel() + // Land at rest rather than freezing mid-morph. Without this the view can go away with + // `endingSync` still armed and no task left to clear it, and a later re-appearance would + // queue its restart behind a wind-down that is never going to finish. + finishSyncImmediately() + restartAfterSettle = false + } + } + + @ViewBuilder + private var indicatorContents: some View { + switch batteryState { + case .charge(let percent, let charging): + ChargeSyncMorph( + progress: visualProgress, + ending: endingSync, + active: animationActive, + reducedMotion: poseStill, + percent: percent, + charging: charging, + batteryTint: Self.ringColor(percent), + spinBaseDegrees: spinBaseDegrees, + chunks: chunks, + spinStartedAt: spinStartedAt, + exitStartDegrees: exitStartDegrees, + exitStartArc: exitStartArc + ) + default: + ZStack { + batteryContents.opacity(1 - visualProgress) + syncingContents.opacity(visualProgress) + } + } + } + + @ViewBuilder + private var batteryContents: some View { + switch batteryState { + case .charge(let percent, let charging): + Circle() + .trim(from: 0, to: max(0.02, min(1, percent / 100))) + .stroke( + Self.ringColor(percent), + style: StrokeStyle(lineWidth: SyncRing.batteryWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(NoopMetrics.syncIndicatorArcInset) + Text("\(Int(percent.rounded()))") + .font(StrandFont.number(SyncRing.numberSize, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.numberOpacity)) + if charging { + Image(systemName: "bolt.fill") + .font(StrandFont.number(SyncRing.boltSize, weight: .bold)) + .foregroundStyle(StrandPalette.chargeColor) + .offset(y: SyncRing.boltOffset) + } + case .pending(let charging): + Image(systemName: charging ? "bolt.fill" : "ellipsis") + .font( + StrandFont.number( + charging ? SyncRing.symbolGlyphSize : SyncRing.ellipsisGlyphSize, + weight: .bold + ) + ) + .foregroundStyle( + charging + ? StrandPalette.chargeColor + : StrandPalette.textPrimary.opacity(SyncRing.dimGlyphOpacity) + ) + case .offline: + Image(systemName: "bolt.slash") + .font(StrandFont.number(SyncRing.symbolGlyphSize)) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.dimGlyphOpacity)) + } + } + + private var syncingContents: some View { + TimelineView( + .animation( + minimumInterval: StrandMotion.syncIndicatorFrameInterval, + paused: !animationActive || poseStill + ) + ) { timeline in + let phase = syncPhase(at: timeline.date) + ZStack { + Circle() + .stroke( + StrandPalette.liquidHeart.opacity(SyncRing.trackOpacity), + lineWidth: SyncRing.trackWidth + ) + .padding(NoopMetrics.syncIndicatorArcInset) + Circle() + .trim(from: 0, to: phase.arc) + .stroke( + StrandPalette.liquidHeart, + style: StrokeStyle(lineWidth: SyncRing.spinnerWidth, lineCap: .round) + ) + .rotationEffect(.degrees(phase.degrees - 90)) + .padding(NoopMetrics.syncIndicatorArcInset) + chunkNumber + } + } + } + + /// The chunk tally inside the spinner, sharing the battery percentage's font and centre so the two + /// read-outs occupy the same spot across the morph. Hidden at 0 — before the first chunk is acked + /// there is no count to state, and a live-HR-only session never gets one. + @ViewBuilder + private var chunkNumber: some View { + if chunks > 0 { + Text("\(chunks)") + .font(StrandFont.number(SyncRing.numberSize, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.numberOpacity)) + .accessibilityHidden(true) + } + } + + private func updateSyncState(_ active: Bool) { + if active { + beginSync() + } else { + endSync() + } + } + + /// Snap to the settled battery state with no motion at all. + /// + /// Shared by the Reduce-Motion path in `endSync`, by the settle task (which has to RE-CHECK — see + /// there), and by `onDisappear`, so "at rest" means one thing rather than three hand-written + /// approximations of it. + private func finishSyncImmediately() { + visualProgress = 0 + pillProgress = 0 + showsLabel = false + animationActive = false + spinStartedAt = nil + spinBaseDegrees = 0 + endingSync = false + } + + /// True only for the state whose ring the wind-down actually parks at 12 o'clock. `.offline` and + /// `.pending` render as a plain crossfade (see `indicatorContents`) with no ring to align. + private var alignsRingOnExit: Bool { + if case .charge = batteryState { return true } + return false + } + + /// Resume a sync that arrived mid wind-down, now that the ring is back at rest. + /// + /// Drops the settle handle FIRST: this runs from inside that very task, and `beginSync` cancels + /// `settleTask` — which would otherwise be the running task cancelling itself. + private func restartIfPending() { + settleTask = nil + guard restartAfterSettle else { return } + restartAfterSettle = false + beginSync() + } + + private func beginSync() { + // Mid wind-down: do NOT cut into it. `entryBody` and `exitBody` draw different geometry for the + // same `visualProgress` — swapping between them mid-flight snaps the ring by up to ~150° of + // rotation and ~40° of arc length at the worst crossover. The exit lasts at most one morph, and + // the raw signal has already been quiet for the host's whole debounce to have got here, so let it + // land and start clean: a fresh entry departs from the static battery ring, which is the one + // state both halves of the morph agree on. + if endingSync { + restartAfterSettle = true + return + } + + settleTask?.cancel() + announcementTask?.cancel() + restartAfterSettle = false + endingSync = false + + if animationActive { + // Already spinning: an exit was scheduled but has not begun drawing — it is still waiting for + // the ring to reach its hand-off angle. Cancelling that task above IS the whole resume; the + // ring simply keeps turning. Re-anchoring `spinBaseDegrees` here would zero `travel` and jump + // the head back to the battery gauge's angle, which is the same snap by another route. + } else { + spinStartedAt = Date() + animationActive = true + // A fresh clock reads ~0°, so the entry sweeps from the battery ring exactly as designed. + spinBaseDegrees = 0 + } + showsLabel = false + animate(poseStill ? nil : StrandMotion.syncIndicatorVisual) { + visualProgress = 1 + } + animate(poseStill ? nil : StrandMotion.syncIndicatorMorph) { + pillProgress = 1 + } + + if poseStill { + showsLabel = true + } + + announcementTask = Task { @MainActor in + if !poseStill { + try? await Task.sleep( + nanoseconds: StrandMotion.syncIndicatorLabelDelayNanoseconds + ) + guard !Task.isCancelled else { return } + } + animate(poseStill ? nil : StrandMotion.syncIndicatorLabelIn) { + 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 + } + } + } + + private func endSync() { + announcementTask?.cancel() + restartAfterSettle = false + + guard animationActive else { + settleTask?.cancel() + visualProgress = 0 + pillProgress = 0 + showsLabel = false + return + } + + if poseStill { + settleTask?.cancel() + finishSyncImmediately() + return + } + + // A wind-down already drawing finishes on its own terms; restarting it here would re-capture the + // hand-off angle from a ring that has already left it. + guard !endingSync else { return } + settleTask?.cancel() + + // The wind-down always covers the SAME distance, so it is always the same length as the entry. + // Deriving the duration from "however far it happens to be from 12 o'clock" instead made the exit + // anything from 0.28s to 2.5s against a fixed 1.25s entry, which is why one half read as quicker. + // Rather than braking from wherever it is, wait for the ring to reach the point that far out — it + // is already spinning, so up to one extra revolution costs nothing and keeps the rate constant. + let rate = StrandMotion.syncIndicatorSpinRateDegrees + let target = StrandMotion.syncIndicatorExitTravelDegrees + let duration = StrandMotion.syncIndicatorMorphDuration + + let phase = syncPhase(at: Date()) + // The tail `entryBody` ACTUALLY draws — head minus length — not the raw spin angle. The two differ + // by `(batteryArc − spinnerArc)·360`, and using the raw value started the exit a third of a turn + // from the visible ring, which showed up as a phantom extra spin. + // Mirrors `entryBody` exactly, base included — the entry sweeps from `spinBaseDegrees`, so an exit + // that read the raw angle would aim the wind-down at a place the ring is not. + let renderedTail = currentBatteryArc * 360 + + (phase.degrees - spinBaseDegrees) + - phase.arc * 360 + let wrapped = renderedTail.truncatingRemainder(dividingBy: 360) + let tailAngle = wrapped < 0 ? wrapped + 360 : wrapped + let alignment = ((target - tailAngle).truncatingRemainder(dividingBy: 360) + 360) + .truncatingRemainder(dividingBy: 360) / rate + // ONLY `.charge` has a ring to park. `.offline` and `.pending` crossfade a glyph, so there is no + // hand-off angle to wait for — and waiting anyway kept them spinning for up to a full extra + // revolution (1.25s) on top of the host's 3s debounce, to align something not on screen. + let lead = alignsRingOnExit ? alignment : 0 + + animate(StrandMotion.syncIndicatorMorph) { + showsLabel = false + pillProgress = 0 + } + + settleTask = Task { @MainActor in + // Keep spinning, untouched, until the ring is exactly `target` degrees short of 12 o'clock. + if lead > 0 { + try? await Task.sleep(nanoseconds: UInt64(lead * 1_000_000_000)) + guard !Task.isCancelled else { return } + } + + // RE-CHECK, do not trust the reading taken when the exit was scheduled: the wait above runs + // for up to a full revolution, and Reduce Motion (or Low Power) can be switched on inside it. + // Without this the wind-down still played its full linear morph to a user who had just asked + // for no motion. + guard !poseStill else { + finishSyncImmediately() + restartIfPending() + return + } + + let atStart = syncPhase(at: Date()) + exitStartDegrees = currentBatteryArc * 360 + + (atStart.degrees - spinBaseDegrees) + - atStart.arc * 360 + exitStartArc = atStart.arc + endingSync = true + // Linear in time on purpose: `exitBody` applies the deceleration curve analytically, so what + // it draws is that curve rather than two easings composed on top of each other. + animate(.linear(duration: duration)) { + visualProgress = 0 + } + + try? await Task.sleep( + nanoseconds: UInt64( + (duration + StrandMotion.syncIndicatorExitSettleMargin) * 1_000_000_000 + ) + ) + guard !Task.isCancelled else { return } + animationActive = false + spinStartedAt = nil + spinBaseDegrees = 0 + endingSync = false + // The ring is back at rest, so a sync that arrived mid wind-down can now start cleanly. + restartIfPending() + } + } + + private func syncPhase(at date: Date) -> (degrees: Double, arc: Double) { + StrandMotion.syncIndicatorPhase(since: spinStartedAt, at: date, posed: poseStill) + } + + private func animate(_ animation: Animation?, changes: () -> Void) { + withAnimation(animation, changes) + } + + /// The three bands a strap charge reads in. Split from the colour lookup so the thresholds can be + /// pinned by a test: SwiftUI `Color` wraps dynamic catalog colours in a fresh provider per access, so + /// two reads of one palette token are not `==` and a test asserting on the colour itself would be + /// comparing identities rather than the banding it means to check. + enum ChargeBand: Equatable { + case critical + case warning + case healthy + } + + /// Boundaries belong to the calmer band above them, so a strap sitting exactly on 35 does not read + /// as a warning. + static func chargeBand(_ percent: Double) -> ChargeBand { + if percent < 15 { return .critical } + if percent < 35 { return .warning } + return .healthy + } + + static func ringColor(_ percent: Double) -> Color { + switch chargeBand(percent) { + case .critical: return StrandPalette.statusCritical + case .warning: return StrandPalette.statusWarning + case .healthy: return StrandPalette.chargeColor + } + } + + /// The capsule's fully-expanded width for a label of `labelWidth`. Split out from the view so the + /// hug-then-cap behaviour is testable directly: at 0 it must not fall below the compact circle, and a + /// label longer than the cap must not be allowed to widen the control. + static func expandedWidth(labelWidth: CGFloat) -> CGFloat { + let hugged = NoopMetrics.compactControlSize + + NoopMetrics.syncIndicatorLabelSpacing + + labelWidth + + NoopMetrics.syncIndicatorLabelSpacing + return min( + NoopMetrics.syncIndicatorExpandedWidth, + max(NoopMetrics.compactControlSize, hugged) + ) + } +} + +/// Carries the sync label's natural width out of the measuring background. `max` because a single +/// reader publishes one value — the reduce only matters if SwiftUI ever evaluates it more than once. +private struct SyncLabelWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct ChargeSyncMorph: View, Animatable { + var progress: Double + let ending: Bool + let active: Bool + let reducedMotion: Bool + let percent: Double + let charging: Bool + /// Slack past the arc's head where its colour is held constant, so the round lineCap that overhangs + /// there samples a pure colour rather than the blend band. See `arcSweep`. + static let capPadTurns = 0.03 + + let batteryTint: Color + /// Spin base for the morph's own reads, mirroring what the entry is handed. + let spinBaseDegrees: Double + let chunks: Int + let spinStartedAt: Date? + let exitStartDegrees: Double + let exitStartArc: Double + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + var body: some View { + TimelineView( + .animation( + minimumInterval: StrandMotion.syncIndicatorFrameInterval, + paused: !active || reducedMotion + ) + ) { timeline in + let p = max(0, min(1, progress)) + let phase = syncPhase(at: timeline.date) + let batteryArc = max(0.02, min(1, percent / 100)) + + if ending { + exitBody(progress: p, batteryArc: batteryArc) + } else { + entryBody( + progress: p, + batteryArc: batteryArc, + spinnerArc: phase.arc, + // Relative to where this entry began — see `spinBaseDegrees`. + spinDegrees: phase.degrees - spinBaseDegrees + ) + } + } + } + + private func exitBody(progress: Double, batteryArc: Double) -> some View { + let exit = 1 - progress + // Quadratic ease-out, NOT smootherstep. Its slope is 2 at the start, so paired with the duration + // `endSync` derives, the rotation begins at exactly the steady spin rate and decays linearly to + // rest — the spinner coasts to a stop. Smootherstep starts at zero slope, which halted the spin + // dead and then performed a second, separate rotation up to the top. + // + // Entry is the mirror case and correctly keeps smootherstep: it departs from a STATIC ring, so + // zero initial velocity is right there, and `spinDegrees · eased` already arrives at the spin + // rate because the curve's slope is zero at its end too. + let eased = StrandMotion.syncIndicatorEaseOutQuad(exit) + + let wrapped = exitStartDegrees.truncatingRemainder(dividingBy: 360) + let remainder = wrapped < 0 ? wrapped + 360 : wrapped + let degreesToTop = remainder == 0 ? 0 : 360 - remainder + let degrees = exitStartDegrees + degreesToTop * eased + // Length converges ON the same curve as the rotation, so the arc has become the percentage at the + // exact moment the ring stops at 12 o'clock. Holding it and filling afterwards made the remaining + // percent appear as a separate tacked-on growth. + let arc = exitStartArc + (batteryArc - exitStartArc) * eased + // One radius across the whole morph — only colour, sweep and stroke weight move. + let inset = NoopMetrics.syncIndicatorArcInset + // `1 − eased`, because the wind-down runs the entry's ramp backwards: at eased 0 it is still the + // spinner's weight, and it has arrived back at the battery ring's by the time eased reaches 1. + let lineWidth = SyncRing.width(eased: 1 - eased) + + return ZStack { + Circle() + .stroke( + StrandPalette.liquidHeart.opacity(SyncRing.trackOpacity * (1 - eased)), + lineWidth: SyncRing.trackWidth + ) + .padding(inset) + // One stroke carrying a travelling colour boundary — see `arcSweep`. On the way out the tint + // recedes, so the battery colour grows back from the tail while the sync colour retreats + // toward the head, arriving fully green as the ring parks at 12 o'clock. + Circle() + .trim(from: 0, to: arc) + .stroke( + arcSweep(tint: 1 - eased, arc: arc), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(degrees - 90)) + .padding(inset) + batteryNumber(opacity: eased) + chunkNumber(opacity: 1 - eased) + } + } + + private func entryBody( + progress: Double, + batteryArc: Double, + spinnerArc: Double, + spinDegrees: Double + ) -> some View { + let eased = StrandMotion.syncIndicatorSmootherStep(progress) + let arc = batteryArc + (spinnerArc - batteryArc) * eased + + // Rotation is anchored to the arc's HEAD — the point the gauge already ends at — so the spin sets + // off from where the percentage sits instead of from 12 o'clock. `trim` draws tail-first, so the + // tail is derived by subtracting the (shrinking) length back off the head. At progress 0 that + // yields head = batteryArc·360 and tail = 0, i.e. exactly the battery ring, untouched. + let travel = reducedMotion ? 0 : spinDegrees * eased + let head = batteryArc * 360 + travel + let degrees = head - arc * 360 + + // Tint trails the motion rather than switching on contact: the ring departs still reading as the + // battery and has fully turned over once it has swept `syncIndicatorColourTravelDegrees`. Driving + // it off `eased` instead made the colour arrive with the shape, which is what read as a swap + // rather than a transformation. Reduced motion has no travel to measure, so it falls back to the + // shape's own curve. + let tint = reducedMotion + ? eased + : min(1, travel / StrandMotion.syncIndicatorColourTravelDegrees) + + // One radius across the whole morph — only colour, sweep and stroke weight move. + let inset = NoopMetrics.syncIndicatorArcInset + let lineWidth = SyncRing.width(eased: eased) + + return ZStack { + Circle() + .stroke( + StrandPalette.liquidHeart.opacity(SyncRing.trackOpacity * tint), + lineWidth: SyncRing.trackWidth + ) + .padding(inset) + // One stroke carrying a travelling colour boundary — see `arcSweep`. + Circle() + .trim(from: 0, to: arc) + .stroke( + arcSweep(tint: tint, arc: arc), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(degrees - 90)) + .padding(inset) + batteryNumber(opacity: 1 - tint) + chunkNumber(opacity: tint) + } + } + + /// The arc's stroke as a sweep that turns over ALONG its length instead of all at once: the head + /// carries the sync colour first and the boundary travels back toward the tail as `tint` rises, with + /// a soft band so it reads as a gradient rather than an edge. Cross-fading two solid strokes changed + /// every point of the ring at the same instant, which reads as one colour being swapped for another; + /// a moving boundary reads as the ring itself turning over. + /// + /// Locations are in turns and line up with `trim`: both this gradient and the trim start at 3 o'clock, + /// and `rotationEffect` carries the gradient around with the shape, so location 0 is always the tail. + private func arcSweep(tint: Double, arc: Double) -> AngularGradient { + let band = StrandMotion.syncIndicatorTintBandTurns + let boundary = StrandMotion.syncIndicatorTintBoundary(tint: tint, arc: arc) + + // Colours at the arc's two ends, once the band has swept past them. + let tailColor = boundary > 0 ? batteryTint : StrandPalette.liquidHeart + let headColor = boundary < arc ? StrandPalette.liquidHeart : batteryTint + + // An AngularGradient WRAPS: location 1 meets location 0. The round lineCap at the tail overhangs + // a few degrees before 0°, which wraps around and samples near location 1 — so the final stop has + // to be the TAIL's colour, or the cap picks up the far end's colour and leaves a stray tip of it + // on an otherwise uniform ring. `headPad` does the same job at the head, holding its colour for a + // little past `arc` so that cap stays pure too. A cap overhangs by lineWidth/2 over the ring + // radius — about 6° — so this pad is comfortably wider than it needs to be. + let headPad = min(1, arc + ChargeSyncMorph.capPadTurns) + let low = max(0, min(headPad, boundary - band)) + let high = max(low, min(headPad, boundary + band)) + return AngularGradient( + stops: [ + .init(color: tailColor, location: 0), + .init(color: tailColor, location: low), + .init(color: headColor, location: high), + .init(color: headColor, location: headPad), + .init(color: tailColor, location: 1), + ], + center: .center, + startAngle: .degrees(0), + endAngle: .degrees(360) + ) + } + + /// The chunk tally, crossfading in as the battery percentage fades out — same font, weight and + /// centre, so across the morph one number appears to replace the other in place. Hidden at 0. + @ViewBuilder + private func chunkNumber(opacity: Double) -> some View { + if chunks > 0 { + Text("\(chunks)") + .font(StrandFont.number(SyncRing.numberSize, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.numberOpacity)) + .scaleEffect(SyncRing.numberScale(opacity: opacity)) + .opacity(opacity) + .accessibilityHidden(true) + } + } + + @ViewBuilder + private func batteryNumber(opacity: Double) -> some View { + Text("\(Int(percent.rounded()))") + .font(StrandFont.number(SyncRing.numberSize, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary.opacity(SyncRing.numberOpacity)) + .scaleEffect(SyncRing.numberScale(opacity: opacity)) + .opacity(opacity) + if charging { + Image(systemName: "bolt.fill") + .font(StrandFont.number(SyncRing.boltSize, weight: .bold)) + .foregroundStyle(StrandPalette.chargeColor) + .offset(y: SyncRing.boltOffset) + .opacity(opacity) + } + } + + /// The same clock `ChargeSyncIndicator` reads when it captures the hand-off angle. One shared + /// implementation rather than a matching pair: any divergence would put the wind-down somewhere the + /// ring is not, and a comment asking two copies to stay identical does not enforce it. + private func syncPhase(at date: Date) -> (degrees: Double, arc: Double) { + StrandMotion.syncIndicatorPhase(since: spinStartedAt, at: date, posed: reducedMotion) + } +} + +public extension View { + /// Fades long header copy underneath a trailing control row while reserving its footprint. + /// + /// `reserving` is the width the control row actually occupies, which the HOST measures and passes — + /// it is not a constant here, because the cluster's composition belongs to the screen, not to the + /// design system. `headerControlReserveWidth` is only the pre-measurement default, so the first + /// frame is not laid out against a reserve of zero. + func headerTrailingControlFadeMask( + reserving reserve: CGFloat = NoopMetrics.headerControlReserveWidth + ) -> some View { + mask { + HStack(spacing: 0) { + Color.black + LinearGradient( + colors: [.black, .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: NoopMetrics.headerTextFadeWidth) + Color.clear.frame(width: max(0, reserve)) + } + } + } +} + +#if DEBUG +private struct ChargeSyncIndicatorPreview: View { + @State private var syncing = false + private let greeting = "Good evening, Maximilian Alexander" + private var toggleTitle: String { + syncing ? "Finish sync" : "Start sync" + } + + var body: some View { + VStack(spacing: NoopMetrics.space5) { + HStack(spacing: NoopMetrics.space2) { + Text(verbatim: greeting) + .font(StrandFont.title1) + .foregroundStyle(StrandPalette.onDarkPrimary) + .lineLimit(2) + .headerTrailingControlFadeMask() + HStack(spacing: NoopMetrics.space1) { + Circle() + .fill(StrandPalette.onDarkPrimary.opacity(0.16)) + .frame( + width: NoopMetrics.compactControlSize, + height: NoopMetrics.compactControlSize + ) + ChargeSyncIndicator( + batteryState: .charge(percent: 68, charging: false), + syncing: syncing + ) + Circle() + .fill(StrandPalette.onDarkPrimary.opacity(0.16)) + .frame( + width: NoopMetrics.compactControlSize, + height: NoopMetrics.compactControlSize + ) + } + } + + Button { + syncing.toggle() + } label: { + Text(verbatim: toggleTitle) + } + .font(StrandFont.body) + } + .padding(NoopMetrics.space5) + .frame(width: 440, height: 180) + .background(StrandPalette.accent) + .preferredColorScheme(.dark) + } +} + +/// The control as a host presents it: chrome supplied from outside, on a theme-following surface. +/// +/// `heroFill` / `heroBorder` stand in for the Today header's Liquid Glass — the point is not the exact +/// material but that the backdrop FLIPS with the scheme, which is the condition the glyph tone has to +/// survive. A fixed backdrop here would prove nothing, since a fixed light glyph looks fine on one. +private struct ChargeSyncIndicatorHosted: View { + var syncing = true + + var body: some View { + ChargeSyncIndicator( + batteryState: .charge(percent: 68, charging: false), + syncing: syncing, + chunks: 3 + ) + .padding(NoopMetrics.syncIndicatorGlassPadding) + .background(Capsule(style: .continuous).fill(StrandPalette.heroFill)) + .overlay(Capsule(style: .continuous).strokeBorder(StrandPalette.heroBorder, lineWidth: 1)) + .padding(NoopMetrics.space5) + } +} + +#Preview("Charge to sync morph") { + ChargeSyncIndicatorPreview() +} + +// Both schemes, deliberately. The ring and numerals read through `StrandPalette.textPrimary`, which +// flips with the theme — so Light is not a duplicate of Dark, it is the case that actually regressed +// when the control carried a fixed near-black surface and had to use fixed light glyphs to sit on it. +#Preview("Charge sync active — Dark") { + ChargeSyncIndicatorHosted() + .preferredColorScheme(.dark) +} + +#Preview("Charge sync active — Light") { + ChargeSyncIndicatorHosted() + .preferredColorScheme(.light) +} + +#Preview("Charge idle — Light") { + ChargeSyncIndicatorHosted(syncing: false) + .preferredColorScheme(.light) +} +#endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Components.swift b/Packages/StrandDesign/Sources/StrandDesign/Components.swift index 2f354cc733..b9efb9c81f 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Components.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Components.swift @@ -25,6 +25,29 @@ public enum NoopMetrics { public static let sourceBadgeHeight: CGFloat = 18 public static let hypnogramBandMinThickness: CGFloat = 14 // floor so short stages read as bars, not ticks public static let tabBarClearance: CGFloat = 76 // iOS: extra bottom scroll room so the last card clears the floating tab bar + /// Canonical diameter for compact circular controls in dense header chrome. + public static let compactControlSize: CGFloat = 36 + /// Expanded width of the compact charge-to-sync status capsule. + public static let syncIndicatorExpandedWidth: CGFloat = 108 + /// Optical space between the sync ring and its transient label. + public static let syncIndicatorLabelSpacing: CGFloat = 5 + /// Smallest readable scale for long localized labels inside the sync capsule. + public static let syncIndicatorMinimumLabelScale: CGFloat = 0.72 + /// Even inset around the sync control before applying exact-bounds Liquid Glass, matching the inset + /// the system's `.small` glass chrome gives the sibling header circles. Equal on both axes so the + /// compact state stays circular. + public static let syncIndicatorGlassPadding: CGFloat = 5 + /// Inset for the indicator's ring in BOTH states — the battery arc and the sync spinner share one + /// radius, so the morph changes colour and sweep without the circle also resizing. Two different + /// radii read as two different controls swapping places rather than one control changing state. + public static let syncIndicatorArcInset: CGFloat = 2.5 + /// Width of the soft fade where long header text passes beneath trailing controls. + public static let headerTextFadeWidth: CGFloat = 48 + /// Starting guess for the trailing footprint a header control row occupies, used ONLY until the host + /// has measured its own cluster (see `headerTrailingControlFadeMask(reserving:)`). Four compact + /// controls plus their gaps and the sync control's glass inset — deliberately not a fixed budget, + /// because a cluster that gains a control must not silently start mis-fading the title beside it. + public static let headerControlReserveWidth: CGFloat = 168 // MARK: Standardised spacing scale (the ONE source of truth for margins) // diff --git a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift index b56a263120..4c2d8205ff 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift @@ -63,6 +63,138 @@ public enum StrandMotion { /// Standard fade. public static let fade = Animation.easeInOut(duration: durationStandard) + + // MARK: Compact charge-to-sync indicator + // + // Only `syncIndicatorSignalDebounceNanoseconds` is `public`: the screen owns the raw sync signal and + // therefore has to debounce it before handing it over. Everything else here is the indicator's own + // internal timing, consumed exclusively by `ChargeSyncIndicator` in this module, so it stays + // `internal` per the "public API is intentional" rule in docs/CONTRIBUTING.md. + + /// Responsive capsule geometry for the compact charge-to-sync indicator. + static let syncIndicatorMorph = Animation.spring( + response: 0.60, + dampingFraction: 0.94, + blendDuration: 0.14 + ) + + /// Coordinated charge-number, arc, and colour transition. + /// + /// Matched to what the wind-down takes for an average half-turn (`2 · 180 / rate`), so both halves of + /// the morph are the same length and read as one gesture. At a fixed 0.58s the entry ran to less than + /// half the typical exit, which made the start feel instant against a visibly slower finish. + /// + /// Linear on purpose, like the exit: `entryBody` applies smootherstep itself, so the curve it draws is + /// the curve you see instead of an easing composed on top of an easing. + static var syncIndicatorVisual: Animation { + .linear(duration: syncIndicatorMorphDuration) + } + + /// Length of BOTH halves of the morph. Constant deceleration over `syncIndicatorExitTravelDegrees` + /// takes `2 · distance / rate`; the entry is given the same, so neither half can feel quicker. + static var syncIndicatorMorphDuration: Double { + 2 * syncIndicatorExitTravelDegrees / syncIndicatorSpinRateDegrees + } + + /// Half-width, in turns, of the blend band that travels along the arc as it changes colour. Wide + /// enough to read as a gradient rather than a hard edge, narrow enough that the two ends stay + /// distinct mid-sweep. + static let syncIndicatorTintBandTurns: Double = 0.10 + + /// Transient 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 + + /// Fixed sweep of the spinner arc, in turns. + /// + /// Deliberately NOT breathing. Rotation is anchored to the arc's head, so the tail's speed is + /// `rate − 360·d(arc)/dt` — a breathing length modulates the tail by ~±17%, and with a 1.2s breath + /// against a 1.25s spin that wobble lands at nearly the same screen position every revolution, which + /// reads as the ring repeatedly slowing at one spot. A constant length makes both ends travel at + /// exactly the spin rate, which is also what lets the wind-down hand off cleanly. + static let syncIndicatorSpinnerArcTurns = 0.38 + + /// Distance the wind-down covers. Fixed rather than "whatever is left to 12 o'clock", so the exit is + /// always the same length as the entry; `endSync` waits for the ring to reach this far out instead. + static let syncIndicatorExitTravelDegrees: Double = 180 + + /// 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. + 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 } + + /// Grace after the wind-down lands before the morph view is torn down, so the swap back to the + /// static battery ring happens on an already-settled frame rather than mid-motion. + static let syncIndicatorExitSettleMargin: Double = 0.06 + + /// How far the ring must sweep for the battery colour to finish becoming the sync colour. The tint + /// trails the motion rather than switching on contact. + /// + /// A full turn, so the colour lands exactly as the entry ends. At half a turn it finished around the + /// entry's midpoint and read as rushed: `spinDegrees` accrues at the full rate from the outset, so by + /// the close of a 1.25s entry the ring has already carried 360°, not 180°. + static let syncIndicatorColourTravelDegrees: Double = 360 + + // MARK: Charge-to-sync curve math + // + // Pure, frame-free and therefore unit-testable — see `ChargeSyncIndicatorMathTests`. It lives here + // rather than on the views because BOTH `ChargeSyncIndicator` and its `ChargeSyncMorph` child read + // the same spin clock: the indicator captures the hand-off angle from it while the morph draws from + // it, so two copies of the curve would put the wind-down somewhere the ring is not. + + /// Where the spinner is at `date`: rotation in degrees since `start`, and the arc's fixed length in + /// turns. `posed` (a motion-saving mode) parks it at 0° with the arc still at full length, so the + /// static state is the moving one stopped rather than a different shape. + static func syncIndicatorPhase( + since start: Date?, + at date: Date, + posed: Bool + ) -> (degrees: Double, arc: Double) { + let arc = syncIndicatorSpinnerArcTurns + guard !posed else { return (0, arc) } + let seconds = max(0, date.timeIntervalSince(start ?? date)) + return ((seconds / syncIndicatorSpinPeriod) * 360, arc) + } + + /// Smootherstep (6t⁵ − 15t⁴ + 10t³). Every value the morph's ENTRY interpolates — arc, sweep, stroke + /// weight and the colour blend — rides this one curve, so they start, travel and land together. + /// + /// The quintic rather than the classic 3t² − 2t³: smoothstep zeroes only the FIRST derivative at each + /// end, so the morph still enters and leaves with a perceptible kick in acceleration. This also zeroes + /// the second derivative, so it eases out of rest and back into it with no such break. + static func syncIndicatorSmootherStep(_ value: Double) -> Double { + let t = max(0, min(1, value)) + return t * t * t * (t * (t * 6 - 15) + 10) + } + + /// Quadratic ease-out, `1 − (1 − t)²`: slope 2 at t = 0 falling linearly to 0 at t = 1 — constant + /// deceleration. Used for the wind-down, whose duration is derived to match that opening slope to the + /// spinner's actual rate, so the ring coasts to a stop instead of halting and then re-rotating. + static func syncIndicatorEaseOutQuad(_ value: Double) -> Double { + let t = max(0, min(1, value)) + return 1 - (1 - t) * (1 - t) + } + + /// Position, in turns, of the travelling colour boundary along an arc of length `arc` at `tint`. + /// + /// Sweeps from `arc + band` down to `−band`, not from `arc` to 0, so the blend band clears the arc + /// completely at BOTH ends. Sweeping only across `[0, arc]` left half a band width hanging over each + /// end, which showed as a green tail still on the ring after it had otherwise fully turned red. + static func syncIndicatorTintBoundary(tint: Double, arc: Double) -> Double { + let band = syncIndicatorTintBandTurns + return arc + band - tint * (arc + 2 * band) + } } #if DEBUG diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index e82291ae67..10477c2587 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -118,6 +118,8 @@ public enum StrandPalette { public static var focusRing: Color { accentChoice.focusRing } /// Opacity for dimmed/disabled sections (shared so screens don't invent their own value). public static let disabledOpacity: Double = 0.45 + /// Liquid-scene activity tint shared by heart-rate feedback and transient sync chrome. + public static let liquidHeart = Color(light: "#D94C64", dark: "#FF6B81") // MARK: - Chart style (data-viz colour mode) — Titanium (brand) or Classic (throwback) // diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings index ef86b9d4a1..42d8aa8645 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -1525,6 +1525,64 @@ } } }, + "Syncing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sync läuft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Syncing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步中" + } + } + } + }, "System": { "localizations": { "de": { diff --git a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift index c84ed4ac15..46bcf8a833 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift @@ -63,6 +63,10 @@ public enum StrandFont { /// Overline 11 / Bold, +1.4 tracking (apply `.tracking(1.4)` at use site; /// `overlineText(_:)` does it for you). Sparing ALL-CAPS labels. Scales with Dynamic Type. + /// + /// Also the face for compact status copy in constrained chrome (the Today header's sync capsule), + /// used there WITHOUT the tracking — that is sentence case, not an overline, and the letter-spacing + /// is what makes an overline read as one. public static let overline = Font.system(.caption2, design: .rounded, weight: .semibold) /// `overline` at a custom point size — same Helvetica face, weight and Dynamic-Type scaling diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/ChargeSyncIndicatorMathTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/ChargeSyncIndicatorMathTests.swift new file mode 100644 index 0000000000..a36db556d9 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/ChargeSyncIndicatorMathTests.swift @@ -0,0 +1,234 @@ +import XCTest +@testable import StrandDesign + +/// Pins the pure math behind the Today header's charge-to-sync control. +/// +/// The control is a view, so nothing here can assert what it looks like. What it CAN assert is the part +/// that is neither a view nor a frame: the spin clock, the two easing curves, the colour-sweep boundary, +/// and the derivation that makes the morph's two halves the same length. Those are exactly the values a +/// later tweak is most likely to break silently — the failure mode is not a crash but "the wind-down +/// feels wrong", which no compile and no screenshot catches, and which `swift-packages.yml` (the one +/// workflow that runs by default) can only catch here. +final class ChargeSyncIndicatorMathTests: XCTestCase { + + private let epsilon = 1e-9 + + // MARK: - The spin clock + + /// One shared clock feeds both the indicator (which captures the hand-off angle) and the morph + /// (which draws from it), so the rate has to be exactly the advertised one at any elapsed time. + func testPhaseAdvancesAtTheSpinRate() { + let start = Date(timeIntervalSince1970: 1_000) + let period = StrandMotion.syncIndicatorSpinPeriod + + for turns in [0.0, 0.25, 1.0, 7.5] { + let phase = StrandMotion.syncIndicatorPhase( + since: start, + at: start.addingTimeInterval(period * turns), + posed: false + ) + XCTAssertEqual(phase.degrees, 360 * turns, accuracy: 1e-9, + "\(turns) turns of elapsed time must be \(360 * turns)°") + } + } + + /// The arc's length is deliberately constant — a breathing length modulates the tail's speed and + /// reads as the ring slowing at one spot every revolution. Pin that it never varies with time. + func testArcLengthIsConstantAcrossTheSpin() { + let start = Date(timeIntervalSince1970: 1_000) + for seconds in [0.0, 0.3, 1.25, 4.0, 60.0] { + let phase = StrandMotion.syncIndicatorPhase( + since: start, + at: start.addingTimeInterval(seconds), + posed: false + ) + XCTAssertEqual(phase.arc, StrandMotion.syncIndicatorSpinnerArcTurns, accuracy: epsilon) + } + } + + /// A motion-saving mode parks the ring at 12 o'clock but keeps it at FULL length, so the static + /// state is the moving one stopped rather than a different shape appearing in its place. + func testPosedParksAtTopWithoutShorteningTheArc() { + let start = Date(timeIntervalSince1970: 1_000) + let phase = StrandMotion.syncIndicatorPhase( + since: start, + at: start.addingTimeInterval(9.3), + posed: true + ) + XCTAssertEqual(phase.degrees, 0, accuracy: epsilon) + XCTAssertEqual(phase.arc, StrandMotion.syncIndicatorSpinnerArcTurns, accuracy: epsilon) + } + + /// A nil start (before the first spin) and a clock that has gone backwards must both read 0° rather + /// than a negative rotation, which would wind the ring the wrong way. + func testPhaseNeverGoesNegative() { + let now = Date(timeIntervalSince1970: 1_000) + XCTAssertEqual( + StrandMotion.syncIndicatorPhase(since: nil, at: now, posed: false).degrees, + 0, accuracy: epsilon + ) + XCTAssertEqual( + StrandMotion.syncIndicatorPhase( + since: now, at: now.addingTimeInterval(-5), posed: false + ).degrees, + 0, accuracy: epsilon + ) + } + + // MARK: - The easing curves + + func testSmootherStepEndpointsAndMidpoint() { + XCTAssertEqual(StrandMotion.syncIndicatorSmootherStep(0), 0, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorSmootherStep(1), 1, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorSmootherStep(0.5), 0.5, accuracy: epsilon) + } + + func testSmootherStepIsMonotonicAndClamped() { + var previous = -1.0 + for step in 0...100 { + let value = StrandMotion.syncIndicatorSmootherStep(Double(step) / 100) + XCTAssertGreaterThanOrEqual(value, previous, "curve must never go backwards") + XCTAssertTrue((0...1).contains(value), "curve must stay in 0…1, got \(value)") + previous = value + } + XCTAssertEqual(StrandMotion.syncIndicatorSmootherStep(-3), 0, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorSmootherStep(4), 1, accuracy: epsilon) + } + + /// The whole reason for the quintic over the classic 3t² − 2t³: BOTH the first and second + /// derivatives vanish at each end, so the morph eases out of rest and back into it with no kick. + /// Swapping in smoothstep would leave the second derivative non-zero and pass every other test here. + func testSmootherStepHasZeroFirstAndSecondDerivativeAtBothEnds() { + let h = 1e-4 + for end in [0.0, 1.0] { + let f = StrandMotion.syncIndicatorSmootherStep + let first = (f(end + h) - f(end - h)) / (2 * h) + let second = (f(end + h) - 2 * f(end) + f(end - h)) / (h * h) + XCTAssertEqual(first, 0, accuracy: 1e-6, "slope at \(end) must be flat") + XCTAssertEqual(second, 0, accuracy: 1e-3, "curvature at \(end) must be flat") + } + } + + func testEaseOutQuadEndpointsAndClamping() { + XCTAssertEqual(StrandMotion.syncIndicatorEaseOutQuad(0), 0, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorEaseOutQuad(1), 1, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorEaseOutQuad(0.5), 0.75, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorEaseOutQuad(-2), 0, accuracy: epsilon) + XCTAssertEqual(StrandMotion.syncIndicatorEaseOutQuad(2), 1, accuracy: epsilon) + } + + /// The wind-down's duration is derived to match this curve's OPENING slope to the spinner's actual + /// rate, which is what makes the ring coast to a stop instead of halting and then re-rotating. That + /// only holds if the slope at t = 0 is exactly 2. + func testEaseOutQuadOpensAtDoubleRate() { + let h = 1e-6 + let slope = (StrandMotion.syncIndicatorEaseOutQuad(h) + - StrandMotion.syncIndicatorEaseOutQuad(0)) / h + XCTAssertEqual(slope, 2, accuracy: 1e-4) + } + + // MARK: - The derivation that keeps both halves equal + + /// Constant deceleration over `exitTravel` takes `2 · distance / rate`, and the entry is handed the + /// same duration. Written out as a literal on either side, the two would drift apart the moment the + /// spin period was retuned — and the symptom is only ever "one half feels quicker". + func testMorphDurationIsBothHalvesOfTheSameGesture() { + XCTAssertEqual( + StrandMotion.syncIndicatorSpinRateDegrees, + 360 / StrandMotion.syncIndicatorSpinPeriod, + accuracy: epsilon + ) + XCTAssertEqual( + StrandMotion.syncIndicatorMorphDuration, + 2 * StrandMotion.syncIndicatorExitTravelDegrees + / StrandMotion.syncIndicatorSpinRateDegrees, + accuracy: epsilon + ) + XCTAssertGreaterThan(StrandMotion.syncIndicatorMorphDuration, 0) + } + + // MARK: - The travelling colour boundary + + /// At either extreme the blend band must sit entirely OFF the arc, or a sliver of the outgoing + /// colour is left behind on a ring that has otherwise finished turning over. + func testTintBoundaryClearsTheArcAtBothEnds() { + let band = StrandMotion.syncIndicatorTintBandTurns + // A float tolerance, not a slack allowance: the boundary lands exactly on the arc's ends by + // construction, so the residue here is representation error (~1e-16), not a real overhang. + let tolerance = 1e-12 + for arc in [0.02, StrandMotion.syncIndicatorSpinnerArcTurns, 1.0] { + let untinted = StrandMotion.syncIndicatorTintBoundary(tint: 0, arc: arc) + XCTAssertGreaterThanOrEqual(untinted - band, arc - tolerance, + "at tint 0 the whole arc must still be the battery colour") + + let fullyTinted = StrandMotion.syncIndicatorTintBoundary(tint: 1, arc: arc) + XCTAssertLessThanOrEqual(fullyTinted + band, tolerance, + "at tint 1 the whole arc must be the sync colour") + } + } + + /// The boundary sweeps head-to-tail, so it only ever decreases as the tint rises. + func testTintBoundarySweepsMonotonically() { + let arc = StrandMotion.syncIndicatorSpinnerArcTurns + var previous = Double.infinity + for step in 0...100 { + let boundary = StrandMotion.syncIndicatorTintBoundary( + tint: Double(step) / 100, arc: arc + ) + XCTAssertLessThan(boundary, previous, "the boundary must travel one way only") + previous = boundary + } + } + + // MARK: - Capsule width + + /// The capsule hugs its label instead of using one constant for every language, so a short label is + /// not left with lopsided padding and a long one is not clipped. Both ends of that need pinning. + func testExpandedWidthHugsTheLabelBetweenItsFloorAndCap() { + let floor = NoopMetrics.compactControlSize + let cap = NoopMetrics.syncIndicatorExpandedWidth + let gap = NoopMetrics.syncIndicatorLabelSpacing + + // No measurement yet (first layout pass) must not collapse the control below its circle. + XCTAssertEqual(ChargeSyncIndicator.expandedWidth(labelWidth: 0), floor + 2 * gap) + XCTAssertGreaterThanOrEqual(ChargeSyncIndicator.expandedWidth(labelWidth: 0), floor) + + // A label that fits gets an even gap on both sides of itself. + let modest: CGFloat = 30 + XCTAssertEqual( + ChargeSyncIndicator.expandedWidth(labelWidth: modest), + floor + gap + modest + gap + ) + + // A translation longer than the cap shrinks to fit rather than widening the control and + // shoving the day title aside on a phone-width header. + XCTAssertEqual(ChargeSyncIndicator.expandedWidth(labelWidth: 400), cap) + XCTAssertLessThanOrEqual(ChargeSyncIndicator.expandedWidth(labelWidth: 10_000), cap) + } + + func testExpandedWidthIsMonotonicInLabelWidth() { + var previous: CGFloat = 0 + for width in stride(from: CGFloat(0), through: 200, by: 5) { + let expanded = ChargeSyncIndicator.expandedWidth(labelWidth: width) + XCTAssertGreaterThanOrEqual(expanded, previous) + previous = expanded + } + } + + // MARK: - Ring tint + + /// The three battery bands. Boundary values are the point: 15 and 35 belong to the calmer band above + /// them, so a strap sitting exactly on 35 does not read as a warning. + /// + /// Asserted on the band rather than the `Color`, because SwiftUI wraps a dynamic catalog colour in a + /// fresh provider per access — two reads of one palette token are not `==`, so a colour comparison + /// would fail on identity while telling you nothing about the thresholds. + func testChargeBandThresholds() { + XCTAssertEqual(ChargeSyncIndicator.chargeBand(0), .critical) + XCTAssertEqual(ChargeSyncIndicator.chargeBand(14.9), .critical) + XCTAssertEqual(ChargeSyncIndicator.chargeBand(15), .warning) + XCTAssertEqual(ChargeSyncIndicator.chargeBand(34.9), .warning) + XCTAssertEqual(ChargeSyncIndicator.chargeBand(35), .healthy) + XCTAssertEqual(ChargeSyncIndicator.chargeBand(100), .healthy) + } +} diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift index 5d99dec373..e3c13539ca 100644 --- a/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift +++ b/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift @@ -30,6 +30,17 @@ final class QuietMotionCoverageTests: XCTestCase { /// A loop that never settles: an indefinitely repeating implicit animation, or a per-frame /// `TimelineView` clock. `TimelineView(.periodic…)` is deliberately NOT censused — a 1 s or 60 s /// clock is a label ticking over, not the per-frame drawing this gate exists to stop. + /// + /// Matched against the source with ALL WHITESPACE REMOVED, not line by line. Line-by-line matching + /// meant a marker only counted when it fitted on one line, so the ordinary wrapped spelling + /// + /// TimelineView( + /// .animation( + /// + /// scored zero hits and the file was never censused at all. That is not hypothetical: it is how the + /// first version of `ChargeSyncIndicator` shipped two ungated 60 Hz clocks with this suite green. A + /// census whose blind spot is "the author let the formatter wrap the call" is worse than none, + /// because the green tick is read as coverage. private static let loopMarkers = ["repeatForever(", "TimelineView(.animation"] /// Files allowed to contain a marker without naming the gate, each with the reason. @@ -75,6 +86,33 @@ final class QuietMotionCoverageTests: XCTestCase { return out } + /// Every marker occurrence in `lines`, as the 1-based line the occurrence STARTS on. + /// + /// Whitespace is stripped from the whole file before matching, so a call split across lines reads + /// the same as one written inline. The index map is carried alongside purely so an offender can + /// still be reported at a line the author can jump to. + private func markerHits(in lines: [String]) -> [Int] { + var flattened = "" + var lineOfCharacter: [Int] = [] + for (index, line) in lines.enumerated() { + for character in line where !character.isWhitespace { + flattened.append(character) + lineOfCharacter.append(index + 1) + } + } + + var hits: [Int] = [] + for marker in Self.loopMarkers { + var searchFrom = flattened.startIndex + while let found = flattened.range(of: marker, range: searchFrom.. [String] { text.split(separator: "\n", omittingEmptySubsequences: false).map { line in @@ -95,15 +133,16 @@ final class QuietMotionCoverageTests: XCTestCase { var censused = 0 for (rel, text) in files { let code = codeLines(text) - let hits = code.enumerated().filter { _, line in - Self.loopMarkers.contains { line.contains($0) } - } + let hits = markerHits(in: code) guard !hits.isEmpty else { continue } censused += 1 if Self.exemptions[rel] != nil { continue } guard !text.contains("NoopMotionState") else { continue } - for (i, line) in hits { - offenders.append("\(rel):\(i + 1): \(line.trimmingCharacters(in: .whitespaces))") + for line in hits { + let source = code.indices.contains(line - 1) + ? code[line - 1].trimmingCharacters(in: .whitespaces) + : "" + offenders.append("\(rel):\(line): \(source)") } } diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 4fc33b9f1c..1ed234b393 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -14,6 +14,10 @@ struct StrandApp: App { // here, before the first Today render. No-op (active stays nil) when the arg is absent, so // Release is unaffected (whole harness is `#if DEBUG`). See DemoDayHarness.swift. DemoDayHarness.applyLaunchArgsIfNeeded() + // DEBUG-only sync harness: `--demo-sync` drives the Today header's charge→sync control with a + // synthetic battery + a looping sync signal, so the morph is watchable with no strap paired. + // No-op (active stays false) when the arg is absent. See DemoSyncHarness.swift. + DemoSyncHarness.applyLaunchArgsIfNeeded() #endif // Foreground presentation: without a delegate, macOS suppresses a notification's banner while the // app is frontmost, so a reminder tested with NOOP open would show nothing. Mirrors iOS. diff --git a/Strand/Data/DemoSyncHarness.swift b/Strand/Data/DemoSyncHarness.swift new file mode 100644 index 0000000000..3856c0b7c2 --- /dev/null +++ b/Strand/Data/DemoSyncHarness.swift @@ -0,0 +1,51 @@ +#if DEBUG +import Foundation + +// MARK: - DEBUG-only sync-indicator harness +// +// A companion to DemoDayHarness for the Today header's charge→sync control. Neither `--demo-seed` nor +// `--demo-hour` can drive that control: it reads LiveState (strap connection, battery percentage, +// backfill progress), none of which the synthetic dataset supplies. Without a paired strap the control +// therefore renders the offline `bolt.slash` and the charge→sync morph never plays — so the morph, which +// is the whole point of the control, cannot be watched or captured in a simulator. +// +// `--demo-sync` closes that gap on two fronts: it supplies a synthetic battery reading, so the control +// takes the `.charge` path that owns ChargeSyncMorph rather than the plain offline crossfade, and it +// cycles the syncing signal so the capsule expands and collapses on a loop instead of needing a real +// offload to be timed by hand. +// +// Gating: the WHOLE file is `#if DEBUG`, so it is stripped from every Release build. At runtime nothing +// changes unless `--demo-sync` is present: `active` stays false and LiquidBatteryButton reads LiveState +// exactly as before, so the shipped control is untouched. Everything here is SYNTHETIC — this is not a +// real battery reading and no strap is contacted. + +enum DemoSyncHarness { + + /// True only when `--demo-sync` was passed (→ otherwise zero behaviour change). + static private(set) var active = false + + /// The synthetic strap battery the control shows while the harness drives it. Mid-range and not + /// charging, so the ring draws a partial arc — a full circle or the charging bolt would hide the + /// arc-to-spinner part of the morph. + static let batteryPercent: Double = 72 + static let charging = false + + /// Idle stretch between sync bursts. MUST comfortably exceed + /// `StrandMotion.syncIndicatorSignalDebounceNanoseconds` (3s): LiquidBatteryButton debounces the + /// falling edge by that much, so a shorter idle cancels the pending collapse and the capsule never + /// morphs back — it just sits expanded with the spinner looping. 3s of debounce + ~2s visibly + /// collapsed. + static let idleSeconds: Double = 5.0 + + /// Chunks "pulled" per burst, and the gap between them, so the expanded read-out ticks 1 → 2 → 3 + /// the way a real offload does rather than showing one frozen number. + static let chunkTicks = 3 + static let chunkIntervalSeconds: Double = 1.2 + + /// Scan the launch args for `--demo-sync`. Call ONCE at launch, beside DemoDayHarness. Safe to call + /// always: with the flag absent `active` stays false and nothing changes. Idempotent. + static func applyLaunchArgsIfNeeded() { + active = CommandLine.arguments.contains("--demo-sync") + } +} +#endif diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index f9c7d18f30..0fa18db4c4 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -122,10 +122,14 @@ 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 (matches LiquidThread's default + the mockup #ff6b81). - private let liquidHeart = Color(.sRGB, red: 1, green: 107 / 255, blue: 129 / 255, opacity: 1) + /// The liquid heart pink shared with the sync indicator and LiquidThread. + private let liquidHeart = StrandPalette.liquidHeart /// Hero / session-start chrome uses theme-aware `NoopPanelSurface` (design-system surfaces that /// flip with Light/Dark). Upstream #1160/#1161 moved the classic RoundedRectangle hero onto /// `StrandPalette.heroFill` / `heroBorder` for the same theme-aware goal; #1068 keeps the panel @@ -453,7 +457,7 @@ struct LiquidTodayView: View { private var scene: some View { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top) { + ZStack(alignment: .topTrailing) { Button { showDayPicker = true } label: { VStack(alignment: .leading, spacing: 2) { Text(dayTitle) @@ -466,6 +470,7 @@ struct LiquidTodayView: View { .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.") @@ -478,11 +483,20 @@ struct LiquidTodayView: View { .frame(minWidth: 320, minHeight: 360) .liquidPopoverAdaptation() } - Spacer(minLength: 8) - HStack(spacing: 8) { + // 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: headerClusterControl, height: headerClusterControl) + Color.clear.frame( + width: NoopMetrics.compactControlSize, + height: NoopMetrics.compactControlSize + ) } .nativeLiquidGlassHeaderButton() .overlay { @@ -503,11 +517,28 @@ struct LiquidTodayView: View { Image(systemName: "slider.horizontal.3") .font(.system(size: 14, weight: .bold)) .foregroundStyle(StrandPalette.textPrimary) - .frame(width: headerClusterControl, height: headerClusterControl) + .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. @@ -1708,42 +1739,30 @@ private struct HeroScoreCell: View { // MARK: - Scene controls (LiveState-isolated leaves) -/// The liquid pull-to-refresh vessel + a "Syncing…" label. Owns LiveState (isolated leaf, per the file's -/// convention — see `LiquidLiveHR`) so a live-HR notify doesn't re-render the whole Today, but the vessel -/// still knows about an ONGOING strap backfill. +/// The liquid pull-to-refresh vessel + a "Syncing…" label. A pure gesture affordance: it answers "did my +/// pull do anything", and nothing else. +/// +/// It used to ALSO hold itself up for the whole of `live.backfilling`, because `ble.syncNow()` kicks off a +/// BLE history offload that far outlives the local `refreshing` flag (which flips false ~350ms after the +/// pull releases), and at the time the only other feedback was the easy-to-miss header `SyncStatusChip`. +/// `LiquidBatteryButton` is now that feedback — an ambient, always-on-screen signal that carries a live +/// chunk count — so the long tail belongs there and the vessel hands off to it instead of shadowing it. +/// Two surfaces reporting one signal is what this replaces: a 64pt banner AND a morphing header, both +/// running their own 60Hz clock (`LiquidVessel` has one too) for the same multi-hour offload. /// -/// Visibility used to be driven only by the local `refreshing` flag, which flips false ~350ms after the -/// pull releases (once the local repo reload + a short "let the fill read as done" delay complete) — but -/// `ble.syncNow()` kicks off a real BLE history offload that can run far longer than that. The vessel was -/// disappearing while the strap was still mid-sync, with no feedback beyond the easy-to-miss header -/// `SyncStatusChip`. `syncing` now also holds it (and the label) up while `live.backfilling` is true, so -/// releasing the pull and watching it go away actually means the sync finished. +/// No longer reads LiveState at all, so it is no longer an isolated leaf — there is nothing left to +/// isolate it from. private struct LiquidRefreshIndicator: View { let pullY: CGFloat let pullThreshold: CGFloat let refreshing: Bool let liquidHeart: Color - @EnvironmentObject private var live: LiveState - private var progress: CGFloat { min(1, max(0, pullY / pullThreshold)) } - /// The RAW "a sync is happening" signal. `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. A deep backlog is now up to ~24 chunks in ONE connection (#594 raised - /// the auto-continue cap 6→24), so binding the vessel straight to this strobes it in/out on every chunk - /// boundary. The MenuBar header pins a constant height for exactly this reason (see MenuBarContent). - private var syncingRaw: Bool { refreshing || live.backfilling } - - /// Debounced visibility that drives the body: goes true INSTANTLY, but only goes false after riding out - /// [hideDelay] with no new chunk — so a brief per-chunk `backfilling` gap can't flicker the vessel. - @State private var syncing = false - @State private var hideTask: Task? - private static let hideDelaySeconds: UInt64 = 3 // comfortably longer than an inter-chunk gap - var body: some View { ZStack { - if syncing { + if refreshing { VStack(spacing: 6) { LiquidVessel(value: 0.6, tint: liquidHeart, animated: true) .frame(width: 34, height: 34) @@ -1759,28 +1778,77 @@ private struct LiquidRefreshIndicator: View { } } .frame(maxWidth: .infinity) - .frame(height: syncing ? 64 : min(pullY, pullThreshold * 1.15)) - .animation(.easeOut(duration: 0.22), value: syncing) - .onAppear { syncing = syncingRaw } - .onChangeCompat(of: syncingRaw) { raw in - hideTask?.cancel() - if raw { - syncing = true // a sync (or pull) is active — show at once - } else { - // Might just be the gap between two chunks — wait it out; a new chunk cancels this. - hideTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: Self.hideDelaySeconds * 1_000_000_000) - if !Task.isCancelled { syncing = false } - } - } + .frame(height: refreshing ? 64 : min(pullY, pullThreshold * 1.15)) + .animation(.easeOut(duration: 0.22), value: refreshing) + } +} + +/// 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.) +/// +/// 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 +/// different ways, and the failure mode of letting them drift — an indicator that flickers only against a +/// strap carrying hours of history — is not reproducible at a desk. +private struct DebouncedSyncSignal: ViewModifier { + let raw: Bool + @Binding var debounced: Bool + @State private var hideTask: Task? + + func body(content: Content) -> some View { + content + .onAppear { apply(raw) } + .onChangeCompat(of: raw) { apply($0) } + .onDisappear { hideTask?.cancel() } + } + + private func apply(_ raw: Bool) { + hideTask?.cancel() + guard !raw else { + 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. + hideTask = Task { @MainActor in + try? await Task.sleep( + nanoseconds: StrandMotion.syncIndicatorSignalDebounceNanoseconds + ) + guard !Task.isCancelled else { return } + debounced = false } } } -/// The uniform diameter of the round Today-header controls — profile avatar, quick-add (+), strap battery -/// ring and Customize — so they sit level in the liquid cluster. Single source of truth (mirrors the -/// Android `HeaderClusterControl`); the syncing vessel is a separate affordance and keeps its own size. -private let headerClusterControl: CGFloat = 36 +private 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)) + } +} + +/// 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 { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +/// Gap between the round Today-header controls. iOS tightens it so the sync capsule has room to expand +/// on a phone-width header without crowding the day title; macOS has the window width to spare, so it +/// opens the cluster up instead of paying for space it does not need. +#if os(iOS) +private let headerClusterSpacing = NoopMetrics.space1 +#else +private let headerClusterSpacing = NoopMetrics.space3 +#endif private struct LiquidAddButton: View { @EnvironmentObject var router: NavRouter @@ -1789,7 +1857,10 @@ private struct LiquidAddButton: View { Image(systemName: "plus") .font(.system(size: 16, weight: .bold)) .foregroundStyle(StrandPalette.textPrimary) - .frame(width: headerClusterControl, height: headerClusterControl) + .frame( + width: NoopMetrics.compactControlSize, + height: NoopMetrics.compactControlSize + ) } .nativeLiquidGlassHeaderButton() .accessibilityLabel("Quick actions") @@ -2079,70 +2150,149 @@ extension LiquidTodayView { } } -/// Strap-battery ring. Owns LiveState. Tap → Devices. +/// Strap-battery ring. At sync start it briefly expands within the trailing control row, then settles into +/// an in-place spinner; the layered header keeps either state from moving the Today content. Tap → Devices. private struct LiquidBatteryButton: View { @EnvironmentObject var live: LiveState @EnvironmentObject var router: NavRouter - private var display: LiquidTodayView.StrapBatteryDisplay { - .resolve(connected: live.connected, batteryPct: live.batteryPct, charging: live.charging) + + /// Debounced by `debouncedSyncSignal` below, so a per-chunk `backfilling` gap cannot flash the + /// indicator back to the battery reading in the middle of one logical sync. + @State private var syncing = false + #if DEBUG + /// Driven only by the `--demo-sync` harness; ignored entirely when that flag is absent. + @State private var demoSyncing = false + /// Synthetic chunk tally for the harness, so the expanded read-out is exercised without a strap. + /// Kept local rather than written into LiveState — a demo aid must not touch real collector state. + @State private var demoChunks = 0 + #endif + + /// The raw, confirmed "strap history is syncing" signal. + /// + /// Pull-to-refresh is not evidence of an offload: `syncNow()` can still decline after its + /// connected/bonded gate when the connection handshake or backing store is not ready. A successful + /// `beginBackfill()` publishes `live.backfilling` synchronously, so that state is both prompt and the + /// only honest source for the header and its VoiceOver label. + private var syncingRaw: Bool { + #if DEBUG + if DemoSyncHarness.active { return demoSyncing } + #endif + return live.backfilling + } + + private var batteryDisplay: LiquidTodayView.StrapBatteryDisplay { + #if DEBUG + if DemoSyncHarness.active { + return .resolve( + connected: true, + batteryPct: DemoSyncHarness.batteryPercent, + charging: DemoSyncHarness.charging + ) + } + #endif + return .resolve( + connected: live.connected, + batteryPct: live.batteryPct, + charging: live.charging + ) } + + private var indicatorState: ChargeSyncIndicator.BatteryState { + switch batteryDisplay { + case .offline: + return .offline + case .pending(let charging): + return .pending(charging: charging) + case .charge(let percent, let charging): + return .charge(percent: percent, charging: charging) + } + } + var body: some View { Button { router.openDevices() } label: { - ZStack { - switch display { - case .charge(let pct, let charging): - Circle() - .trim(from: 0, to: max(0.02, min(1, pct / 100))) - .stroke(ringColor(pct), style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .padding(2.5) - Text("\(Int(pct.rounded()))") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(StrandPalette.textPrimary) - if charging { - // #972: the default Today never surfaced charging state — only the % ring. A small - // bolt over the ring gives the same signal as the "· Charging" text on Mac/Android. - Image(systemName: "bolt.fill") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(StrandPalette.chargeColor) - .offset(y: -10) - } - case .pending(let charging): - // Connected, no % yet. If the BATTERY_LEVEL event has told us we're charging, SAY so — - // that is the one thing we actually know, and it is the wearer's live question. - Image(systemName: charging ? "bolt.fill" : "ellipsis") - .font(.system(size: charging ? 11 : 9, weight: .bold)) - .foregroundStyle(charging ? StrandPalette.chargeColor : StrandPalette.textTertiary) - case .offline: - Image(systemName: "bolt.slash") - .font(.system(size: 11)) - .foregroundStyle(StrandPalette.textTertiary) - } - } - .frame(width: headerClusterControl, height: headerClusterControl) + ChargeSyncIndicator( + batteryState: indicatorState, + syncing: syncing, + chunks: syncChunks + ) } - .nativeLiquidGlassHeaderButton() + .nativeLiquidGlassSyncButton() .accessibilityLabel(batteryAccessibility) + .debouncedSyncSignal(syncingRaw, into: $syncing) + // DEBUG-gated at the CALL SITE too, not just in the body: in Release the harness must cost + // literally nothing, rather than an async task created and immediately returned per appearance. + #if DEBUG + .task { await runDemoSyncCycleIfNeeded() } + #endif + } + + /// DEBUG `--demo-sync` only: loop the syncing signal so the charge→sync morph plays in both + /// directions without a strap. Returns immediately in Release and whenever the flag is absent, and + /// `.task` cancels it on disappear. + private func runDemoSyncCycleIfNeeded() async { + #if DEBUG + guard DemoSyncHarness.active else { return } + while !Task.isCancelled { + try? await Task.sleep( + nanoseconds: UInt64(DemoSyncHarness.idleSeconds * 1_000_000_000) + ) + guard !Task.isCancelled else { return } + demoChunks = 0 + demoSyncing = true + // Tick the tally the way an offload does, so the expanded label is watched changing rather + // than appearing once and holding. + for tick in 1...DemoSyncHarness.chunkTicks { + try? await Task.sleep( + nanoseconds: UInt64(DemoSyncHarness.chunkIntervalSeconds * 1_000_000_000) + ) + guard !Task.isCancelled else { return } + demoChunks = tick + } + demoSyncing = false + } + #endif + } + + /// Chunks acked this session, shown inside the spinner where the battery percentage sits. The + /// expanded label stays "Syncing" — this is the numeric read-out, not the caption. + private var syncChunks: Int { + #if DEBUG + if DemoSyncHarness.active { return demoChunks } + #endif + return live.syncChunksThisSession } + /// Never "Strap battery" alone for a no-reading state — that was indistinguishable from a real one. private var batteryAccessibility: String { - switch display { + if syncing { + // `syncChunks` is a COUNT, not an index, so it reads "3 chunks" — the phrasing the Android + // twin and `SyncStatusChip` already use. Reusing that exact key also means this read-out + // inherits its existing translations rather than adding an untranslated variant. + // + // The SAME accessor the ring draws from, not `live.syncChunksThisSession` directly: in + // Release the two are identical, but under `--demo-sync` reading LiveState here would have + // VoiceOver announcing a real count while the ring showed the synthetic one — i.e. the + // harness could not be used to check the read-out it exists to exercise. + let n = syncChunks + return n > 0 + ? String(localized: "Syncing strap history, \(n) chunks") + : String(localized: "Syncing strap history") + } + + switch batteryDisplay { case .offline: return String(localized: "Strap battery, strap not connected") case .pending(let charging): return charging ? String(localized: "Strap battery charging, no reading yet") : String(localized: "Strap battery, no reading yet") - case .charge(let pct, let charging): - let n = Int(pct.rounded()) + case .charge(let percent, let charging): + let n = Int(percent.rounded()) return charging ? String(localized: "Strap battery \(n) percent, charging") : String(localized: "Strap battery \(n) percent") } } - private func ringColor(_ p: Double) -> Color { - p < 15 ? StrandPalette.statusCritical : p < 35 ? StrandPalette.statusWarning : StrandPalette.chargeColor - } } private extension View { @@ -2166,6 +2316,36 @@ private extension View { .overlay(Circle().strokeBorder(.white.opacity(0.16), lineWidth: 0.8)) } } + + /// Exact-bounds glass for the charge-to-sync morph. The same Capsule stretches only while its label + /// expands. Not `nativeLiquidGlassButtonChrome(capsule:)`: `.buttonBorderShape(.capsule)` applies the + /// system's capsule metrics, which pad wider than tall and render the compact 36-point state as a + /// pill — hence the manual, equal padding here, which keeps it circular. + /// + /// iOS 26 matches the sibling `.glass` circles, whose own `.small` chrome insets the label by the + /// same amount. The fallbacks add no padding: their siblings draw the material straight onto a + /// 36-point label, so a Capsule over the identical 36×36 frame is already that circle. + @ViewBuilder + func nativeLiquidGlassSyncButton() -> some View { + #if os(iOS) + if #available(iOS 26.0, *) { + self + .buttonStyle(.plain) + .padding(NoopMetrics.syncIndicatorGlassPadding) + .glassEffect(.regular.interactive(), in: Capsule()) + } else { + self + .buttonStyle(LiquidPressStyle()) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.16), lineWidth: 0.8)) + } + #else + self + .buttonStyle(LiquidPressStyle()) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.16), lineWidth: 0.8)) + #endif + } } /// Strap-history sync state inside the Data Sources card. Owns LiveState; display-only. @@ -2181,7 +2361,7 @@ private extension View { /// it has pulled, and when one last completed. It does NOT yet say "~15h behind" — that needs the /// persisted data frontier (max HR ts) compared against `strapRange.newestUnix`, and the frontier is a /// Repository read that LiveState does not carry. That remains open in B1. Kept here in the Data Sources -/// card as the detailed view; the Devices screen now owns the larger at-a-glance sync card. +/// card as the detailed view; `LiquidBatteryButton` above is the header's ambient at-a-glance signal. private struct LiquidSyncStatusRow: View { @EnvironmentObject var live: LiveState var body: some View { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 2f924dec48..6e511e649d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -137529,54 +137529,226 @@ } } }, - "Syncing strap history, %lld chunks": { + "Syncing strap history": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks" + "value": "Strap-Verlauf wird synchronisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history" } }, "es": { "stringUnit": { "state": "translated", - "value": "Sincronizando el historial de la pulsera, %lld bloques" + "value": "Sincronizando el historial de la pulsera" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Synchronisation de l'historique du bracelet, %lld blocs" + "value": "Synchronisation de l'historique du bracelet" } }, "it": { "stringUnit": { "state": "translated", - "value": "Sincronizzazione della cronologia della fascia, %lld blocchi" + "value": "Sincronizzazione della cronologia della fascia" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "A sincronizar o histórico da bracelete, %lld pedaços" + "value": "A sincronizar o histórico da bracelete" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Синхронизация истории браслета, %lld фрагментов" + "value": "Синхронизирую историю браслета" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "正在同步手环历史记录,%lld 个数据块" + "value": "正在同步手环历史" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "正在同步手環歷史記錄,%lld 個資料塊" + "value": "正在同步手環歷史" + } + } + } + }, + "Syncing strap history, %lld chunks": { + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunk" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunks" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloque" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloques" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld bloc" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld blocs" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocco" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocchi" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaço" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaços" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмент" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагментов" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手环历史记录,%lld 个数据块" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手環歷史記錄,%lld 個資料塊" + } + } + } } } } diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 4dc93433e6..4196a3f8b4 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -45,6 +45,11 @@ struct StrandiOSApp: App { // MUST live here, not in StrandApp.swift — that is the macOS @main and is excluded from the iOS // target, so the hook there never runs on iOS. DemoDayHarness.applyLaunchArgsIfNeeded() + // DEBUG-only sync harness: `--demo-sync` drives the Today header's charge→sync control with a + // synthetic battery + a looping sync signal, so the morph is watchable with no strap paired. + // Same reason this lives here rather than StrandApp.swift: that file is the macOS @main and is + // excluded from the iOS target. See DemoSyncHarness.swift. + DemoSyncHarness.applyLaunchArgsIfNeeded() #endif // Debug-only canary: trips if the App Group entitlement is missing on this target before any // silent no-op (PendingIntents, WidgetSnapshot.publish, Live Activity) can mask the issue as