learning_ai_clock/ios/ChronoMind/Views/Gamification/ConfettiView.swift

193 lines
6.5 KiB
Swift

// 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
)
}
}