import SwiftUI struct OnboardingView: View { @Binding var hasCompletedOnboarding: Bool @State private var textOpacity: CGFloat = 0 @State private var showSecondaryElements = false @State private var showPermissions = false // Animation timing private let animationDelay = 0.2 private let textAnimationDuration = 0.6 var body: some View { ZStack { GeometryReader { geometry in ZStack { // Reusable background OnboardingBackgroundView() // Content container ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { // Content Area VStack(spacing: 60) { Spacer() .frame(height: 40) // Title and subtitle VStack(spacing: 16) { Text("Welcome to the Future of Typing") .font(.system(size: min(geometry.size.width * 0.055, 42), weight: .bold, design: .rounded)) .foregroundColor(.white) .opacity(textOpacity) .multilineTextAlignment(.center) .padding(.horizontal) Text("A New Way to Type") .font(.system(size: min(geometry.size.width * 0.032, 24), weight: .medium, design: .rounded)) .foregroundColor(.white.opacity(0.7)) .opacity(textOpacity) .multilineTextAlignment(.center) } if showSecondaryElements { // Typewriter roles animation TypewriterRoles() .frame(height: 160) .transition(.scale.combined(with: .opacity)) .padding(.horizontal, 40) } } .padding(.top, geometry.size.height * 0.15) Spacer(minLength: geometry.size.height * 0.2) // Bottom navigation if showSecondaryElements { VStack(spacing: 20) { Button(action: { withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { showPermissions = true } }) { Text("Get Started") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.black) .frame(width: min(geometry.size.width * 0.3, 200), height: 50) .background(Color.white) .cornerRadius(25) } .buttonStyle(ScaleButtonStyle()) SkipButton(text: "Skip Tour") { hasCompletedOnboarding = true } } .padding(.bottom, 35) .transition(.move(edge: .bottom).combined(with: .opacity)) } } } } } if showPermissions { OnboardingPermissionsView(hasCompletedOnboarding: $hasCompletedOnboarding) .transition(.move(edge: .trailing).combined(with: .opacity)) } } .onAppear { startAnimations() } } private func startAnimations() { // Text fade in withAnimation(.easeOut(duration: textAnimationDuration).delay(animationDelay)) { textOpacity = 1 } // Show secondary elements DispatchQueue.main.asyncAfter(deadline: .now() + animationDelay * 3) { withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { showSecondaryElements = true } } } } // MARK: - Supporting Views struct TypewriterRoles: View { private let roles = [ "Your Writing Assistant", "Your Vibe-Coding Assistant", "Works Everywhere on Mac with a click", "100% offline & private", ] @State private var displayedText = "" @State private var currentIndex = 0 @State private var showCursor = true @State private var isTyping = false @State private var isDeleting = false // Animation timing private let typingSpeed = 0.05 // Time between each character private let deleteSpeed = 0.03 // Faster deletion private let pauseDuration = 1.0 // How long to show completed text private let cursorBlinkSpeed = 0.6 var body: some View { VStack { HStack(spacing: 0) { Text(displayedText) .font(.system(size: 42, weight: .bold, design: .rounded)) .foregroundStyle( LinearGradient( colors: [ Color.accentColor, Color.accentColor.opacity(0.8), Color.white.opacity(0.9) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) // Blinking cursor Text("|") .font(.system(size: 42, weight: .bold, design: .rounded)) .foregroundStyle( LinearGradient( colors: [ Color.accentColor, Color.accentColor.opacity(0.8) ], startPoint: .top, endPoint: .bottom ) ) .opacity(showCursor ? 1 : 0) .animation(.easeInOut(duration: cursorBlinkSpeed).repeatForever(), value: showCursor) } .multilineTextAlignment(.center) .shadow(color: Color.accentColor.opacity(0.5), radius: 15, x: 0, y: 0) .padding(.horizontal) } .frame(maxWidth: .infinity) .onAppear { startTypingAnimation() // Start cursor blinking withAnimation(.easeInOut(duration: cursorBlinkSpeed).repeatForever()) { showCursor.toggle() } } } private func startTypingAnimation() { guard currentIndex < roles.count else { return } let targetText = roles[currentIndex] isTyping = true // Type out the text var charIndex = 0 func typeNextCharacter() { guard charIndex < targetText.count else { // Typing complete, pause then delete isTyping = false DispatchQueue.main.asyncAfter(deadline: .now() + pauseDuration) { startDeletingAnimation() } return } let nextChar = String(targetText[targetText.index(targetText.startIndex, offsetBy: charIndex)]) displayedText += nextChar charIndex += 1 // Schedule next character DispatchQueue.main.asyncAfter(deadline: .now() + typingSpeed) { typeNextCharacter() } } typeNextCharacter() } private func startDeletingAnimation() { isDeleting = true func deleteNextCharacter() { guard !displayedText.isEmpty else { isDeleting = false currentIndex = (currentIndex + 1) % roles.count DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { startTypingAnimation() } return } displayedText.removeLast() // Schedule next deletion DispatchQueue.main.asyncAfter(deadline: .now() + deleteSpeed) { deleteNextCharacter() } } deleteNextCharacter() } } struct SkipButton: View { let text: String let action: () -> Void var body: some View { Text(text) .font(.system(size: 13, weight: .regular)) .foregroundColor(.white.opacity(0.2)) .onTapGesture(perform: action) } } struct OnboardingBackgroundView: View { @State private var glowOpacity: CGFloat = 0 @State private var glowScale: CGFloat = 0.8 @State private var particlesActive = false var body: some View { GeometryReader { geometry in ZStack { // Base background with black gradient Color.black .overlay( LinearGradient( colors: [ Color.black, Color.black.opacity(0.8), Color.black.opacity(0.6) ], startPoint: .top, endPoint: .bottom ) ) // Animated glow effect Circle() .fill(Color.accentColor) .frame(width: min(geometry.size.width, geometry.size.height) * 0.4) .blur(radius: 100) .opacity(glowOpacity) .scaleEffect(glowScale) .position( x: geometry.size.width * 0.5, y: geometry.size.height * 0.3 ) // Enhanced particles with reduced opacity ParticlesView(isActive: $particlesActive) .opacity(0.2) .drawingGroup() } } .onAppear { startAnimations() } } private func startAnimations() { // Glow animation withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { glowOpacity = 0.3 glowScale = 1.2 } // Start particles DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { particlesActive = true } } } // MARK: - Particles struct ParticlesView: View { @Binding var isActive: Bool let particleCount = 60 // Increased particle count var body: some View { TimelineView(.animation) { timeline in Canvas { context, size in let timeOffset = timeline.date.timeIntervalSinceReferenceDate for i in 0.. CGPoint { let relativeIndex = Double(index) / Double(particleCount) let speed = 0.3 // Slower, more graceful movement let radius = min(size.width, size.height) * 0.45 let angle = time * speed + relativeIndex * .pi * 4 let x = sin(angle) * radius + size.width * 0.5 let y = cos(angle * 0.5) * radius + size.height * 0.5 return CGPoint(x: x, y: y) } private func particleOpacity(index: Int, time: TimeInterval) -> Double { let relativeIndex = Double(index) / Double(particleCount) return (sin(time + relativeIndex * .pi * 2) + 1) * 0.3 // Reduced opacity for subtlety } private func particleScale(index: Int, time: TimeInterval) -> CGFloat { let relativeIndex = Double(index) / Double(particleCount) let baseScale: CGFloat = 3 return baseScale + sin(time * 2 + relativeIndex * .pi) * 2 } } // MARK: - Button Style struct ScaleButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) } } // MARK: - Preview #Preview { OnboardingView(hasCompletedOnboarding: .constant(false)) }