Files
Reflect/Shared/Views/CelebrationAnimations.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

871 lines
31 KiB
Swift

//
// CelebrationAnimations.swift
// Reflect
//
// Full-view celebration animations that play after voting.
//
import SwiftUI
// MARK: - Animation Type Enum
enum CelebrationAnimationType: String, CaseIterable, Identifiable {
case confettiCannon = "Confetti"
case vortexCheckmark = "Vortex"
case explosionReveal = "Explosion"
case flipReveal = "Flip"
case shatterReform = "Shatter"
case pulseWave = "Pulse Wave"
case fireworks = "Fireworks"
case morphBlob = "Morph"
case zoomTunnel = "Tunnel"
case gravityDrop = "Gravity"
var id: String { rawValue }
/// Int index for AppStorage persistence (enum uses String raw values)
var index: Int {
Self.allCases.firstIndex(of: self) ?? 0
}
/// Restore from persisted Int index
static func fromIndex(_ index: Int) -> CelebrationAnimationType {
guard index >= 0, index < allCases.count else { return .confettiCannon }
return allCases[index]
}
var icon: String {
switch self {
case .confettiCannon: return "party.popper.fill"
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 .morphBlob: return "drop.fill"
case .zoomTunnel: return "circle.dashed"
case .gravityDrop: return "arrow.down.to.line"
}
}
var description: String {
switch self {
case .confettiCannon: return "Party confetti"
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 .morphBlob: return "Liquid transform"
case .zoomTunnel: return "Warp speed zoom"
case .gravityDrop: return "Falls with bounce"
}
}
var accentColor: Color {
switch self {
case .confettiCannon: return .pink
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 .morphBlob: return .cyan
case .zoomTunnel: return .indigo
case .gravityDrop: return .mint
}
}
var duration: Double {
switch self {
case .confettiCannon: return 2.2
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 .morphBlob: return 2.0
case .zoomTunnel: return 1.8
case .gravityDrop: return 2.0
}
}
static var random: CelebrationAnimationType {
allCases.randomElement() ?? .confettiCannon
}
}
// 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
Task { @MainActor in
try? await Task.sleep(for: .seconds(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
private enum AnimationConstants {
static let shatterPhaseDuration: TimeInterval = 0.6
static let checkmarkAppearDelay: TimeInterval = 1.1
static let fadeOutDelay: TimeInterval = 1.8
}
@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
Task { @MainActor in
try? await Task.sleep(for: .seconds(AnimationConstants.shatterPhaseDuration))
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
Task { @MainActor in
try? await Task.sleep(for: .seconds(AnimationConstants.checkmarkAppearDelay))
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
checkmarkOpacity = 1
}
}
// Phase 4: Fade out
Task { @MainActor in
try? await Task.sleep(for: .seconds(AnimationConstants.fadeOutDelay))
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
}
}
}
}