Created
September 13, 2025 12:26
-
-
Save mariosaputra/c034c752d1026b526af0b86ab2579f50 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| import SwiftUI | |
| struct OnboardingView: View { | |
| @State private var currentStep = 0 | |
| @State private var isAnimating = false | |
| @State private var tattooPosition = 0 | |
| @State private var tattooTimer: Timer? | |
| @State private var tattooOpacity: Double = 0 | |
| @State private var tattooScale: Double = 0.5 | |
| // Onboarding steps data | |
| private let onboardingSteps = [ | |
| OnboardingStep( | |
| title: "Personalized", | |
| subtitle: "Tattoo Designs", | |
| description: "Craft unique tattoos that reflect your individuality, powered by AI.", | |
| buttonText: "Continue", | |
| imageName: "onboarding1" | |
| ), | |
| OnboardingStep( | |
| title: "AI-Powered", | |
| subtitle: "Tattoo Creation", | |
| description: "Generate stunning tattoo designs tailored to your style and preferences.", | |
| buttonText: "Continue", | |
| imageName: "onboarding2" | |
| ), | |
| OnboardingStep( | |
| title: "Try Before", | |
| subtitle: "You Ink", | |
| description: "Visualize how your tattoo will look on your skin before making it permanent.", | |
| buttonText: "Get Started", | |
| imageName: "onboarding3" | |
| ) | |
| ] | |
| var body: some View { | |
| GeometryReader { proxy in | |
| ZStack { | |
| // Background image (fills entire screen including safe areas) | |
| Image(onboardingSteps[currentStep].imageName) | |
| .resizable() | |
| .scaledToFill() | |
| .frame(width: proxy.size.width, height: proxy.size.height) | |
| .clipped() | |
| .ignoresSafeArea(.all) | |
| // Tattoo overlay for step 3 (Try Before You Ink) | |
| if currentStep == 2 { | |
| tattooOverlayView(proxy: proxy) | |
| } | |
| // Dark gradient overlay to make content readable | |
| LinearGradient( | |
| gradient: Gradient(colors: [ | |
| Color.clear, | |
| Color.black.opacity(0.25), | |
| Color.black.opacity(0.55), | |
| Color.black.opacity(1) | |
| ]), | |
| startPoint: .top, | |
| endPoint: .bottom | |
| ) | |
| .ignoresSafeArea(.all) | |
| // Bottom content overlay (keeps same look as WelcomeGridView) | |
| VStack { | |
| Spacer() | |
| VStack(spacing: 16) { | |
| // Title row: gradient + white text | |
| HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| Text(onboardingSteps[currentStep].title) | |
| .font(.system(size: adaptiveTitleFont(proxy: proxy), weight: .bold)) | |
| .foregroundStyle( | |
| LinearGradient( | |
| gradient: Gradient(colors: [Color.appPrimary, Color.appAccent]), | |
| startPoint: .leading, | |
| endPoint: .trailing | |
| ) | |
| ) | |
| .minimumScaleFactor(0.5) | |
| .lineLimit(1) | |
| Text(onboardingSteps[currentStep].subtitle) | |
| .font(.system(size: adaptiveTitleFont(proxy: proxy), weight: .bold)) | |
| .foregroundColor(.white) | |
| .minimumScaleFactor(0.5) | |
| .lineLimit(1) | |
| } | |
| .padding(.horizontal, 24) | |
| .opacity(isAnimating ? 1 : 0) | |
| .offset(y: isAnimating ? 0 : 12) | |
| .animation(.easeOut(duration: 0.5).delay(0.12), value: isAnimating) | |
| // Description — allow wrapping and vertical growth. limit width to avoid ultra-long lines on iPad. | |
| Text(onboardingSteps[currentStep].description) | |
| .font(.system(size: adaptiveSubtitleFont(proxy: proxy), weight: .medium)) | |
| .foregroundColor(.white.opacity(0.92)) | |
| .multilineTextAlignment(.center) | |
| .fixedSize(horizontal: false, vertical: true) // important: lets it expand vertically | |
| .frame(maxWidth: min(720, proxy.size.width - 64)) // cap width on large screens | |
| .padding(.horizontal, 26) | |
| .opacity(isAnimating ? 1 : 0) | |
| .offset(y: isAnimating ? 0 : 12) | |
| .animation(.easeOut(duration: 0.5).delay(0.22), value: isAnimating) | |
| // CTA button | |
| Button(action: { | |
| if currentStep < onboardingSteps.count - 1 { | |
| // Add haptic feedback for step progression | |
| Haptics.impact(.light) | |
| withAnimation(.easeInOut(duration: 0.3)) { | |
| currentStep += 1 | |
| isAnimating = false | |
| } | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { | |
| withAnimation(.easeOut(duration: 0.45)) { | |
| isAnimating = true | |
| } | |
| } | |
| } else { | |
| // Add success haptic feedback for onboarding completion | |
| Haptics.notification(.success) | |
| // Complete onboarding and navigate to main app | |
| NotificationCenter.default.post(name: .onboardingCompleted, object: nil) | |
| } | |
| }) { | |
| Text(onboardingSteps[currentStep].buttonText) | |
| .font(.system(size: adaptiveButtonFont(proxy: proxy), weight: .bold)) | |
| .foregroundColor(.white) | |
| .frame(maxWidth: .infinity) | |
| .padding(.vertical, 16) | |
| .background( | |
| LinearGradient( | |
| gradient: Gradient(colors: [Color.appPrimary, Color.appAccent]), | |
| startPoint: .leading, | |
| endPoint: .trailing | |
| ) | |
| ) | |
| .cornerRadius(12) | |
| .padding(.horizontal, 24) | |
| } | |
| .opacity(isAnimating ? 1 : 0) | |
| .offset(y: isAnimating ? 0 : 12) | |
| .animation(.easeOut(duration: 0.5).delay(0.32), value: isAnimating) | |
| Spacer().frame(height: max(20, proxy.safeAreaInsets.bottom)) | |
| } | |
| // keep a small transparent background so the gradient beneath is visible but layout is stable | |
| .padding(.vertical, 6) | |
| .background(Color.black.opacity(0.001)) | |
| } // VStack | |
| .ignoresSafeArea(edges: .bottom) | |
| } // ZStack | |
| .onAppear { | |
| withAnimation(.easeOut(duration: 0.5)) { | |
| isAnimating = true | |
| } | |
| } | |
| .onChange(of: currentStep) { newStep in | |
| // reset & replay entrance animation | |
| isAnimating = false | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { | |
| withAnimation(.easeOut(duration: 0.45)) { isAnimating = true } | |
| } | |
| // Handle tattoo animation for step 3 | |
| if newStep == 2 { | |
| startTattooAnimation() | |
| } else { | |
| stopTattooAnimation() | |
| } | |
| } | |
| } // GeometryReader | |
| } | |
| // adaptive font sizes so the text isn't too big on very large screens | |
| private func adaptiveTitleFont(proxy: GeometryProxy) -> CGFloat { | |
| let w = proxy.size.width | |
| if w > 900 { return 44 } | |
| if w > 600 { return 36 } | |
| return 28 | |
| } | |
| private func adaptiveSubtitleFont(proxy: GeometryProxy) -> CGFloat { | |
| let w = proxy.size.width | |
| if w > 900 { return 20 } | |
| if w > 600 { return 18 } | |
| return 16 | |
| } | |
| private func adaptiveButtonFont(proxy: GeometryProxy) -> CGFloat { | |
| let w = proxy.size.width | |
| if w > 900 { return 20 } | |
| if w > 600 { return 18 } | |
| return 16 | |
| } | |
| // MARK: - Tattoo Animation Functions | |
| private func startTattooAnimation() { | |
| // Add haptic feedback when tattoo animation starts | |
| Haptics.pulse() | |
| // Start with first position visible | |
| tattooPosition = 0 | |
| withAnimation(.easeOut(duration: 1.0)) { | |
| tattooOpacity = 0.8 | |
| tattooScale = 1.0 | |
| } | |
| // Set up timer for subsequent animations | |
| tattooTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in | |
| animateToNextPosition() | |
| } | |
| } | |
| private func animateToNextPosition() { | |
| // Fade out current tattoo | |
| withAnimation(.easeOut(duration: 0.8)) { | |
| tattooOpacity = 0 | |
| tattooScale = 0.5 | |
| } | |
| // After fade out, change position and fade in (no movement animation) | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { | |
| // Change position instantly while invisible | |
| tattooPosition = (tattooPosition + 1) % 3 | |
| // Fade in at new position | |
| withAnimation(.easeOut(duration: 1.0)) { | |
| tattooOpacity = 0.8 | |
| tattooScale = 1.0 | |
| } | |
| } | |
| } | |
| private func stopTattooAnimation() { | |
| tattooTimer?.invalidate() | |
| tattooTimer = nil | |
| // Fade out and reset | |
| withAnimation(.easeOut(duration: 0.4)) { | |
| tattooOpacity = 0 | |
| tattooScale = 0.5 | |
| } | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| tattooPosition = 0 | |
| } | |
| } | |
| // MARK: - Tattoo Overlay View | |
| @ViewBuilder | |
| private func tattooOverlayView(proxy: GeometryProxy) -> some View { | |
| Image("six_pack_tattoo") | |
| .resizable() | |
| .aspectRatio(contentMode: .fit) | |
| .frame(width: 250, height: 150) | |
| .opacity(tattooOpacity) | |
| .scaleEffect(tattooScale) | |
| .position(tattooPosition(for: proxy)) | |
| } | |
| private func tattooPosition(for proxy: GeometryProxy) -> CGPoint { | |
| let width = proxy.size.width | |
| let height = proxy.size.height | |
| switch tattooPosition { | |
| case 0: | |
| // Position 1: Upper chest area | |
| return CGPoint(x: width * 0.5, y: height * 0.1) | |
| case 1: | |
| // Position 2: Lower chest/upper abs | |
| return CGPoint(x: width * 0.7, y: height * 0.4) | |
| case 2: | |
| // Position 3: Lower abs | |
| return CGPoint(x: width * 0.5, y: height * 0.55) | |
| default: | |
| return CGPoint(x: width * 0.3, y: height * 0.25) | |
| } | |
| } | |
| } | |
| // MARK: - Onboarding Step Model | |
| struct OnboardingStep { | |
| let title: String | |
| let subtitle: String | |
| let description: String | |
| let buttonText: String | |
| let imageName: String | |
| } | |
| // MARK: - Previews | |
| struct OnboardingView_Previews: PreviewProvider { | |
| static var previews: some View { | |
| Group { | |
| OnboardingView() | |
| .previewDevice("iPhone 14 Pro") | |
| OnboardingView() | |
| .previewDevice("iPhone 13 mini") | |
| OnboardingView() | |
| .previewDevice("iPad Pro (12.9-inch) (6th generation)") | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment