From 93fb42d261e3489245ca47dabbd44e8bc8874df1 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:53:05 +1000 Subject: [PATCH 1/2] feat(onboarding): responsive fit-to-viewport + copy + celebratory finale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding steps 1/2/5 were each wrapped in a ScrollView with fixed Pro-Max dimensions, so content overflowed and scrolled on smaller iPhones (tiles clipped behind the footer). Rework them to fit centered with no scroll on every size. - New OnboardingLayout.swift: OnboardingFittedBand — a GeometryReader band that derives a scale s = clamp(height/560, 0.80…1.06) and centers content with a Spacer pair. Scaled dims = base*s; button heights stay fixed (56/52/44). Dynamic-Type accessibility sizes fall back to the original ScrollView bodies. Also hosts the shared FittedOnboardingHeader + CompactOnboardingHeader. - Step 1 (Welcome): rebuilt on the fitted band; fixed even-height 1-line tiles (was height 118 + lineLimit 2 → uneven wrap/clipping); shorter tile copy; header subtitle; 'Explore without ring' demoted from a capsule to a quiet text button (Get started stays primary). - Step 5 (Baseline): redesigned finale — animated success medallion, largeTitle 'You're all set', and a connected vertical glass timeline (Today / First sync / Days 3–7), centered so the dead gap is gone. Button → 'Start using PulseLoop'. - Step 2 (Pairing): fitted band for the default state only (scanning/connected/ accessibility keep ScrollView); carousel height derived from the ring-art size; 'Skip for now' → text button; 'Connect ring' → 'Connect my ring'. - Steps 3/4 (Profile/Goals): kept scrollable (forms); share the header for visual consistency, no behavior change. All animations gated behind Reduce Motion; tap targets >= 44pt. --- PulseLoop/Views/OnboardingFlowView.swift | 417 ++++++++++++++++------- PulseLoop/Views/OnboardingLayout.swift | 107 ++++++ PulseLoop/Views/PairingView.swift | 102 ++++-- 3 files changed, 476 insertions(+), 150 deletions(-) create mode 100644 PulseLoop/Views/OnboardingLayout.swift diff --git a/PulseLoop/Views/OnboardingFlowView.swift b/PulseLoop/Views/OnboardingFlowView.swift index ad3d83d..5b0659c 100644 --- a/PulseLoop/Views/OnboardingFlowView.swift +++ b/PulseLoop/Views/OnboardingFlowView.swift @@ -149,61 +149,83 @@ 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), + ("lock.shield.fill", "Privacy first", "Stays on your device", PulseColors.info), + ("sparkles", "AI coach", "Learns your baseline", PulseColors.accent), + ("waveform.path.ecg", "Your vitals", "HR, SpO₂, HRV, stress", PulseColors.heartRate), + ("moon.stars.fill", "Sleep tracking", "Stages & trends", PulseColors.sleep), + ("figure.run", "Workouts", "Live activity tracking", 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, id: \.1) { 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 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)) + tileIcon(feature, s: 1) Text(feature.1) .font(PulseFont.headline) .foregroundStyle(PulseColors.textPrimary) .multilineTextAlignment(.center) - .lineLimit(2) Text(feature.2) .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 +234,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: (String, String, String, Color), s: CGFloat) -> some View { + Image(systemName: feature.0) + .font(.system(size: (18 * s).rounded())) + .foregroundStyle(feature.3) + .frame(width: (34 * s).rounded(), height: (34 * s).rounded()) + .background(feature.3.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) + } + + private func tile(_ feature: (String, String, String, Color), s: CGFloat) -> some View { + VStack(spacing: 6) { + tileIcon(feature, s: s) + Text(feature.1) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.85) + Text(feature.2) + .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 +397,91 @@ 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), + ("1", "Today", "Activity and live vitals, right away", PulseColors.info), + ("moon.fill", "First sync", "Sleep stages and nightly trends", PulseColors.sleep), + ("chart.line.uptrend.xyaxis", "Days 3–7", "Your personalized baseline unlocks", 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 +490,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.1) { 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: (String, String, String, Color), + 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.0 == "1" { + Text("1").font(PulseFont.footnote.weight(.bold)) + } else { + Image(systemName: milestone.0).font(PulseFont.footnote.weight(.semibold)) + } + } + .foregroundStyle(milestone.3) + .frame(width: node, height: node) + .pulseGlass(Circle(), tint: milestone.3.opacity(0.18)) + + if !isLast { + Rectangle() + .fill(milestone.3.opacity(0.35)) + .frame(width: 2) + .frame(maxHeight: .infinity) + } + } + .frame(width: node) + + 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) + .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 +617,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 06131c6..42c202b 100644 --- a/PulseLoop/Views/PairingView.swift +++ b/PulseLoop/Views/PairingView.swift @@ -11,6 +11,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 @@ -128,7 +129,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 { VStack(spacing: 24) { OnboardingHeader( @@ -143,18 +193,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 { - Text(error) - .font(.caption) - .foregroundStyle(PulseColors.danger) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + if hasError { + errorText } } @@ -164,26 +208,39 @@ 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 { + Text(error) + .font(.caption) + .foregroundStyle(PulseColors.danger) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) } } // MARK: - Carousel - private var carousel: some View { - VStack(spacing: 12) { + private func carousel(s: CGFloat) -> some View { + let ringSize = (168 * s).rounded() + return VStack(spacing: (12 * s).rounded()) { brandTabs TabView(selection: $selectedIndex) { ForEach(Array(models.enumerated()), id: \.element.id) { index, model in - VStack(spacing: 16) { - RingArtView(tint: model.tint, imageName: model.imageName) + VStack(spacing: (16 * 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) CapabilityChips(blurb: model.blurb) // §2 replaces blurb Text } .frame(maxWidth: .infinity) // constant page width so content doesn't drive reflow @@ -192,7 +249,7 @@ struct PairingView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) // §2 Fix #2 — dots moved to modelDotRow - .frame(height: 300) // §2 Fix #2 + .frame(height: (ringSize + 78).rounded()) // §2 derive from ring art + name/chip band .id(selectedBrand) // recreate on brand change so pages swap instantly (no page-slide) modelDotRow // §2 fixed-height dot area keeps layout stable across tabs @@ -270,7 +327,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() @@ -283,11 +340,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) } } } From 1012eb0614a513dac765d21ac6c1a6041b383f95 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Thu, 16 Jul 2026 00:21:15 -0400 Subject: [PATCH 2/2] fix(onboarding): replace 4-member tuples with OnboardingItem struct to satisfy SwiftLint large_tuple --- PulseLoop/Views/OnboardingFlowView.swift | 79 +++++++++++++++--------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/PulseLoop/Views/OnboardingFlowView.swift b/PulseLoop/Views/OnboardingFlowView.swift index 5b0659c..0346642 100644 --- a/PulseLoop/Views/OnboardingFlowView.swift +++ b/PulseLoop/Views/OnboardingFlowView.swift @@ -145,6 +145,16 @@ 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 @@ -152,12 +162,12 @@ struct OnboardingWelcomeView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize private let features = [ - ("dollarsign.circle.fill", "No subscription", "Own your ring data", PulseColors.success), - ("lock.shield.fill", "Privacy first", "Stays on your device", PulseColors.info), - ("sparkles", "AI coach", "Learns your baseline", PulseColors.accent), - ("waveform.path.ecg", "Your vitals", "HR, SpO₂, HRV, stress", PulseColors.heartRate), - ("moon.stars.fill", "Sleep tracking", "Stages & trends", PulseColors.sleep), - ("figure.run", "Workouts", "Live activity 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 = [ @@ -193,7 +203,7 @@ struct OnboardingWelcomeView: View { Spacer().frame(height: (16 * s).rounded()) LazyVGrid(columns: columns, spacing: 10) { - ForEach(features, id: \.1) { feature in + ForEach(features) { feature in tile(feature, s: s) } } @@ -208,14 +218,14 @@ struct OnboardingWelcomeView: View { 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) { tileIcon(feature, s: 1) - Text(feature.1) + Text(feature.title) .font(PulseFont.headline) .foregroundStyle(PulseColors.textPrimary) .multilineTextAlignment(.center) - Text(feature.2) + Text(feature.detail) .font(PulseFont.footnote.weight(.regular)) .foregroundStyle(PulseColors.textMuted) .multilineTextAlignment(.center) @@ -246,24 +256,24 @@ struct OnboardingWelcomeView: View { .accessibilityHidden(true) } - private func tileIcon(_ feature: (String, String, String, Color), s: CGFloat) -> some View { - Image(systemName: feature.0) + private func tileIcon(_ feature: OnboardingItem, s: CGFloat) -> some View { + Image(systemName: feature.icon) .font(.system(size: (18 * s).rounded())) - .foregroundStyle(feature.3) + .foregroundStyle(feature.tint) .frame(width: (34 * s).rounded(), height: (34 * s).rounded()) - .background(feature.3.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) + .background(feature.tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) } - private func tile(_ feature: (String, String, String, Color), s: CGFloat) -> some View { + private func tile(_ feature: OnboardingItem, s: CGFloat) -> some View { VStack(spacing: 6) { tileIcon(feature, s: s) - Text(feature.1) + Text(feature.title) .font(PulseFont.subheadline.weight(.semibold)) .foregroundStyle(PulseColors.textPrimary) .multilineTextAlignment(.center) .lineLimit(1) .minimumScaleFactor(0.85) - Text(feature.2) + Text(feature.detail) .font(PulseFont.caption) .foregroundStyle(PulseColors.textMuted) .multilineTextAlignment(.center) @@ -403,11 +413,20 @@ struct OnboardingBaselineView: View { // 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. + // icon / title / one-line description / node tint. "1" renders as a numbered chip. private let milestones = [ - ("1", "Today", "Activity and live vitals, right away", PulseColors.info), - ("moon.fill", "First sync", "Sleep stages and nightly trends", PulseColors.sleep), - ("chart.line.uptrend.xyaxis", "Days 3–7", "Your personalized baseline unlocks", 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 { @@ -541,7 +560,7 @@ struct OnboardingBaselineView: View { private func timeline(s: CGFloat) -> some View { VStack(alignment: .leading, spacing: (18 * s).rounded()) { - ForEach(Array(milestones.enumerated()), id: \.element.1) { index, milestone in + ForEach(Array(milestones.enumerated()), id: \.element.id) { index, milestone in milestoneRow(milestone, index: index, s: s, isLast: index == milestones.count - 1) } } @@ -559,7 +578,7 @@ struct OnboardingBaselineView: View { } private func milestoneRow( - _ milestone: (String, String, String, Color), + _ milestone: OnboardingItem, index: Int, s: CGFloat, isLast: Bool @@ -569,19 +588,19 @@ struct OnboardingBaselineView: View { // Glass node + connecting line to the next node. VStack(spacing: 0) { Group { - if milestone.0 == "1" { + if milestone.icon == "1" { Text("1").font(PulseFont.footnote.weight(.bold)) } else { - Image(systemName: milestone.0).font(PulseFont.footnote.weight(.semibold)) + Image(systemName: milestone.icon).font(PulseFont.footnote.weight(.semibold)) } } - .foregroundStyle(milestone.3) + .foregroundStyle(milestone.tint) .frame(width: node, height: node) - .pulseGlass(Circle(), tint: milestone.3.opacity(0.18)) + .pulseGlass(Circle(), tint: milestone.tint.opacity(0.18)) if !isLast { Rectangle() - .fill(milestone.3.opacity(0.35)) + .fill(milestone.tint.opacity(0.35)) .frame(width: 2) .frame(maxHeight: .infinity) } @@ -589,10 +608,10 @@ struct OnboardingBaselineView: View { .frame(width: node) VStack(alignment: .leading, spacing: 2) { - Text(milestone.1) + Text(milestone.title) .font(PulseFont.subheadline.weight(.semibold)) .foregroundStyle(PulseColors.textPrimary) - Text(milestone.2) + Text(milestone.detail) .font(PulseFont.footnote.weight(.regular)) .foregroundStyle(PulseColors.textMuted) .fixedSize(horizontal: false, vertical: true)