diff --git a/PulseLoop/Views/OnboardingFlowView.swift b/PulseLoop/Views/OnboardingFlowView.swift index ad3d83d..0346642 100644 --- a/PulseLoop/Views/OnboardingFlowView.swift +++ b/PulseLoop/Views/OnboardingFlowView.swift @@ -145,65 +145,97 @@ private struct OnboardingTopBar: View { } } +/// One icon + title + one-line detail + tint, shared by the Welcome feature tiles and the Baseline +/// timeline. A named struct rather than a 4-tuple (SwiftLint `large_tuple`) — `title` is the identity. +struct OnboardingItem: Identifiable { + let icon: String + let title: String + let detail: String + let tint: Color + var id: String { title } +} + struct OnboardingWelcomeView: View { let getStarted: () -> Void let exploreWithoutRing: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + private let features = [ - ("dollarsign.circle.fill", "No subscription", "Own your ring data", PulseColors.success), - ("lock.shield.fill", "Privacy first", "Data stays on device", PulseColors.info), - ("sparkles", "AI health coach", "Learns your baseline", PulseColors.accent), - ("waveform.path.ecg", "See your vitals", "HR, SpO₂, HRV & stress", PulseColors.heartRate), - ("moon.stars.fill", "Sleep tracking", "Stages and trends", PulseColors.sleep), - ("figure.run", "Activity recording", "Live workout tracking", PulseColors.steps), + OnboardingItem(icon: "dollarsign.circle.fill", title: "No subscription", detail: "Own your ring data", tint: PulseColors.success), + OnboardingItem(icon: "lock.shield.fill", title: "Privacy first", detail: "Stays on your device", tint: PulseColors.info), + OnboardingItem(icon: "sparkles", title: "AI coach", detail: "Learns your baseline", tint: PulseColors.accent), + OnboardingItem(icon: "waveform.path.ecg", title: "Your vitals", detail: "HR, SpO₂, HRV, stress", tint: PulseColors.heartRate), + OnboardingItem(icon: "moon.stars.fill", title: "Sleep tracking", detail: "Stages & trends", tint: PulseColors.sleep), + OnboardingItem(icon: "figure.run", title: "Workouts", detail: "Live activity tracking", tint: PulseColors.steps), ] private let columns = [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), ] + private let headerSubtitle = "Your health, on your terms — no subscription, no cloud." + var body: some View { - ScrollView { - VStack(spacing: 18) { - Image("pulseloop-logo") - .resizable() - .scaledToFit() - .frame(width: 92, height: 92) - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: PulseColors.accent.opacity(0.22), radius: 18) - .accessibilityHidden(true) + Group { + if dynamicTypeSize.isAccessibilitySize { + accessibilityBody + } else { + fittedBody + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + OnboardingActionFooter { footer } + } + } - CompactOnboardingHeader( - title: "Set up PulseLoop" - ) + // Fitted, no-scroll layout for standard Dynamic Type sizes. + private var fittedBody: some View { + OnboardingFittedBand { s in + VStack(spacing: 0) { + logo(size: (78 * s).rounded()) + Spacer().frame(height: (14 * s).rounded()) + + FittedOnboardingHeader(title: "Set up PulseLoop", subtitle: headerSubtitle, s: s) + + Spacer().frame(height: (16 * s).rounded()) + + LazyVGrid(columns: columns, spacing: 10) { + ForEach(features) { feature in + tile(feature, s: s) + } + } + } + } + } + + // Accessibility fallback: keep the scrolling body so large type never clips. + private var accessibilityBody: some View { + ScrollView { + VStack(spacing: 18) { + logo(size: 92) + CompactOnboardingHeader(title: "Set up PulseLoop", subtitle: headerSubtitle) LazyVGrid(columns: columns, spacing: 12) { - ForEach(features, id: \.1) { feature in + ForEach(features) { feature in VStack(spacing: 8) { - Image(systemName: feature.0) - .font(PulseFont.title3) - .foregroundStyle(feature.3) - .frame(width: 38, height: 38) - .background(feature.3.opacity(0.14), in: RoundedRectangle(cornerRadius: 11)) - Text(feature.1) + tileIcon(feature, s: 1) + Text(feature.title) .font(PulseFont.headline) .foregroundStyle(PulseColors.textPrimary) .multilineTextAlignment(.center) - .lineLimit(2) - Text(feature.2) + Text(feature.detail) .font(PulseFont.footnote.weight(.regular)) .foregroundStyle(PulseColors.textMuted) .multilineTextAlignment(.center) - .lineLimit(2) } .frame(maxWidth: .infinity) - .frame(height: 118, alignment: .center) + .padding(.vertical, 14) .padding(.horizontal, 13) .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } - } .frame(maxWidth: 560) .padding(.horizontal, 24) @@ -212,17 +244,55 @@ struct OnboardingWelcomeView: View { .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) - .safeAreaInset(edge: .bottom, spacing: 0) { - OnboardingActionFooter { - VStack(spacing: 10) { - PrimaryButton(title: "Get started", systemImage: "arrow.right", action: getStarted) - SecondaryButton( - title: "Explore without ring", - systemImage: "square.grid.2x2", - action: exploreWithoutRing - ) - } - } + } + + private func logo(size: CGFloat) -> some View { + Image("pulseloop-logo") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: PulseColors.accent.opacity(0.22), radius: 16) + .accessibilityHidden(true) + } + + private func tileIcon(_ feature: OnboardingItem, s: CGFloat) -> some View { + Image(systemName: feature.icon) + .font(.system(size: (18 * s).rounded())) + .foregroundStyle(feature.tint) + .frame(width: (34 * s).rounded(), height: (34 * s).rounded()) + .background(feature.tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) + } + + private func tile(_ feature: OnboardingItem, s: CGFloat) -> some View { + VStack(spacing: 6) { + tileIcon(feature, s: s) + Text(feature.title) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.85) + Text(feature.detail) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.9) + } + .frame(maxWidth: .infinity) + .frame(height: (92 * s).rounded(), alignment: .center) + .padding(.horizontal, 13) + .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private var footer: some View { + VStack(spacing: 10) { + PrimaryButton(title: "Get started", systemImage: "arrow.right", action: getStarted) + Button("Explore without ring", action: exploreWithoutRing) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textSecondary) + .frame(height: 44) } } } @@ -337,71 +407,100 @@ struct OnboardingGoalsView: View { struct OnboardingBaselineView: View { let finish: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + // Drives the medallion (rings + checkmark) and staggered timeline reveal. + @State private var appeared = false + + // icon / title / one-line description / node tint. "1" renders as a numbered chip. private let milestones = [ - ("1", "Day 1", "Basic activity and vitals", PulseColors.info), - ("moon.fill", "After sleep", "Sleep trends", PulseColors.sleep), - ("chart.line.uptrend.xyaxis", "After 3–7 days", "Personalized baseline", PulseColors.success), + OnboardingItem( + icon: "1", title: "Today", + detail: "Activity and live vitals, right away", tint: PulseColors.info + ), + OnboardingItem( + icon: "moon.fill", title: "First sync", + detail: "Sleep stages and nightly trends", tint: PulseColors.sleep + ), + OnboardingItem( + icon: "chart.line.uptrend.xyaxis", title: "Days 3–7", + detail: "Your personalized baseline unlocks", tint: PulseColors.success + ), ] var body: some View { - ScrollView { - VStack(spacing: 20) { - CompactOnboardingHeader( - title: "You're ready", - subtitle: "A little context before your first day with PulseLoop." - ) - - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 8) { - Text("PulseLoop learns your baseline over time") - .font(PulseFont.numberM) - .foregroundStyle(PulseColors.textPrimary) - Text("Wear your ring during the day and sync after sleep. Trends become more personal after a few days.") - .font(PulseFont.subheadline.weight(.regular)) - .foregroundStyle(PulseColors.textSecondary) - .lineSpacing(4) - } + Group { + if dynamicTypeSize.isAccessibilitySize { + accessibilityBody + } else { + fittedBody + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + OnboardingActionFooter { + PrimaryButton(title: "Start using PulseLoop", systemImage: "arrow.right", action: finish) + } + } + .onAppear { + if reduceMotion { + appeared = true + } else { + withAnimation(PulseMotion.bouncy) { appeared = true } + } + } + } - ForEach(milestones, id: \.1) { milestone in - HStack(spacing: 12) { - Group { - if milestone.0 == "1" { - Text("1").font(PulseFont.footnote.weight(.bold)) - } else { - Image(systemName: milestone.0).font(PulseFont.footnote.weight(.semibold)) - } - } - .foregroundStyle(milestone.3) - .frame(width: 34, height: 34) - .background(milestone.3.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) - - VStack(alignment: .leading, spacing: 2) { - Text(milestone.1) - .font(PulseFont.subheadline.weight(.semibold)) - .foregroundStyle(PulseColors.textPrimary) - Text(milestone.2) - .font(PulseFont.footnote.weight(.regular)) - .foregroundStyle(PulseColors.textMuted) - } - Spacer() - } - .accessibilityElement(children: .combine) - } + private var fittedBody: some View { + OnboardingFittedBand { s in + VStack(spacing: 0) { + medallion(s: s) + + Spacer().frame(height: (16 * s).rounded()) + + eyebrow + + VStack(spacing: 6) { + Text("You're all set") + .font(PulseFont.largeTitle) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text("Wear your ring today. Your baseline builds from here.") + .font(PulseFont.callout) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.85) } - .padding(20) - .background( - LinearGradient( - colors: [PulseColors.accent.opacity(0.14), PulseColors.card], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(PulseColors.borderSubtle, lineWidth: 1) - ) + .padding(.top, 4) + + Spacer().frame(height: (20 * s).rounded()) + timeline(s: s) + } + } + } + + // Accessibility fallback: linear scrolling layout, no scale, no motion gating needed + // (the .onAppear still sets `appeared = true` so nothing stays hidden). + private var accessibilityBody: some View { + ScrollView { + VStack(spacing: 20) { + medallion(s: 1) + eyebrow + VStack(spacing: 6) { + Text("You're all set") + .font(PulseFont.largeTitle) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + Text("Wear your ring today. Your baseline builds from here.") + .font(PulseFont.callout) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + } + timeline(s: 1) } .frame(maxWidth: 560) .padding(.horizontal, 24) @@ -410,11 +509,121 @@ struct OnboardingBaselineView: View { .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) - .safeAreaInset(edge: .bottom, spacing: 0) { - OnboardingActionFooter { - PrimaryButton(title: "Go to app", systemImage: "arrow.right", action: finish) + } + + // MARK: - Tier 1: animated success medallion + + private func medallion(s: CGFloat) -> some View { + ZStack { + // Outer concentric ring. + Circle() + .stroke(PulseColors.accent.opacity(0.12), lineWidth: 2) + .frame(width: (140 * s).rounded(), height: (140 * s).rounded()) + .scaleEffect(appeared ? 1.0 : 0.7) + .opacity(appeared ? 1.0 : 0.0) + // Inner concentric ring (staggered). + Circle() + .stroke(PulseColors.accent.opacity(0.25), lineWidth: 2) + .frame(width: (104 * s).rounded(), height: (104 * s).rounded()) + .scaleEffect(appeared ? 1.0 : 0.7) + .opacity(appeared ? 1.0 : 0.0) + .animation(reduceMotion ? nil : PulseMotion.bouncy.delay(0.06), value: appeared) + + Image(systemName: "checkmark.seal.fill") + .font(.system(size: (72 * s).rounded())) + .foregroundStyle(PulseColors.accent) + .padding((14 * s).rounded()) + .pulseGlass(Circle()) + .scaleEffect(appeared ? 1.0 : 0.3) + .animation(reduceMotion ? nil : .spring(response: 0.5, dampingFraction: 0.6), value: appeared) + } + .pulseGlassContainer() + .accessibilityHidden(true) + } + + private var eyebrow: some View { + HStack(spacing: 6) { + Image(systemName: "checkmark.seal.fill") + .font(PulseFont.caption) + Text("Setup complete") + .font(PulseFont.caption.weight(.semibold)) + } + .foregroundStyle(PulseColors.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .pulseGlass(Capsule(), tint: PulseColors.accent.opacity(0.16)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Setup complete") + } + + // MARK: - Tier 3: connected vertical glass timeline + + private func timeline(s: CGFloat) -> some View { + VStack(alignment: .leading, spacing: (18 * s).rounded()) { + ForEach(Array(milestones.enumerated()), id: \.element.id) { index, milestone in + milestoneRow(milestone, index: index, s: s, isLast: index == milestones.count - 1) + } + } + .padding((20 * s).rounded()) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + LinearGradient( + colors: [PulseColors.accent.opacity(0.14), PulseColors.card], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: (22 * s).rounded(), style: .continuous) + ) + .pulseGlass(RoundedRectangle(cornerRadius: (22 * s).rounded(), style: .continuous)) + } + + private func milestoneRow( + _ milestone: OnboardingItem, + index: Int, + s: CGFloat, + isLast: Bool + ) -> some View { + let node = (40 * s).rounded() + return HStack(alignment: .top, spacing: 12) { + // Glass node + connecting line to the next node. + VStack(spacing: 0) { + Group { + if milestone.icon == "1" { + Text("1").font(PulseFont.footnote.weight(.bold)) + } else { + Image(systemName: milestone.icon).font(PulseFont.footnote.weight(.semibold)) + } + } + .foregroundStyle(milestone.tint) + .frame(width: node, height: node) + .pulseGlass(Circle(), tint: milestone.tint.opacity(0.18)) + + if !isLast { + Rectangle() + .fill(milestone.tint.opacity(0.35)) + .frame(width: 2) + .frame(maxHeight: .infinity) + } + } + .frame(width: node) + + VStack(alignment: .leading, spacing: 2) { + Text(milestone.title) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Text(milestone.detail) + .font(PulseFont.footnote.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) } + .padding(.top, (node - 26) / 2) // vertically center title against the node + Spacer(minLength: 0) } + .fixedSize(horizontal: false, vertical: true) + .opacity(appeared ? 1.0 : 0.0) + .offset(y: appeared ? 0 : 8) + .animation(reduceMotion ? nil : PulseMotion.materialize.delay(0.12 * Double(index)), value: appeared) + .accessibilityElement(children: .combine) } } @@ -427,33 +636,6 @@ struct OnboardingPairView: View { } } -private struct CompactOnboardingHeader: View { - let title: String - let subtitle: String? - - init(title: String, subtitle: String? = nil) { - self.title = title - self.subtitle = subtitle - } - - var body: some View { - VStack(spacing: 8) { - Text(title) - .font(PulseFont.numberXL) - .foregroundStyle(PulseColors.textPrimary) - .multilineTextAlignment(.center) - if let subtitle { - Text(subtitle) - .font(PulseFont.callout.weight(.regular)) - .foregroundStyle(PulseColors.textSecondary) - .multilineTextAlignment(.center) - .lineSpacing(3) - } - } - .frame(maxWidth: .infinity) - } -} - struct OnboardingActionFooter: View { @ViewBuilder let content: Content diff --git a/PulseLoop/Views/OnboardingLayout.swift b/PulseLoop/Views/OnboardingLayout.swift new file mode 100644 index 0000000..c7d356e --- /dev/null +++ b/PulseLoop/Views/OnboardingLayout.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Shared responsive primitive for the "fit-to-viewport" onboarding screens (steps 1, 2, 5). +/// +/// The problem it solves: steps 1/2/5 used to be wrapped in a `ScrollView` sized for a Pro Max, so +/// on smaller iPhones (SE/mini) the content overflowed and tiles clipped behind the footer. Instead +/// of scrolling, `OnboardingFittedBand` measures the available height, derives a scale `s`, and hands +/// it to the content so every dimension compresses proportionally — the screen fits centered with no +/// scroll on every iPhone. Accessibility Dynamic Type sizes fall back to the old ScrollView body +/// (handled at each call site), because compressing type past the user's chosen size is user-hostile. +enum OnboardingLayout { + /// Reference content-band height (a comfortable Pro). Below → compress, above → cap. + static let referenceContentHeight: CGFloat = 560 + /// Clamp so small phones compress to ~0.80 and large never exceed 1.06. + static func scale(for contentHeight: CGFloat) -> CGFloat { + min(1.06, max(0.80, contentHeight / referenceContentHeight)) + } +} + +/// Non-scrolling content band: measures available height, hands a scale `s` to content, and +/// centers it vertically (Spacer pair) so tall phones get balanced breathing room, not dead gaps. +struct OnboardingFittedBand: View { + @ViewBuilder var content: (_ s: CGFloat) -> Content + var body: some View { + GeometryReader { geo in + let s = OnboardingLayout.scale(for: geo.size.height) + VStack(spacing: 0) { + Spacer(minLength: 0) + content(s) + Spacer(minLength: 0) + } + .frame(maxWidth: 560) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 24) + } + } +} + +/// Onboarding header whose type tier follows the fitted scale `s`: at generous heights it reads big +/// and confident; when compressed it steps down so the title never wraps or crowds the content below. +/// Used by the fitted screens; the non-fitted (form) steps keep the plain `CompactOnboardingHeader`. +struct FittedOnboardingHeader: View { + let title: String + let subtitle: String? + let s: CGFloat + + init(title: String, subtitle: String? = nil, s: CGFloat) { + self.title = title + self.subtitle = subtitle + self.s = s + } + + private var titleFont: Font { s >= 0.92 ? PulseFont.numberXL : PulseFont.numberL } + private var subtitleFont: Font { + s >= 0.92 ? PulseFont.callout.weight(.regular) : PulseFont.subheadline.weight(.regular) + } + + var body: some View { + VStack(spacing: 8) { + Text(title) + .font(titleFont) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.85) + if let subtitle { + Text(subtitle) + .font(subtitleFont) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.85) + .lineSpacing(3) + } + } + .frame(maxWidth: .infinity) + } +} + +/// Plain, non-scaling onboarding header used by the accessibility ScrollView fallbacks and the +/// form steps (Profile/Goals). Kept as a shared component so all onboarding headers match. +struct CompactOnboardingHeader: View { + let title: String + let subtitle: String? + + init(title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + + var body: some View { + VStack(spacing: 8) { + Text(title) + .font(PulseFont.numberXL) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + if let subtitle { + Text(subtitle) + .font(PulseFont.callout.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/PulseLoop/Views/PairingView.swift b/PulseLoop/Views/PairingView.swift index d88ac75..606a18b 100644 --- a/PulseLoop/Views/PairingView.swift +++ b/PulseLoop/Views/PairingView.swift @@ -12,6 +12,7 @@ import UIKit struct PairingView: View { @Environment(RingBLEClient.self) private var ble @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize /// Pushed onto the Settings nav stack (no onboarding "Skip"): show our own glass /// back button and hide the system nav bar so it doesn't stack a second, empty @@ -185,7 +186,56 @@ struct PairingView: View { } } + /// The bare default state (Bluetooth ready, not connected, not scanning) fits centered without + /// scrolling on every iPhone — but only at standard Dynamic Type sizes. Scanning lists, the + /// connected/bluetooth-off states, and accessibility sizes keep the scrolling layout so they + /// never clip. `errorText` is nil-friendly and pulled out so both layouts share it. + private var isFittedDefaultState: Bool { + canUseBluetoothUI + && ble.state != .connected + && !isLooking + && !dynamicTypeSize.isAccessibilitySize + } + private var scrollContent: some View { + Group { + if isFittedDefaultState { + fittedDefault + } else { + scrollingContent + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if showsActionFooter { + OnboardingActionFooter { pairingFooterContent } + } + } + } + + // Fitted, no-scroll default: header + carousel sized to the viewport via the shared band. + private var fittedDefault: some View { + OnboardingFittedBand { s in + VStack(spacing: 0) { + FittedOnboardingHeader( + title: "Add your ring", + subtitle: "Swipe to find your model, then tap to connect.", + s: s + ) + .frame(maxWidth: .infinity) + + Spacer().frame(height: (14 * s).rounded()) + + carousel(s: s) + + if hasError { + Spacer().frame(height: 12) + errorText + } + } + } + } + + private var scrollingContent: some View { ScrollView { // Tight vertical rhythm so the whole picker — carousel *and* its scrub bar — clears the // pinned footer without scrolling. When it didn't, the scrub bar sat behind the footer, @@ -203,21 +253,12 @@ struct PairingView: View { } else if ble.state == .connected { connectedCard } else { - carousel + carousel(s: 1) if isLooking { scanningArea } } - if let error = ble.lastError, - ble.state != .connected, - !forcePairingUIForTesting { - VStack(spacing: 10) { - Text(error) - .font(.caption) - .foregroundStyle(PulseColors.danger) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - variantRetry - } + if hasError { + errorText } } @@ -228,28 +269,44 @@ struct PairingView: View { } .scrollBounceBehavior(.basedOnSize) // static when it fits; scrolls only if content overflows // (small devices / scanning list) so nothing clips - .safeAreaInset(edge: .bottom, spacing: 0) { - if showsActionFooter { - OnboardingActionFooter { pairingFooterContent } + } + + private var hasError: Bool { + ble.lastError != nil && ble.state != .connected && !forcePairingUIForTesting + } + + @ViewBuilder + private var errorText: some View { + if let error = ble.lastError { + VStack(spacing: 10) { + Text(error) + .font(.caption) + .foregroundStyle(PulseColors.danger) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + variantRetry } } } // MARK: - Carousel - private var carousel: some View { - VStack(spacing: 6) { + private func carousel(s: CGFloat) -> some View { + let ringSize = (168 * s).rounded() + return VStack(spacing: (6 * s).rounded()) { brandTabs TabView(selection: $selectedIndex) { ForEach(Array(models.enumerated()), id: \.element.id) { index, model in // The picker only applies to the card in front; every other page shows its default. let variant = model.id == selectedModel.id ? effectiveVariant : nil - VStack(spacing: 10) { - RingArtView(tint: model.tint, imageName: model.imageName) + VStack(spacing: (10 * s).rounded()) { + RingArtView(tint: model.tint, size: ringSize, imageName: model.imageName) Text(model.displayName) .font(PulseFont.numberL) .foregroundStyle(PulseColors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.85) SupportBadge(level: model.supportLevel(for: variant)) // nothing when fully supported CapabilityChips(blurb: model.blurb(for: variant)) // §2 replaces blurb Text } @@ -259,9 +316,9 @@ struct PairingView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) // §2 Fix #2 — dots moved to modelDotRow - // Hugs the tallest page (art + name + support badge + chips). Slack here shows up as dead - // space between the chips and the scrub bar below, so keep it tight. - .frame(height: 292) + // Derive from the scaled ring art plus a fixed band for the tallest page (name + support + // badge + chips). Slack shows up as dead space above the scrub bar, so keep the band tight. + .frame(height: (ringSize + 112).rounded()) .id(selectedBrand) // recreate on brand change so pages swap instantly (no page-slide) modelDotRow // §2 fixed-height dot area keeps layout stable across tabs @@ -350,7 +407,7 @@ struct PairingView: View { private var pairingFooterContent: some View { VStack(spacing: 10) { if !isLooking, canUseBluetoothUI { - PrimaryButton(title: "Connect ring", systemImage: "dot.radiowaves.left.and.right") { + PrimaryButton(title: "Connect my ring", systemImage: "dot.radiowaves.left.and.right") { isLooking = true successHaptic.prepare() ble.startScanning() @@ -363,11 +420,10 @@ struct PairingView: View { } if let onSkip { - SecondaryButton(title: "Skip for now", systemImage: "arrow.right", action: onSkip) - Text("You can pair a ring later from Settings.") - .font(PulseFont.caption.weight(.regular)) + Button("Skip for now", action: onSkip) + .font(PulseFont.subheadline.weight(.semibold)) .foregroundStyle(PulseColors.textMuted) - .multilineTextAlignment(.center) + .frame(height: 44) } } }