diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index ee4c513..94fb3fe 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -85,7 +85,7 @@ struct OnboardingView: View { private var welcomePage: some View { VStack(spacing: 0) { Spacer() - ZStack { + AnimatedOnboardingIcon(tint: .accentColor) { // The app icon itself, with the standard iOS rounded-corner // treatment (~22% of the size), so onboarding matches the // Home Screen. @@ -122,8 +122,7 @@ struct OnboardingView: View { private var locationPage: some View { VStack(spacing: 0) { Spacer() - ZStack { - Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96) + AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) { Image(systemName: "location.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.blue) @@ -154,8 +153,7 @@ struct OnboardingView: View { private var notificationsPage: some View { VStack(spacing: 0) { Spacer() - ZStack { - Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96) + AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) { Image(systemName: "bell.badge.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.orange) @@ -186,8 +184,7 @@ struct OnboardingView: View { private var dataPage: some View { VStack(spacing: 0) { Spacer() - ZStack { - Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96) + AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.teal) @@ -326,6 +323,99 @@ struct OnboardingView: View { } } +private struct AnimatedOnboardingIcon: View { + enum Motion { + case gentle + case locationArrow + case alarmBell + case downloadArrow + } + + let tint: Color + var motion: Motion = .gentle + @ViewBuilder var content: Content + + @State private var settled = false + @State private var iconRotation: Double = 0 + @State private var iconOffsetY: CGFloat = 0 + @State private var iconScale: CGFloat = 0.92 + + var body: some View { + ZStack { + Circle() + .fill(tint.opacity(0.10)) + .frame(width: 108, height: 108) + .scaleEffect(settled ? 1.0 : 0.94) + + Circle() + .stroke(tint.opacity(0.18), lineWidth: 1) + .frame(width: 118, height: 118) + .scaleEffect(settled ? 1.03 : 0.97) + .opacity(settled ? 1.0 : 0.55) + + content + .scaleEffect(iconScale) + .rotationEffect(.degrees(iconRotation)) + .offset(y: iconOffsetY) + } + .shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2) + .animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled) + .onAppear { + playAnimation() + } + } + + private func playAnimation() { + settled = false + iconRotation = 0 + iconOffsetY = 4 + iconScale = 0.92 + + switch motion { + case .gentle: + withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { + settled = true + iconOffsetY = 0 + iconScale = 1.0 + } + + case .locationArrow: + iconRotation = -120 + withAnimation(.linear(duration: 0.45)) { + settled = true + iconRotation = 24 + iconOffsetY = 0 + iconScale = 1.02 + } + withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) { + iconRotation = 0 + iconScale = 1.0 + } + + case .alarmBell: + settled = true + iconOffsetY = 0 + iconScale = 1.0 + withAnimation(.linear(duration: 0.10)) { iconRotation = -14 } + withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 } + withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 } + withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 } + withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 } + + case .downloadArrow: + iconOffsetY = -22 + withAnimation(.easeIn(duration: 0.22)) { + settled = true + iconScale = 1.0 + iconOffsetY = 8 + } + withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) { + iconOffsetY = 0 + } + } + } +} + /// Owns the system permission requests during onboarding and publishes /// their outcomes so the pages can reflect them live. @MainActor