// ── Confetti Celebration ─────────────────────────────────────── // Gentle confetti animation for streak milestones and badge unlocks import SwiftUI struct ConfettiView: View { let colors: [Color] @State private var particles: [ConfettiParticle] = [] @State private var isActive = false init(colors: [Color] = [ CMColors.accent, CMColors.success, CMColors.important, CMColors.standard, CMColors.critical, CMColors.gentle, ]) { self.colors = colors } var body: some View { GeometryReader { geo in ZStack { ForEach(particles) { particle in Circle() .fill(particle.color) .frame(width: particle.size, height: particle.size) .position(particle.position) .opacity(particle.opacity) .rotationEffect(.degrees(particle.rotation)) } } .onAppear { generateParticles(in: geo.size) withAnimation(.easeOut(duration: 2.5)) { isActive = true animateParticles(in: geo.size) } // Fade out after animation DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { withAnimation(.easeOut(duration: 0.5)) { for i in particles.indices { particles[i].opacity = 0 } } } } } .allowsHitTesting(false) } private func generateParticles(in size: CGSize) { particles = (0..<40).map { _ in ConfettiParticle( id: UUID().uuidString, color: colors.randomElement() ?? .white, size: CGFloat.random(in: 4...10), position: CGPoint( x: CGFloat.random(in: 0...size.width), y: -20 ), targetY: CGFloat.random(in: size.height * 0.3...size.height), rotation: Double.random(in: 0...360), opacity: 1.0, delay: Double.random(in: 0...0.5) ) } } private func animateParticles(in size: CGSize) { for i in particles.indices { let targetX = particles[i].position.x + CGFloat.random(in: -60...60) let targetY = particles[i].targetY withAnimation( .interpolatingSpring(stiffness: 20, damping: 8) .delay(particles[i].delay) ) { particles[i].position = CGPoint(x: targetX, y: targetY) particles[i].rotation += Double.random(in: 180...720) } } } } struct ConfettiParticle: Identifiable { let id: String let color: Color let size: CGFloat var position: CGPoint let targetY: CGFloat var rotation: Double var opacity: Double let delay: Double } // MARK: - Badge Celebration Overlay struct BadgeCelebrationOverlay: View { let badge: Badge let onDismiss: () -> Void @State private var scale: CGFloat = 0.5 @State private var opacity: Double = 0 var body: some View { ZStack { // Dimmed background Color.black.opacity(0.6) .ignoresSafeArea() .onTapGesture { onDismiss() } // Confetti ConfettiView() // Badge card VStack(spacing: CMSpacing.xl) { Spacer() VStack(spacing: CMSpacing.lg) { // Badge icon ZStack { Circle() .fill(tierGradient(badge.tier)) .frame(width: 100, height: 100) .shadow(color: tierColor(badge.tier).opacity(0.5), radius: 20) Image(systemName: badge.icon) .font(.system(size: 44)) .foregroundStyle(.white) } Text("Badge Unlocked!") .font(CMFonts.body(size: 14, weight: .medium)) .foregroundStyle(CMColors.textSecondary) Text(badge.title) .font(CMFonts.display(size: 28)) .foregroundStyle(CMColors.text) Text(badge.description) .font(CMFonts.body(size: 16)) .foregroundStyle(CMColors.textSecondary) Button { HapticEngine.tap() onDismiss() } label: { Text("Awesome!") .font(CMFonts.body(size: 16, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, CMSpacing.md) .background(tierColor(badge.tier)) .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) } .padding(.horizontal, CMSpacing.xxl) } .padding(CMSpacing.xxl) .background(CMColors.surface) .clipShape(RoundedRectangle(cornerRadius: CMRadius.xl)) .padding(.horizontal, CMSpacing.xl) Spacer() } .scaleEffect(scale) .opacity(opacity) } .onAppear { HapticEngine.fire(urgency: .standard) withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { scale = 1.0 opacity = 1.0 } } } private func tierColor(_ tier: BadgeTier) -> Color { switch tier { case .bronze: return Color(red: 0.8, green: 0.5, blue: 0.2) case .silver: return Color(red: 0.75, green: 0.75, blue: 0.8) case .gold: return Color(red: 1.0, green: 0.84, blue: 0.0) case .platinum: return Color(red: 0.7, green: 0.85, blue: 0.95) case .diamond: return Color(red: 0.6, green: 0.8, blue: 1.0) } } private func tierGradient(_ tier: BadgeTier) -> LinearGradient { let color = tierColor(tier) return LinearGradient( colors: [color, color.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing ) } }