vOOice/VoiceInk/Views/Onboarding/OnboardingView.swift
2025-02-22 11:52:41 +05:45

374 lines
14 KiB
Swift

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..<particleCount {
let position = particlePosition(index: i, time: timeOffset, size: size)
let opacity = particleOpacity(index: i, time: timeOffset)
let scale = particleScale(index: i, time: timeOffset)
context.opacity = opacity
context.fill(
Circle().path(in: CGRect(
x: position.x - scale/2,
y: position.y - scale/2,
width: scale,
height: scale
)),
with: .color(.white)
)
}
}
}
.opacity(isActive ? 1 : 0)
}
private func particlePosition(index: Int, time: TimeInterval, size: CGSize) -> 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))
}