From 16af463569193c7e171e52c4a5b0b031ecb1e0ce Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 24 Dec 2025 11:59:09 -0600 Subject: [PATCH] Add celebration animations when voting on mood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create 10 full-view celebration animations (vortex, explosion, flip, shatter, pulse wave, fireworks, confetti, morph, tunnel, gravity) - Play random animation when user votes in-app - Add Animation Lab debug view to preview and test animations - Animations complete before saving mood to prevent view flash 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Feels/Localizable.xcstrings | 13 + Shared/Views/AddMoodHeaderView.swift | 29 +- Shared/Views/CelebrationAnimations.swift | 849 ++++++++++++++++++ .../DebugAnimationSettingsView.swift | 358 ++++++++ Shared/Views/SettingsView/SettingsView.swift | 42 + 5 files changed, 1289 insertions(+), 2 deletions(-) create mode 100644 Shared/Views/CelebrationAnimations.swift create mode 100644 Shared/Views/SettingsView/DebugAnimationSettingsView.swift diff --git a/Feels/Localizable.xcstrings b/Feels/Localizable.xcstrings index 4ce8192..eca39f9 100644 --- a/Feels/Localizable.xcstrings +++ b/Feels/Localizable.xcstrings @@ -1132,6 +1132,9 @@ } } } + }, + "Animation Lab" : { + }, "App icon style %@" : { "comment" : "A button that lets the user select an app icon style. The label shows the name of the style, without the \"AppIcon\" or \"Image\" prefix.", @@ -4705,6 +4708,10 @@ } } }, + "Experiment with vote celebrations" : { + "comment" : "A description of a feature in the Animation Lab.", + "isCommentAutoGenerated" : true + }, "Explore Your Mood History" : { "comment" : "A title for a feature that allows users to explore their mood history.", "isCommentAutoGenerated" : true @@ -5556,6 +5563,9 @@ } } } + }, + "How are you feeling?" : { + }, "How to add widgets" : { "localizations" : { @@ -12443,6 +12453,9 @@ "Tap to view or edit" : { "comment" : "A hint that appears when a user taps on an entry to view or edit it.", "isCommentAutoGenerated" : true + }, + "Tap to vote" : { + }, "Test builds only" : { "comment" : "A section header that indicates that the settings view contains only test data.", diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index 8e558f2..de09ec9 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -18,6 +18,12 @@ struct AddMoodHeaderView: View { @State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData + // Celebration animation state + @State private var showCelebration = false + @State private var celebrationMood: Mood = .great + @State private var celebrationAnimation: CelebrationAnimationType = .pulseWave + @State private var celebrationDate: Date = Date() + let addItemHeaderClosure: ((Mood, Date) -> Void) init(addItemHeaderClosure: @escaping ((Mood, Date) -> Void)) { @@ -46,6 +52,18 @@ struct AddMoodHeaderView: View { .padding(.bottom) } .padding(.horizontal) + .opacity(showCelebration ? 0 : 1) + + // Celebration animation overlay + if showCelebration { + CelebrationOverlayView( + animationType: celebrationAnimation, + mood: celebrationMood + ) { + // Animation complete - save the mood (parent will remove this view) + addItemHeaderClosure(celebrationMood, celebrationDate) + } + } } .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .fixedSize(horizontal: false, vertical: true) @@ -71,8 +89,15 @@ struct AddMoodHeaderView: View { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData) - addItemHeaderClosure(mood, date) + // Store mood, date, and pick random animation + celebrationMood = mood + celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData) + celebrationAnimation = .random + + // Show celebration - mood will be saved when animation completes + withAnimation(.easeInOut(duration: 0.3)) { + showCelebration = true + } } } diff --git a/Shared/Views/CelebrationAnimations.swift b/Shared/Views/CelebrationAnimations.swift new file mode 100644 index 0000000..18817b5 --- /dev/null +++ b/Shared/Views/CelebrationAnimations.swift @@ -0,0 +1,849 @@ +// +// CelebrationAnimations.swift +// Feels +// +// Full-view celebration animations that play after voting. +// + +import SwiftUI + +// MARK: - Animation Type Enum + +enum CelebrationAnimationType: String, CaseIterable, Identifiable { + case vortexCheckmark = "Vortex" + case explosionReveal = "Explosion" + case flipReveal = "Flip" + case shatterReform = "Shatter" + case pulseWave = "Pulse Wave" + case fireworks = "Fireworks" + case confettiCannon = "Confetti" + case morphBlob = "Morph" + case zoomTunnel = "Tunnel" + case gravityDrop = "Gravity" + + var id: String { rawValue } + + var icon: String { + switch self { + case .vortexCheckmark: return "tornado" + case .explosionReveal: return "sparkles" + case .flipReveal: return "rectangle.portrait.rotate" + case .shatterReform: return "square.grid.3x3" + case .pulseWave: return "dot.radiowaves.left.and.right" + case .fireworks: return "fireworks" + case .confettiCannon: return "party.popper.fill" + case .morphBlob: return "drop.fill" + case .zoomTunnel: return "tunnel.circle" + case .gravityDrop: return "arrow.down.to.line" + } + } + + var description: String { + switch self { + case .vortexCheckmark: return "Sucked into center" + case .explosionReveal: return "Explodes outward" + case .flipReveal: return "3D card flip" + case .shatterReform: return "Shatters & reforms" + case .pulseWave: return "Expanding waves" + case .fireworks: return "Celebration burst" + case .confettiCannon: return "Party confetti" + case .morphBlob: return "Liquid transform" + case .zoomTunnel: return "Warp speed zoom" + case .gravityDrop: return "Falls with bounce" + } + } + + var accentColor: Color { + switch self { + case .vortexCheckmark: return .purple + case .explosionReveal: return .orange + case .flipReveal: return .blue + case .shatterReform: return .red + case .pulseWave: return .green + case .fireworks: return .yellow + case .confettiCannon: return .pink + case .morphBlob: return .cyan + case .zoomTunnel: return .indigo + case .gravityDrop: return .mint + } + } + + var duration: Double { + switch self { + case .vortexCheckmark: return 2.0 + case .explosionReveal: return 1.8 + case .flipReveal: return 1.5 + case .shatterReform: return 2.2 + case .pulseWave: return 1.6 + case .fireworks: return 2.5 + case .confettiCannon: return 2.2 + case .morphBlob: return 2.0 + case .zoomTunnel: return 1.8 + case .gravityDrop: return 2.0 + } + } + + static var random: CelebrationAnimationType { + allCases.randomElement() ?? .pulseWave + } +} + +// MARK: - Celebration Overlay View + +struct CelebrationOverlayView: View { + let animationType: CelebrationAnimationType + let mood: Mood + let onComplete: () -> Void + + @State private var hasAppeared = false + + var body: some View { + ZStack { + if hasAppeared { + animationView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + hasAppeared = true + DispatchQueue.main.asyncAfter(deadline: .now() + animationType.duration) { + onComplete() + } + } + } + + @ViewBuilder + private var animationView: some View { + switch animationType { + case .vortexCheckmark: + VortexCheckmarkAnimation(mood: mood) + case .explosionReveal: + ExplosionRevealAnimation(mood: mood) + case .flipReveal: + FlipRevealAnimation(mood: mood) + case .shatterReform: + ShatterReformAnimation(mood: mood) + case .pulseWave: + PulseWaveAnimation(mood: mood) + case .fireworks: + FireworksAnimation(mood: mood) + case .confettiCannon: + ConfettiCannonAnimation(mood: mood) + case .morphBlob: + MorphBlobAnimation(mood: mood) + case .zoomTunnel: + ZoomTunnelAnimation(mood: mood) + case .gravityDrop: + GravityDropAnimation(mood: mood) + } + } +} + +// MARK: - Animation 1: Vortex Checkmark + +struct VortexCheckmarkAnimation: View { + let mood: Mood + + @State private var spiralRotation: Double = 0 + @State private var checkmarkScale: CGFloat = 0 + @State private var checkmarkOpacity: Double = 0 + @State private var ringScale: CGFloat = 0.5 + @State private var ringOpacity: Double = 0 + + var body: some View { + ZStack { + ForEach(0..<8, id: \.self) { i in + Circle() + .stroke(mood.color.opacity(0.3), lineWidth: 2) + .frame(width: CGFloat(30 + i * 25), height: CGFloat(30 + i * 25)) + .rotationEffect(.degrees(spiralRotation + Double(i * 15))) + } + + Circle() + .stroke(mood.color, lineWidth: 4) + .frame(width: 100, height: 100) + .scaleEffect(ringScale) + .opacity(ringOpacity) + + Image(systemName: "checkmark") + .font(.system(size: 50, weight: .bold)) + .foregroundColor(mood.color) + .scaleEffect(checkmarkScale) + .opacity(checkmarkOpacity) + } + .onAppear { + withAnimation(.easeOut(duration: 0.5)) { spiralRotation = 180 } + withAnimation(.easeOut(duration: 0.3).delay(0.3)) { ringScale = 1.2; ringOpacity = 1 } + withAnimation(.easeIn(duration: 0.2).delay(0.6)) { ringScale = 1.0 } + withAnimation(.spring(response: 0.4, dampingFraction: 0.5).delay(0.5)) { checkmarkScale = 1.2; checkmarkOpacity = 1 } + withAnimation(.spring(response: 0.2, dampingFraction: 0.6).delay(0.7)) { checkmarkScale = 1.0 } + withAnimation(.easeOut(duration: 0.3).delay(1.5)) { checkmarkOpacity = 0; ringOpacity = 0 } + } + } +} + +// MARK: - Animation 2: Explosion Reveal + +struct ExplosionRevealAnimation: View { + let mood: Mood + + @State private var particles: [ExplosionParticle] = [] + @State private var checkmarkOffset: CGFloat = -200 + @State private var checkmarkScale: CGFloat = 1.5 + @State private var checkmarkOpacity: Double = 0 + @State private var glowOpacity: Double = 0 + + struct ExplosionParticle: Identifiable { + let id = UUID() + let angle: Double + let distance: CGFloat + let size: CGFloat + let color: Color + var offset: CGFloat = 0 + var opacity: Double = 1 + var rotation: Double = 0 + } + + var body: some View { + ZStack { + ForEach(particles) { particle in + RoundedRectangle(cornerRadius: 4) + .fill(particle.color) + .frame(width: particle.size, height: particle.size) + .rotationEffect(.degrees(particle.rotation)) + .offset(x: cos(particle.angle) * particle.offset, y: sin(particle.angle) * particle.offset) + .opacity(particle.opacity) + } + + Circle() + .fill(RadialGradient(colors: [mood.color.opacity(0.5), .clear], center: .center, startRadius: 0, endRadius: 100)) + .frame(width: 200, height: 200) + .opacity(glowOpacity) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 70)) + .foregroundColor(mood.color) + .offset(y: checkmarkOffset) + .scaleEffect(checkmarkScale) + .opacity(checkmarkOpacity) + } + .onAppear { + for i in 0..<20 { + let angle = Double(i) * (2 * .pi / 20) + Double.random(in: -0.2...0.2) + particles.append(ExplosionParticle(angle: angle, distance: CGFloat.random(in: 100...180), size: CGFloat.random(in: 8...20), color: [mood.color, mood.color.opacity(0.7), .white, .orange][i % 4])) + } + withAnimation(.easeOut(duration: 0.6)) { for i in particles.indices { particles[i].offset = particles[i].distance; particles[i].rotation = Double.random(in: 0...360) } } + withAnimation(.easeOut(duration: 0.4).delay(0.4)) { for i in particles.indices { particles[i].opacity = 0 } } + withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { glowOpacity = 1 } + withAnimation(.easeOut(duration: 0.5).delay(0.6)) { glowOpacity = 0 } + withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.3)) { checkmarkOffset = 0; checkmarkScale = 1.0; checkmarkOpacity = 1 } + withAnimation(.easeOut(duration: 0.3).delay(1.4)) { checkmarkOpacity = 0 } + } + } +} + +// MARK: - Animation 3: Flip Reveal + +struct FlipRevealAnimation: View { + let mood: Mood + + @State private var showBack = false + @State private var checkmarkScale: CGFloat = 0.5 + @State private var shimmerOffset: CGFloat = -200 + + var body: some View { + GeometryReader { geo in + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(LinearGradient(colors: [mood.color, mood.color.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay( + Rectangle() + .fill(LinearGradient(colors: [.clear, .white.opacity(0.4), .clear], startPoint: .leading, endPoint: .trailing)) + .frame(width: 60) + .offset(x: shimmerOffset) + .blur(radius: 5) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.white) + .scaleEffect(checkmarkScale) + Text("Logged!") + .font(.title2.weight(.bold)) + .foregroundColor(.white) + } + ) + .opacity(showBack ? 1 : 0) + } + .frame(width: geo.size.width, height: geo.size.height) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { showBack = true } + withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.4)) { checkmarkScale = 1.1 } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.6)) { checkmarkScale = 1.0 } + withAnimation(.easeInOut(duration: 0.8).delay(0.5)) { shimmerOffset = 200 } + } + } +} + +// MARK: - Animation 4: Shatter Reform + +struct ShatterReformAnimation: View { + let mood: Mood + + @State private var shardOffsets: [CGSize] = [] + @State private var shardRotations: [Double] = [] + @State private var shardOpacities: [Double] = [] + @State private var phase: AnimationPhase = .initial + @State private var checkmarkOpacity: Double = 0 + + enum AnimationPhase { case initial, shatter, reform, complete } + + private let shardCount = 12 + + var body: some View { + ZStack { + // Shards that explode outward + ForEach(0.. Void + + @State private var isPressed = false + + var body: some View { + Button(action: onTap) { + VStack(spacing: 8) { + // Icon + ZStack { + Circle() + .fill( + isSelected + ? type.accentColor.opacity(0.2) + : (isDark ? Color(.systemGray5) : Color(.systemGray6)) + ) + .frame(width: 44, height: 44) + + Image(systemName: type.icon) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(isSelected ? type.accentColor : .secondary) + } + + // Name + Text(type.rawValue) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(isSelected ? type.accentColor : .secondary) + } + .frame(width: 72, height: 80) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isDark ? Color(.systemGray6) : .white) + .shadow( + color: isSelected ? type.accentColor.opacity(0.3) : .black.opacity(0.05), + radius: isSelected ? 8 : 4, + x: 0, + y: isSelected ? 4 : 2 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + isSelected ? type.accentColor : Color.clear, + lineWidth: 2 + ) + ) + .scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0)) + } + .buttonStyle(PlainButtonStyle()) + .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in + withAnimation(.easeInOut(duration: 0.15)) { + isPressed = pressing + } + }, perform: {}) + } +} + +// MARK: - Animated Voting View + +struct AnimatedVotingView: View { + let animationType: CelebrationAnimationType + @Binding var isAnimating: Bool + + @State private var selectedMood: Mood = .great + + // Animation state + @State private var animationScale: CGFloat = 1 + @State private var animationRotation: Angle = .zero + @State private var animationOffset: CGSize = .zero + + var body: some View { + ZStack { + // The voting content (gets animated) + DebugVotingContentView(selectedMood: $selectedMood) { + triggerAnimation() + } + .opacity(isAnimating ? 0 : 1) + .scaleEffect(animationScale) + .rotation3DEffect(animationRotation, axis: (x: 0, y: 1, z: 0)) + .offset(animationOffset) + + // Animation overlay + if isAnimating { + CelebrationOverlayView( + animationType: animationType, + mood: selectedMood + ) { + // Reset handled by triggerAnimation + } + } + } + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isAnimating) + } + + private func triggerAnimation() { + guard !isAnimating else { return } + + let impact = UIImpactFeedbackGenerator(style: .medium) + impact.impactOccurred() + + // Set animation properties based on type + withAnimation(.easeInOut(duration: 0.4)) { + isAnimating = true + + switch animationType { + case .vortexCheckmark: + animationScale = 0.01 + case .explosionReveal: + animationScale = 2.5 + case .flipReveal: + animationRotation = .degrees(90) + case .shatterReform: + break + case .pulseWave: + animationScale = 0.9 + case .fireworks: + animationScale = 0.8 + case .confettiCannon: + animationScale = 0.95 + case .morphBlob: + animationScale = 0.5 + case .zoomTunnel: + animationScale = 0.01 + case .gravityDrop: + animationOffset = CGSize(width: 0, height: 400) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + animationType.duration) { + withAnimation(.easeOut(duration: 0.3)) { + isAnimating = false + animationScale = 1 + animationRotation = .zero + animationOffset = .zero + } + } + } +} + +// MARK: - Debug Voting Content View + +struct DebugVotingContentView: View { + @Binding var selectedMood: Mood + let onVote: () -> Void + + var body: some View { + VStack(spacing: 20) { + Text("How are you feeling?") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(.primary) + + HStack(spacing: 12) { + ForEach(Mood.allValues, id: \.self) { mood in + Button { + selectedMood = mood + onVote() + } label: { + mood.icon + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .padding(8) + .background( + Circle() + .fill(mood.color.opacity(0.15)) + ) + } + } + } + + Text("Tap to vote") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + DebugAnimationSettingsView() + } +} + +#endif diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index 1c387ab..6871715 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -53,6 +53,7 @@ struct SettingsContentView: View { // Debug section debugSectionHeader trialDateButton + animationLabButton #endif Spacer() @@ -242,6 +243,47 @@ struct SettingsContentView: View { .presentationDetents([.medium]) } } + + @State private var showAnimationLab = false + + private var animationLabButton: some View { + ZStack { + theme.currentTheme.secondaryBGColor + Button { + showAnimationLab = true + } label: { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundColor(.purple) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Animation Lab") + .foregroundColor(textColor) + + Text("Experiment with vote celebrations") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding() + } + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + .sheet(isPresented: $showAnimationLab) { + NavigationStack { + DebugAnimationSettingsView() + } + } + } #endif // MARK: - Privacy Lock Toggle