Add celebration animations when voting on mood
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
849
Shared/Views/CelebrationAnimations.swift
Normal file
849
Shared/Views/CelebrationAnimations.swift
Normal file
@@ -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..<shardCount, id: \.self) { i in
|
||||
Rectangle()
|
||||
.fill(mood.color.opacity(Double.random(in: 0.5...1.0)))
|
||||
.frame(width: 20, height: 20)
|
||||
.rotationEffect(.degrees(shardRotations.indices.contains(i) ? shardRotations[i] : 0))
|
||||
.offset(shardOffsets.indices.contains(i) ? shardOffsets[i] : .zero)
|
||||
.opacity(shardOpacities.indices.contains(i) ? shardOpacities[i] : 1)
|
||||
}
|
||||
|
||||
// Checkmark
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 60, weight: .bold))
|
||||
.foregroundColor(mood.color)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
// Initialize arrays
|
||||
shardOffsets = Array(repeating: .zero, count: shardCount)
|
||||
shardRotations = Array(repeating: 0, count: shardCount)
|
||||
shardOpacities = Array(repeating: 1, count: shardCount)
|
||||
|
||||
// Phase 1: Explode outward
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
for i in 0..<shardCount {
|
||||
let angle = Double(i) * (2 * .pi / Double(shardCount))
|
||||
let distance: CGFloat = CGFloat.random(in: 80...140)
|
||||
shardOffsets[i] = CGSize(
|
||||
width: cos(angle) * distance,
|
||||
height: sin(angle) * distance
|
||||
)
|
||||
shardRotations[i] = Double.random(in: -180...180)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Converge to center and fade
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
phase = .reform
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
for i in 0..<shardCount {
|
||||
shardOffsets[i] = .zero
|
||||
shardRotations[i] = 0
|
||||
shardOpacities[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Show checkmark
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Fade out
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
checkmarkOpacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 5: Pulse Wave
|
||||
|
||||
struct PulseWaveAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var wave1Scale: CGFloat = 0.1
|
||||
@State private var wave1Opacity: Double = 1
|
||||
@State private var wave2Scale: CGFloat = 0.1
|
||||
@State private var wave2Opacity: Double = 1
|
||||
@State private var wave3Scale: CGFloat = 0.1
|
||||
@State private var wave3Opacity: Double = 1
|
||||
@State private var checkmarkScale: CGFloat = 3.0
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
@State private var stampRotation: Double = -15
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(mood.color, lineWidth: 8).scaleEffect(wave1Scale).opacity(wave1Opacity)
|
||||
Circle().stroke(mood.color.opacity(0.7), lineWidth: 6).scaleEffect(wave2Scale).opacity(wave2Opacity)
|
||||
Circle().stroke(mood.color.opacity(0.4), lineWidth: 4).scaleEffect(wave3Scale).opacity(wave3Opacity)
|
||||
|
||||
ZStack {
|
||||
Circle().fill(mood.color).frame(width: 80, height: 80)
|
||||
Image(systemName: "checkmark").font(.system(size: 40, weight: .bold)).foregroundColor(.white)
|
||||
}
|
||||
.scaleEffect(checkmarkScale)
|
||||
.opacity(checkmarkOpacity)
|
||||
.rotationEffect(.degrees(stampRotation))
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.6)) { wave1Scale = 1.5; wave1Opacity = 0 }
|
||||
withAnimation(.easeOut(duration: 0.6).delay(0.1)) { wave2Scale = 1.5; wave2Opacity = 0 }
|
||||
withAnimation(.easeOut(duration: 0.6).delay(0.2)) { wave3Scale = 1.5; wave3Opacity = 0 }
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.5).delay(0.2)) { checkmarkScale = 1.0; checkmarkOpacity = 1; stampRotation = 0 }
|
||||
withAnimation(.easeOut(duration: 0.3).delay(1.2)) { checkmarkOpacity = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 6: Fireworks
|
||||
|
||||
struct FireworksAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var bursts: [[FireworkParticle]] = []
|
||||
@State private var checkmarkScale: CGFloat = 0
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
|
||||
struct FireworkParticle: Identifiable {
|
||||
let id = UUID()
|
||||
let angle: Double
|
||||
let color: Color
|
||||
var distance: CGFloat = 0
|
||||
var opacity: Double = 1
|
||||
var scale: CGFloat = 1
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(0..<bursts.count, id: \.self) { burstIndex in
|
||||
let offset = burstOffsets[burstIndex]
|
||||
ZStack {
|
||||
ForEach(bursts[burstIndex]) { particle in
|
||||
Circle()
|
||||
.fill(particle.color)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(particle.scale)
|
||||
.offset(
|
||||
x: cos(particle.angle) * particle.distance,
|
||||
y: sin(particle.angle) * particle.distance
|
||||
)
|
||||
.opacity(particle.opacity)
|
||||
}
|
||||
}
|
||||
.offset(x: offset.x, y: offset.y)
|
||||
}
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [mood.color, mood.color.opacity(0.7)], startPoint: .top, endPoint: .bottom)
|
||||
)
|
||||
.scaleEffect(checkmarkScale)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
let colors: [Color] = [mood.color, .yellow, .orange, .pink, .white]
|
||||
|
||||
for _ in 0..<4 {
|
||||
var particles: [FireworkParticle] = []
|
||||
for i in 0..<12 {
|
||||
let angle = Double(i) * (2 * .pi / 12)
|
||||
particles.append(FireworkParticle(angle: angle, color: colors.randomElement()!))
|
||||
}
|
||||
bursts.append(particles)
|
||||
}
|
||||
|
||||
for burstIndex in 0..<4 {
|
||||
let delay = Double(burstIndex) * 0.15
|
||||
|
||||
withAnimation(.easeOut(duration: 0.5).delay(delay)) {
|
||||
for i in bursts[burstIndex].indices {
|
||||
bursts[burstIndex][i].distance = CGFloat.random(in: 40...70)
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.4).delay(delay + 0.3)) {
|
||||
for i in bursts[burstIndex].indices {
|
||||
bursts[burstIndex][i].opacity = 0
|
||||
bursts[burstIndex][i].scale = 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.6)) {
|
||||
checkmarkScale = 1.1
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.8)) {
|
||||
checkmarkScale = 1.0
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.3).delay(2.0)) {
|
||||
checkmarkOpacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var burstOffsets: [CGPoint] {
|
||||
[CGPoint(x: -60, y: -50), CGPoint(x: 70, y: -40), CGPoint(x: -50, y: 60), CGPoint(x: 60, y: 50)]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 7: Confetti Cannon
|
||||
|
||||
struct ConfettiCannonAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var confetti: [ConfettiPiece] = []
|
||||
@State private var checkmarkScale: CGFloat = 0
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
@State private var glowScale: CGFloat = 0.5
|
||||
@State private var glowOpacity: Double = 0
|
||||
|
||||
struct ConfettiPiece: Identifiable {
|
||||
let id = UUID()
|
||||
let color: Color
|
||||
let size: CGSize
|
||||
let xVelocity: CGFloat
|
||||
var yOffset: CGFloat = 200
|
||||
var rotation: Double = 0
|
||||
var opacity: Double = 1
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(RadialGradient(colors: [mood.color.opacity(0.4), .clear], center: .center, startRadius: 0, endRadius: 80))
|
||||
.frame(width: 160, height: 160)
|
||||
.scaleEffect(glowScale)
|
||||
.opacity(glowOpacity)
|
||||
|
||||
ForEach(confetti) { piece in
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(piece.color)
|
||||
.frame(width: piece.size.width, height: piece.size.height)
|
||||
.rotationEffect(.degrees(piece.rotation))
|
||||
.offset(x: piece.xVelocity, y: piece.yOffset)
|
||||
.opacity(piece.opacity)
|
||||
}
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 50, weight: .bold))
|
||||
.foregroundColor(mood.color)
|
||||
.scaleEffect(checkmarkScale)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
let colors: [Color] = [mood.color, .yellow, .pink, .orange, .cyan, .white]
|
||||
|
||||
for i in 0..<30 {
|
||||
confetti.append(ConfettiPiece(
|
||||
color: colors[i % colors.count],
|
||||
size: CGSize(width: CGFloat.random(in: 6...12), height: CGFloat.random(in: 8...16)),
|
||||
xVelocity: CGFloat.random(in: -120...120)
|
||||
))
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
for i in confetti.indices {
|
||||
confetti[i].yOffset = CGFloat.random(in: -150 ... -80)
|
||||
confetti[i].rotation = Double.random(in: -180...180)
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeIn(duration: 1.0).delay(0.5)) {
|
||||
for i in confetti.indices {
|
||||
confetti[i].yOffset = 200
|
||||
confetti[i].rotation += Double.random(in: 180...540)
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeIn(duration: 0.3).delay(1.3)) {
|
||||
for i in confetti.indices { confetti[i].opacity = 0 }
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.3).delay(0.1)) { glowScale = 1.2; glowOpacity = 1 }
|
||||
withAnimation(.easeIn(duration: 0.4).delay(0.5)) { glowOpacity = 0 }
|
||||
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.5).delay(0.3)) { checkmarkScale = 1.2; checkmarkOpacity = 1 }
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.6).delay(0.5)) { checkmarkScale = 1.0 }
|
||||
withAnimation(.easeOut(duration: 0.3).delay(1.8)) { checkmarkOpacity = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 8: Morph Blob
|
||||
|
||||
struct MorphBlobAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var blobScale: CGFloat = 0.3
|
||||
@State private var blobOffset1: CGSize = .zero
|
||||
@State private var blobOffset2: CGSize = .zero
|
||||
@State private var blobOffset3: CGSize = .zero
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(mood.color.opacity(0.6))
|
||||
.frame(width: 100, height: 100)
|
||||
.offset(blobOffset1)
|
||||
.blur(radius: 20)
|
||||
|
||||
Circle()
|
||||
.fill(mood.color.opacity(0.5))
|
||||
.frame(width: 80, height: 80)
|
||||
.offset(blobOffset2)
|
||||
.blur(radius: 15)
|
||||
|
||||
Circle()
|
||||
.fill(mood.color.opacity(0.7))
|
||||
.frame(width: 60, height: 60)
|
||||
.offset(blobOffset3)
|
||||
.blur(radius: 10)
|
||||
}
|
||||
.scaleEffect(blobScale)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: mood.color, radius: 10)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
blobScale = 1.5
|
||||
blobOffset1 = CGSize(width: -30, height: -40)
|
||||
blobOffset2 = CGSize(width: 40, height: 20)
|
||||
blobOffset3 = CGSize(width: -20, height: 50)
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.5).delay(0.4)) {
|
||||
blobOffset1 = CGSize(width: 40, height: 30)
|
||||
blobOffset2 = CGSize(width: -30, height: -30)
|
||||
blobOffset3 = CGSize(width: 20, height: -40)
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.4).delay(0.9)) {
|
||||
blobOffset1 = .zero
|
||||
blobOffset2 = .zero
|
||||
blobOffset3 = .zero
|
||||
blobScale = 0.8
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6).delay(1.2)) {
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.3).delay(1.7)) {
|
||||
checkmarkOpacity = 0
|
||||
blobScale = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 9: Zoom Tunnel
|
||||
|
||||
struct ZoomTunnelAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var rings: [TunnelRing] = []
|
||||
@State private var checkmarkScale: CGFloat = 0
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
@State private var starburstRotation: Double = 0
|
||||
|
||||
struct TunnelRing: Identifiable {
|
||||
let id = UUID()
|
||||
var scale: CGFloat
|
||||
var opacity: Double
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(rings) { ring in
|
||||
Circle()
|
||||
.stroke(mood.color.opacity(0.5), lineWidth: 3)
|
||||
.scaleEffect(ring.scale)
|
||||
.opacity(ring.opacity)
|
||||
}
|
||||
|
||||
ForEach(0..<8, id: \.self) { i in
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [mood.color, .clear], startPoint: .center, endPoint: .trailing))
|
||||
.frame(width: 150, height: 4)
|
||||
.offset(x: 75)
|
||||
.rotationEffect(.degrees(Double(i) * 45 + starburstRotation))
|
||||
.opacity(checkmarkOpacity * 0.6)
|
||||
}
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 70))
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.white, mood.color], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
.shadow(color: mood.color, radius: 15)
|
||||
.scaleEffect(checkmarkScale)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
for i in 0..<6 {
|
||||
rings.append(TunnelRing(scale: CGFloat(3.0 - Double(i) * 0.4), opacity: 0))
|
||||
}
|
||||
|
||||
for i in rings.indices {
|
||||
let delay = Double(i) * 0.1
|
||||
withAnimation(.easeIn(duration: 0.6).delay(delay)) {
|
||||
rings[i].scale = 0.1
|
||||
rings[i].opacity = 1
|
||||
}
|
||||
withAnimation(.easeIn(duration: 0.2).delay(delay + 0.4)) {
|
||||
rings[i].opacity = 0
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.linear(duration: 2.0)) {
|
||||
starburstRotation = 45
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.5)) {
|
||||
checkmarkScale = 1.1
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.7)) {
|
||||
checkmarkScale = 1.0
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.3).delay(1.5)) {
|
||||
checkmarkOpacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation 10: Gravity Drop
|
||||
|
||||
struct GravityDropAnimation: View {
|
||||
let mood: Mood
|
||||
|
||||
@State private var checkmarkOffset: CGFloat = -200
|
||||
@State private var checkmarkRotation: Double = -30
|
||||
@State private var checkmarkScale: CGFloat = 0.5
|
||||
@State private var checkmarkOpacity: Double = 0
|
||||
@State private var impactRingScale: CGFloat = 0.3
|
||||
@State private var impactRingOpacity: Double = 0
|
||||
@State private var dustParticles: [DustParticle] = []
|
||||
|
||||
struct DustParticle: Identifiable {
|
||||
let id = UUID()
|
||||
var xOffset: CGFloat
|
||||
var yOffset: CGFloat = 0
|
||||
var opacity: Double = 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(mood.color.opacity(0.5), lineWidth: 3)
|
||||
.scaleEffect(impactRingScale)
|
||||
.opacity(impactRingOpacity)
|
||||
.offset(y: 30)
|
||||
|
||||
ForEach(dustParticles) { particle in
|
||||
Circle()
|
||||
.fill(mood.color.opacity(0.4))
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: particle.xOffset, y: particle.yOffset + 30)
|
||||
.opacity(particle.opacity)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(mood.color)
|
||||
.frame(width: 80, height: 80)
|
||||
.shadow(color: mood.color.opacity(0.5), radius: 10, y: 5)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 40, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.offset(y: checkmarkOffset)
|
||||
.rotationEffect(.degrees(checkmarkRotation))
|
||||
.scaleEffect(checkmarkScale)
|
||||
.opacity(checkmarkOpacity)
|
||||
}
|
||||
.onAppear {
|
||||
for i in 0..<8 {
|
||||
dustParticles.append(DustParticle(xOffset: CGFloat(i - 4) * 20))
|
||||
}
|
||||
|
||||
withAnimation(.easeIn(duration: 0.1)) {
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
|
||||
withAnimation(.easeIn(duration: 0.4)) {
|
||||
checkmarkOffset = 30
|
||||
checkmarkRotation = 10
|
||||
checkmarkScale = 1.0
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2).delay(0.4)) {
|
||||
checkmarkOffset = -20
|
||||
checkmarkRotation = -5
|
||||
}
|
||||
|
||||
withAnimation(.easeIn(duration: 0.15).delay(0.6)) {
|
||||
checkmarkOffset = 30
|
||||
checkmarkRotation = 3
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.1).delay(0.75)) {
|
||||
checkmarkOffset = 20
|
||||
checkmarkRotation = 0
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.1).delay(0.85)) {
|
||||
checkmarkOffset = 30
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.4).delay(0.4)) {
|
||||
impactRingScale = 1.5
|
||||
impactRingOpacity = 0.8
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.3).delay(0.6)) {
|
||||
impactRingOpacity = 0
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.4).delay(0.4)) {
|
||||
for i in dustParticles.indices {
|
||||
dustParticles[i].yOffset = CGFloat.random(in: -30 ... -10)
|
||||
dustParticles[i].opacity = 0.8
|
||||
}
|
||||
}
|
||||
withAnimation(.easeIn(duration: 0.4).delay(0.7)) {
|
||||
for i in dustParticles.indices {
|
||||
dustParticles[i].yOffset = 20
|
||||
dustParticles[i].opacity = 0
|
||||
}
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.3).delay(1.6)) {
|
||||
checkmarkOpacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
358
Shared/Views/SettingsView/DebugAnimationSettingsView.swift
Normal file
358
Shared/Views/SettingsView/DebugAnimationSettingsView.swift
Normal file
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// DebugAnimationSettingsView.swift
|
||||
// Feels
|
||||
//
|
||||
// Debug-only view for experimenting with vote celebration animations.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Main Debug View
|
||||
|
||||
struct DebugAnimationSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
|
||||
|
||||
@State private var selectedAnimation: CelebrationAnimationType = .vortexCheckmark
|
||||
@State private var isAnimating = false
|
||||
|
||||
private var isDark: Bool { colorScheme == .dark }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Lab Header
|
||||
labHeader
|
||||
.padding(.top, 8)
|
||||
|
||||
// Animation Picker - Horizontal Scroll Cards
|
||||
animationPicker
|
||||
.padding(.top, 16)
|
||||
|
||||
// Preview Area
|
||||
previewArea
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(
|
||||
ZStack {
|
||||
theme.currentTheme.bg
|
||||
// Subtle grid pattern for "lab" feel
|
||||
GridPatternView()
|
||||
.opacity(isDark ? 0.03 : 0.02)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lab Header
|
||||
|
||||
private var labHeader: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "flask.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.purple, .pink],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
Text("Animation Lab")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
Text("Experiment with vote celebrations")
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Picker
|
||||
|
||||
private var animationPicker: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(CelebrationAnimationType.allCases) { type in
|
||||
AnimationCard(
|
||||
type: type,
|
||||
isSelected: selectedAnimation == type,
|
||||
isDark: isDark
|
||||
) {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
selectedAnimation = type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Area
|
||||
|
||||
private var previewArea: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Current selection indicator
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(selectedAnimation.accentColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(selectedAnimation.rawValue)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(selectedAnimation.accentColor)
|
||||
|
||||
Text("—")
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(selectedAnimation.description)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// The animated voting view
|
||||
AnimatedVotingView(
|
||||
animationType: selectedAnimation,
|
||||
isAnimating: $isAnimating
|
||||
)
|
||||
.frame(height: 280)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.fill(isDark ? Color(.systemGray6) : .white)
|
||||
.shadow(color: .black.opacity(isDark ? 0.3 : 0.08), radius: 20, x: 0, y: 8)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(selectedAnimation.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid Pattern Background
|
||||
|
||||
struct GridPatternView: View {
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let gridSize: CGFloat = 20
|
||||
for x in stride(from: 0, to: size.width, by: gridSize) {
|
||||
for y in stride(from: 0, to: size.height, by: gridSize) {
|
||||
let rect = CGRect(x: x, y: y, width: 1, height: 1)
|
||||
context.fill(Path(ellipseIn: rect), with: .color(.primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Card
|
||||
|
||||
struct AnimationCard: View {
|
||||
let type: CelebrationAnimationType
|
||||
let isSelected: Bool
|
||||
let isDark: Bool
|
||||
let onTap: () -> 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user