193 lines
6.5 KiB
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
|
|
)
|
|
}
|
|
}
|