
Milestone Celebration
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates achievement celebration UI with confetti animations, badge unlocks, progress milestones, haptic feedback, and optional share-to-social cards.
About
Generates a milestone celebration system with CAEmitterLayer confetti, achievement badges, spring-animated overlays, haptics, and shareable cards triggered at key thresholds. A developer uses it to add gamified celebrations when users hit milestones.
- Confetti particle effects and animated badge unlocks
- Haptics plus optional shareable achievement cards
Milestone Celebration by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill milestone-celebrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates achievement celebration UI with confetti animations, badge unlocks, progress milestones, haptic feedback, and optional share-to-social cards.
Files
Milestone Celebration Generator
Generate a production milestone celebration system with confetti particle animations via CAEmitterLayer, achievement badge views with locked/unlocked states, a celebration overlay with spring animations, haptic feedback, and optional shareable achievement cards — all triggered automatically when users hit key thresholds.
When This Skill Activates
Use this skill when the user:
- Asks to "add celebrations" or "celebrate achievements"
- Wants "confetti animation" or "particle effects for milestones"
- Mentions "achievement badges" or "badge unlock animation"
- Asks about "milestone rewards" or "progress milestones"
- Wants to "celebrate achievement" or "trigger celebration on threshold"
- Asks about "level-up animation" or "gamification celebrations"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 17+ / macOS 14+ for
@Observable, spring animations,sensoryFeedback) - [ ] Check for
UIKitavailability (iOS —CAEmitterLayerfor confetti, haptics) - [ ] Identify source file locations
2. Conflict Detection
Search for existing celebration/animation code:
Glob: **/*Confetti*.swift, **/*Celebration*.swift, **/*Achievement*.swift, **/*Milestone*.swift, **/*Badge*.swift
Grep: "CAEmitterLayer" or "CAEmitterCell" or "UINotificationFeedbackGenerator" or "confetti" or "celebration"If existing celebration or gamification library found (e.g., custom confetti, third-party particle libraries):
- Ask if user wants to replace or integrate with it
- If keeping, advise on best practices instead of generating
3. Platform Detection
Determine if generating for iOS (UIKit-based confetti + haptics) or macOS (Core Animation confetti, no haptics) or both (cross-platform with feature gates).
Configuration Questions
Ask user via AskUserQuestion:
1. Celebration type? (multi-select)
- Confetti burst (full-screen particle animation)
- Badge unlock (icon reveal with glow animation)
- Level-up (progress ring fill + title transition)
- Custom (user defines their own celebration style)
2. Haptic feedback?
- Yes — success haptic on celebration trigger (recommended)
- No — silent celebrations only
3. Shareable achievement card?
- Yes — render achievement as image for social sharing via
ShareLink - No — in-app celebration only
4. Sound effects?
- Yes — play system sound or bundled audio on celebration
- No — visual only
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. Milestone.swift — Model with id, title, description, threshold, icon, unlock state. Codable + Sendable. 2. MilestoneTracker.swift — @Observable class tracking progress toward milestones, checking thresholds, triggering celebrations. Persists unlock state via UserDefaults or file storage. 3. ConfettiView.swift — UIViewRepresentable wrapping CAEmitterLayer for confetti particle animation. Configurable colors, duration, density. Respects Reduce Motion.
Step 3: Create UI Files
4. CelebrationOverlay.swift — Full-screen overlay combining confetti + badge reveal + congratulations message. Auto-dismisses after configurable duration. Uses withAnimation(.spring). 5. MilestoneBadgeView.swift — Individual badge view with locked/unlocked states, SF Symbol icon, progress ring for partial progress. 6. MilestoneCollectionView.swift — Grid layout of all milestones showing locked/unlocked state with progress indicators.
Step 4: Create Optional Files
Based on configuration:
HapticManager.swift— If haptic feedback selected (thin wrapper aroundUINotificationFeedbackGenerator/UIImpactFeedbackGenerator)ShareableMilestoneCard.swift— If shareable selected (renders milestone as image viaImageRenderer+ShareLink)
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/MilestoneCelebration/ - If
App/exists ->App/MilestoneCelebration/ - Otherwise ->
MilestoneCelebration/
Output Format
After generation, provide:
Files Created
MilestoneCelebration/
├── Milestone.swift # Model with threshold, icon, unlock state
├── MilestoneTracker.swift # @Observable tracker with persistence
├── ConfettiView.swift # CAEmitterLayer confetti animation
├── CelebrationOverlay.swift # Full-screen celebration overlay
├── MilestoneBadgeView.swift # Badge with locked/unlocked + progress ring
├── MilestoneCollectionView.swift # Grid of all milestones
├── HapticManager.swift # Celebration haptics (optional)
└── ShareableMilestoneCard.swift # Achievement share card (optional)Integration Steps
Trigger celebration on threshold:
struct WorkoutCompleteView: View {
@State private var tracker = MilestoneTracker()
@State private var celebratingMilestone: Milestone?
var body: some View {
VStack {
// ... workout summary content ...
Button("Save Workout") {
saveWorkout()
let newCount = totalWorkouts + 1
if let milestone = tracker.checkThreshold(value: newCount, category: .workouts) {
celebratingMilestone = milestone
}
}
}
.overlay {
if let milestone = celebratingMilestone {
CelebrationOverlay(milestone: milestone) {
celebratingMilestone = nil
}
}
}
}
}Badge collection screen:
struct ProfileView: View {
@State private var tracker = MilestoneTracker()
var body: some View {
NavigationStack {
ScrollView {
MilestoneCollectionView(
milestones: tracker.allMilestones,
columns: 3
)
}
.navigationTitle("Achievements")
}
}
}Share an achievement:
struct MilestoneDetailView: View {
let milestone: Milestone
@State private var showShareCard = false
var body: some View {
VStack {
MilestoneBadgeView(milestone: milestone, size: .large)
Text(milestone.title).font(.title2.bold())
Text(milestone.milestoneDescription).foregroundStyle(.secondary)
if milestone.isUnlocked {
Button("Share Achievement") {
showShareCard = true
}
.buttonStyle(.borderedProminent)
}
}
.sheet(isPresented: $showShareCard) {
ShareableMilestoneCard(milestone: milestone, brandName: "FitApp")
}
}
}Testing
@Test
func milestoneUnlocksAtThreshold() {
let tracker = MilestoneTracker(store: InMemoryMilestoneStore())
let milestone = Milestone(
id: "first-10",
title: "First 10",
milestoneDescription: "Complete 10 workouts",
threshold: 10,
iconName: "flame.fill"
)
tracker.register(milestone)
let result = tracker.checkThreshold(value: 10, for: milestone.id)
#expect(result != nil)
#expect(result?.isUnlocked == true)
#expect(result?.unlockedDate != nil)
}
@Test
func milestoneDoesNotUnlockBelowThreshold() {
let tracker = MilestoneTracker(store: InMemoryMilestoneStore())
let milestone = Milestone(
id: "first-10",
title: "First 10",
milestoneDescription: "Complete 10 workouts",
threshold: 10,
iconName: "flame.fill"
)
tracker.register(milestone)
let result = tracker.checkThreshold(value: 9, for: milestone.id)
#expect(result == nil)
}
@Test
func confettiRespectsReduceMotion() {
// When Reduce Motion is enabled, ConfettiView should not emit particles
let config = ConfettiConfiguration(reduceMotionOverride: true)
#expect(config.shouldAnimate == false)
}
@Test
func celebrationOverlayAutoDismisses() async throws {
// Verify overlay dismisses after the configured duration
let expectation = XCTestExpectation(description: "Dismissed")
let duration: TimeInterval = 0.5
// Test that the onDismiss callback fires after duration
}Common Patterns
Trigger Celebration on Threshold
Best for: fitness, learning, productivity apps.
- Define milestones with numeric thresholds (10 workouts, 100 pages read, 7-day streak)
MilestoneTrackerchecks values against registered milestones- Celebration fires only on the first crossing — subsequent calls are no-ops
- Persist unlock state so celebrations do not repeat
Badge Collection View
Best for: gamification, loyalty, progression systems.
- Grid of all milestones: locked ones are dimmed/grayscale, unlocked ones are vibrant
- Progress ring shows how close user is to unlocking each badge
- Tapping a badge shows detail view with full description and share option
- Counter showing "12 of 20 unlocked" at the top
Share Achievement
Best for: social apps, fitness, competitions.
- Render the unlocked badge + title + metric as a shareable image
- Use
ImageRendererat@2xscale for Retina quality - Include app branding and optional QR code for deep linking
- Integrate with
ShareLinkfor native share sheet
Gotchas
CAEmitterLayer Performance on Older Devices
- Confetti with many particles (200+) can drop frames on A11 and older chips
- Cap
birthRateat 50-80 for smooth 60fps on most devices - Use
emitterCellswith only 3-5 distinct shapes to reduce GPU draw calls - Stop emission after burst duration — do not leave the emitter running indefinitely
- Profile with Instruments (Core Animation template) if users report frame drops
Reduce Motion Accessibility
- Always check
UIAccessibility.isReduceMotionEnabledbefore starting particle animations - When Reduce Motion is on: skip confetti, show a static badge reveal or a simple fade-in instead
- Use
@Environment(\.accessibilityReduceMotion)in SwiftUI for reactive updates - Provide an equally rewarding experience without animation — the badge reveal and message still appear
Haptic Feedback on Non-Supporting Devices
UINotificationFeedbackGeneratoris a no-op on devices without a Taptic Engine (e.g., iPod touch, iPad)- Always guard with
CHHapticEngine.capabilitiesForHardware().supportsHapticsbefore preparing generators - On macOS, haptics are not available — gate with
#if canImport(UIKit)and#if os(iOS) - Use
.sensoryFeedback(.success, trigger:)modifier on iOS 17+ for a simpler SwiftUI-native approach
Sound Effect Considerations
- Use
AudioServicesPlaySystemSoundfor lightweight celebration sounds (no audio session needed) - Respect the device silent/mute switch —
AudioServicesPlaySystemSoundhonors it automatically - For custom sounds, keep files under 30 seconds and use CAF or WAV format
- Do not play sounds when Reduce Motion is enabled (some users enable it for sensory reasons)
Persistence Edge Cases
- If the app is terminated between unlocking a milestone and persisting it, the user may see the celebration again
- Use
UserDefaultsfor simple unlock flags or a lightweight JSON file for full milestone state - For
SwiftDataorCoreDataapps, consider storing unlock state alongside the user's data model - Sync unlock state via CloudKit if the app supports multiple devices
References
- templates.md — All production Swift templates
- Related:
generators/streak-tracker— Streak tracking pairs naturally with milestone celebrations - Related:
generators/share-card— Shareable achievement card rendering and social sharing - Related:
generators/variable-rewards— Variable reward schedules for deeper engagement
Milestone Celebration Code Templates
Production-ready Swift templates for milestone celebration UI. All code targets iOS 17+ / macOS 14+ and uses @Observable, modern Swift concurrency, and accessibility-aware animations.
Platform Compatibility
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endifMilestone.swift
import Foundation
/// A milestone achievement that can be unlocked when a threshold is reached.
///
/// Milestones are immutable value types that describe an achievement.
/// Unlock state is tracked separately by `MilestoneTracker`.
///
/// Usage:
/// ```swift
/// let milestone = Milestone(
/// id: "workouts-50",
/// title: "Half Century",
/// milestoneDescription: "Complete 50 workouts",
/// threshold: 50,
/// iconName: "flame.fill"
/// )
/// ```
struct Milestone: Identifiable, Codable, Sendable, Hashable {
/// Unique identifier for this milestone.
let id: String
/// Display title shown in the celebration overlay and badge.
let title: String
/// Longer description explaining how to earn this milestone.
let milestoneDescription: String
/// The numeric value that must be reached or exceeded to unlock.
let threshold: Double
/// SF Symbol name for the badge icon.
let iconName: String
/// Optional category for grouping milestones (e.g., "workouts", "streaks", "social").
var category: String?
}
/// Tracks the unlock state of a single milestone.
struct MilestoneUnlockState: Codable, Sendable {
let milestoneID: String
var isUnlocked: Bool
var unlockedDate: Date?
var currentProgress: Double
/// Progress as a fraction from 0.0 to 1.0.
func progressFraction(threshold: Double) -> Double {
guard threshold > 0 else { return 1.0 }
return min(currentProgress / threshold, 1.0)
}
}
/// A milestone paired with its current unlock state for display purposes.
struct MilestoneWithState: Identifiable, Sendable {
let milestone: Milestone
let state: MilestoneUnlockState
var id: String { milestone.id }
var isUnlocked: Bool { state.isUnlocked }
var unlockedDate: Date? { state.unlockedDate }
var progressFraction: Double { state.progressFraction(threshold: milestone.threshold) }
}MilestoneTracker.swift
import Foundation
import SwiftUI
/// Tracks progress toward milestones and triggers celebrations on unlock.
///
/// Persists unlock state so celebrations fire only once per milestone.
/// Supports registering milestones, updating progress, and querying state.
///
/// Usage:
/// ```swift
/// @State private var tracker = MilestoneTracker()
///
/// // Register milestones at app launch
/// tracker.register(Milestone(id: "first-10", title: "First 10", ...))
///
/// // Check after an action
/// if let unlocked = tracker.checkThreshold(value: newCount, for: "first-10") {
/// celebratingMilestone = unlocked
/// }
/// ```
@Observable
final class MilestoneTracker {
/// All registered milestones with their current state.
private(set) var milestones: [String: Milestone] = [:]
private(set) var states: [String: MilestoneUnlockState] = [:]
private let store: any MilestoneStore
init(store: any MilestoneStore = UserDefaultsMilestoneStore()) {
self.store = store
loadStates()
}
// MARK: - Registration
/// Register a milestone to be tracked.
func register(_ milestone: Milestone) {
milestones[milestone.id] = milestone
if states[milestone.id] == nil {
states[milestone.id] = MilestoneUnlockState(
milestoneID: milestone.id,
isUnlocked: false,
unlockedDate: nil,
currentProgress: 0
)
}
}
/// Register multiple milestones at once.
func register(_ milestones: [Milestone]) {
for milestone in milestones {
register(milestone)
}
}
// MARK: - Threshold Checking
/// Update progress and check if a milestone should unlock.
///
/// Returns the `MilestoneWithState` if this call caused an unlock,
/// or `nil` if already unlocked or threshold not yet reached.
@discardableResult
func checkThreshold(value: Double, for milestoneID: String) -> MilestoneWithState? {
guard let milestone = milestones[milestoneID],
var state = states[milestoneID] else {
return nil
}
// Already unlocked — do not celebrate again
guard !state.isUnlocked else { return nil }
state.currentProgress = value
if value >= milestone.threshold {
state.isUnlocked = true
state.unlockedDate = Date()
states[milestoneID] = state
saveStates()
return MilestoneWithState(milestone: milestone, state: state)
}
states[milestoneID] = state
saveStates()
return nil
}
/// Convenience: check a threshold against all milestones in a category.
func checkThreshold(value: Double, category: String) -> MilestoneWithState? {
for (id, milestone) in milestones where milestone.category == category {
if let result = checkThreshold(value: value, for: id) {
return result
}
}
return nil
}
// MARK: - Queries
/// All milestones with their current state, sorted by threshold.
var allMilestones: [MilestoneWithState] {
milestones.values
.sorted { $0.threshold < $1.threshold }
.map { milestone in
let state = states[milestone.id] ?? MilestoneUnlockState(
milestoneID: milestone.id,
isUnlocked: false,
unlockedDate: nil,
currentProgress: 0
)
return MilestoneWithState(milestone: milestone, state: state)
}
}
/// Only unlocked milestones, sorted by unlock date (newest first).
var unlockedMilestones: [MilestoneWithState] {
allMilestones
.filter { $0.isUnlocked }
.sorted { ($0.unlockedDate ?? .distantPast) > ($1.unlockedDate ?? .distantPast) }
}
/// Count of unlocked vs total.
var progressSummary: (unlocked: Int, total: Int) {
let unlocked = states.values.filter(\.isUnlocked).count
return (unlocked, milestones.count)
}
// MARK: - Persistence
private func loadStates() {
if let loaded = try? store.load() {
for state in loaded {
states[state.milestoneID] = state
}
}
}
private func saveStates() {
try? store.save(Array(states.values))
}
/// Reset all milestone progress (for testing or account reset).
func resetAll() {
states = states.mapValues { state in
MilestoneUnlockState(
milestoneID: state.milestoneID,
isUnlocked: false,
unlockedDate: nil,
currentProgress: 0
)
}
saveStates()
}
}
// MARK: - Persistence Protocol
/// Protocol for milestone state persistence.
protocol MilestoneStore: Sendable {
func load() throws -> [MilestoneUnlockState]
func save(_ states: [MilestoneUnlockState]) throws
}
/// UserDefaults-based persistence for milestone unlock state.
final class UserDefaultsMilestoneStore: MilestoneStore {
private let key: String
private let defaults: UserDefaults
init(key: String = "milestone_unlock_states", defaults: UserDefaults = .standard) {
self.key = key
self.defaults = defaults
}
func load() throws -> [MilestoneUnlockState] {
guard let data = defaults.data(forKey: key) else { return [] }
return try JSONDecoder().decode([MilestoneUnlockState].self, from: data)
}
func save(_ states: [MilestoneUnlockState]) throws {
let data = try JSONEncoder().encode(states)
defaults.set(data, forKey: key)
}
}
/// In-memory store for testing and previews.
final class InMemoryMilestoneStore: MilestoneStore {
private var stored: [MilestoneUnlockState] = []
func load() throws -> [MilestoneUnlockState] {
stored
}
func save(_ states: [MilestoneUnlockState]) throws {
stored = states
}
}ConfettiView.swift
import SwiftUI
#if canImport(UIKit)
import UIKit
/// Configuration for the confetti particle animation.
struct ConfettiConfiguration: Sendable {
/// Colors for confetti particles.
var colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
/// Duration of the confetti burst in seconds.
var duration: TimeInterval = 3.0
/// Number of particles emitted per second (capped for performance).
var birthRate: Float = 60
/// Override for Reduce Motion preference (used in testing).
var reduceMotionOverride: Bool?
/// Whether animation should play based on accessibility settings.
var shouldAnimate: Bool {
if let override = reduceMotionOverride {
return !override
}
return !UIAccessibility.isReduceMotionEnabled
}
}
/// A SwiftUI view that displays a confetti particle animation using CAEmitterLayer.
///
/// Automatically respects Reduce Motion accessibility settings.
/// Particles burst from the top and fall with gravity and spin.
///
/// Usage:
/// ```swift
/// ConfettiView(isEmitting: $showConfetti)
/// ```
struct ConfettiView: UIViewRepresentable {
@Binding var isEmitting: Bool
var configuration: ConfettiConfiguration = ConfettiConfiguration()
func makeUIView(context: Context) -> ConfettiUIView {
ConfettiUIView(configuration: configuration)
}
func updateUIView(_ uiView: ConfettiUIView, context: Context) {
if isEmitting {
uiView.startEmitting()
// Auto-stop after duration
DispatchQueue.main.asyncAfter(deadline: .now() + configuration.duration) {
uiView.stopEmitting()
isEmitting = false
}
} else {
uiView.stopEmitting()
}
}
}
/// UIKit view that manages the CAEmitterLayer for confetti particles.
final class ConfettiUIView: UIView {
private let emitterLayer = CAEmitterLayer()
private let configuration: ConfettiConfiguration
init(configuration: ConfettiConfiguration) {
self.configuration = configuration
super.init(frame: .zero)
isUserInteractionEnabled = false
setupEmitter()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
emitterLayer.frame = bounds
emitterLayer.emitterPosition = CGPoint(x: bounds.midX, y: -20)
emitterLayer.emitterSize = CGSize(width: bounds.width, height: 1)
}
private func setupEmitter() {
emitterLayer.emitterShape = .line
emitterLayer.renderMode = .additive
emitterLayer.birthRate = 0 // Start paused
let shapes: [String] = ["circle.fill", "square.fill", "triangle.fill"]
let uiColors = configuration.colors.map { UIColor($0) }
emitterLayer.emitterCells = uiColors.flatMap { color in
shapes.prefix(2).map { shape in
makeCell(color: color, shape: shape)
}
}
layer.addSublayer(emitterLayer)
}
private func makeCell(color: UIColor, shape: String) -> CAEmitterCell {
let cell = CAEmitterCell()
// Render a small colored rectangle as the particle image
let size = CGSize(width: 10, height: 10)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
color.setFill()
if shape == "circle.fill" {
ctx.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size))
} else if shape == "triangle.fill" {
let path = UIBezierPath()
path.move(to: CGPoint(x: size.width / 2, y: 0))
path.addLine(to: CGPoint(x: size.width, y: size.height))
path.addLine(to: CGPoint(x: 0, y: size.height))
path.close()
path.fill()
} else {
ctx.fill(CGRect(origin: .zero, size: size))
}
}
cell.contents = image.cgImage
cell.birthRate = configuration.birthRate / Float(configuration.colors.count * 2)
cell.lifetime = 4.0
cell.lifetimeRange = 1.0
cell.velocity = 250
cell.velocityRange = 80
cell.emissionLongitude = .pi // Downward
cell.emissionRange = .pi / 4 // Spread angle
cell.spin = 3.0
cell.spinRange = 6.0
cell.scale = 0.6
cell.scaleRange = 0.3
cell.scaleSpeed = -0.05
cell.yAcceleration = 200 // Gravity
cell.alphaSpeed = -0.3
return cell
}
func startEmitting() {
guard configuration.shouldAnimate else { return }
emitterLayer.birthRate = 1
}
func stopEmitting() {
emitterLayer.birthRate = 0
}
}
#elseif canImport(AppKit)
import AppKit
/// Configuration for the confetti particle animation (macOS).
struct ConfettiConfiguration: Sendable {
var colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
var duration: TimeInterval = 3.0
var birthRate: Float = 60
var reduceMotionOverride: Bool?
var shouldAnimate: Bool {
if let override = reduceMotionOverride {
return !override
}
return !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
}
/// macOS confetti view using Core Animation.
struct ConfettiView: NSViewRepresentable {
@Binding var isEmitting: Bool
var configuration: ConfettiConfiguration = ConfettiConfiguration()
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
let emitterLayer = CAEmitterLayer()
emitterLayer.emitterShape = .line
emitterLayer.renderMode = .additive
emitterLayer.birthRate = 0
view.layer?.addSublayer(emitterLayer)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let emitterLayer = nsView.layer?.sublayers?.first as? CAEmitterLayer else { return }
emitterLayer.frame = nsView.bounds
emitterLayer.emitterPosition = CGPoint(x: nsView.bounds.midX, y: nsView.bounds.maxY + 20)
emitterLayer.emitterSize = CGSize(width: nsView.bounds.width, height: 1)
if isEmitting && configuration.shouldAnimate {
emitterLayer.birthRate = 1
DispatchQueue.main.asyncAfter(deadline: .now() + configuration.duration) {
emitterLayer.birthRate = 0
isEmitting = false
}
} else {
emitterLayer.birthRate = 0
}
}
}
#endifCelebrationOverlay.swift
import SwiftUI
/// A full-screen celebration overlay that combines confetti, badge reveal,
/// and a congratulations message.
///
/// Auto-dismisses after the configured duration. Uses spring animations
/// for the badge entrance. Falls back to a simple fade when Reduce Motion
/// is enabled.
///
/// Usage:
/// ```swift
/// .overlay {
/// if let milestone = celebratingMilestone {
/// CelebrationOverlay(milestone: milestone) {
/// celebratingMilestone = nil
/// }
/// }
/// }
/// ```
struct CelebrationOverlay: View {
let milestone: MilestoneWithState
let onDismiss: () -> Void
var duration: TimeInterval = 4.0
@State private var showConfetti = false
@State private var showBadge = false
@State private var showText = false
@State private var badgeScale: CGFloat = 0.3
@State private var textOpacity: Double = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
// Dimmed background
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture {
dismiss()
}
// Confetti layer
ConfettiView(isEmitting: $showConfetti)
.ignoresSafeArea()
.allowsHitTesting(false)
// Badge and message
VStack(spacing: 24) {
MilestoneBadgeView(
milestone: milestone,
size: .large
)
.scaleEffect(badgeScale)
VStack(spacing: 8) {
Text("Achievement Unlocked!")
.font(.headline)
.foregroundStyle(.white)
Text(milestone.milestone.title)
.font(.title2.bold())
.foregroundStyle(.white)
Text(milestone.milestone.milestoneDescription)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.opacity(textOpacity)
}
}
.onAppear {
triggerCelebration()
}
#if os(iOS)
.sensoryFeedback(.success, trigger: showBadge)
#endif
}
private func triggerCelebration() {
if reduceMotion {
// Reduced motion: simple fade-in, no confetti
withAnimation(.easeIn(duration: 0.3)) {
badgeScale = 1.0
textOpacity = 1.0
}
} else {
// Full animation: confetti + spring badge + fade text
showConfetti = true
withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) {
badgeScale = 1.0
}
withAnimation(.easeIn(duration: 0.4).delay(0.3)) {
textOpacity = 1.0
}
}
// Auto-dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
dismiss()
}
}
private func dismiss() {
withAnimation(.easeOut(duration: 0.3)) {
badgeScale = 0.8
textOpacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onDismiss()
}
}
}MilestoneBadgeView.swift
import SwiftUI
/// A badge view representing a single milestone with locked/unlocked states.
///
/// Shows an SF Symbol icon inside a circle. When locked, displays in grayscale
/// with a lock overlay. When partially complete, shows a progress ring.
/// When unlocked, displays in full color with a subtle glow.
///
/// Usage:
/// ```swift
/// MilestoneBadgeView(milestone: milestoneWithState, size: .medium)
/// ```
struct MilestoneBadgeView: View {
let milestone: MilestoneWithState
var size: BadgeSize = .medium
enum BadgeSize {
case small, medium, large
var dimension: CGFloat {
switch self {
case .small: return 48
case .medium: return 72
case .large: return 120
}
}
var iconFont: Font {
switch self {
case .small: return .body
case .medium: return .title2
case .large: return .largeTitle
}
}
var ringLineWidth: CGFloat {
switch self {
case .small: return 2
case .medium: return 3
case .large: return 5
}
}
}
var body: some View {
ZStack {
// Background circle
Circle()
.fill(backgroundFill)
.frame(width: size.dimension, height: size.dimension)
// Progress ring (when not yet unlocked)
if !milestone.isUnlocked {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: size.ringLineWidth)
.frame(width: size.dimension, height: size.dimension)
Circle()
.trim(from: 0, to: milestone.progressFraction)
.stroke(
Color.accentColor,
style: StrokeStyle(lineWidth: size.ringLineWidth, lineCap: .round)
)
.frame(width: size.dimension, height: size.dimension)
.rotationEffect(.degrees(-90))
}
// Icon
Image(systemName: milestone.milestone.iconName)
.font(size.iconFont)
.foregroundStyle(iconColor)
.symbolEffect(.bounce, value: milestone.isUnlocked)
// Lock overlay for locked milestones
if !milestone.isUnlocked {
Circle()
.fill(.ultraThinMaterial)
.frame(width: size.dimension, height: size.dimension)
Image(systemName: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.shadow(color: milestone.isUnlocked ? .accentColor.opacity(0.4) : .clear, radius: 8)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
private var backgroundFill: some ShapeStyle {
if milestone.isUnlocked {
return AnyShapeStyle(Color.accentColor.opacity(0.15))
} else {
return AnyShapeStyle(Color.secondary.opacity(0.08))
}
}
private var iconColor: Color {
milestone.isUnlocked ? .accentColor : .secondary
}
private var accessibilityLabel: String {
if milestone.isUnlocked {
return "\(milestone.milestone.title), unlocked"
} else {
let percent = Int(milestone.progressFraction * 100)
return "\(milestone.milestone.title), locked, \(percent) percent progress"
}
}
}MilestoneCollectionView.swift
import SwiftUI
/// A grid layout displaying all milestones with their locked/unlocked states
/// and progress indicators.
///
/// Shows a summary header ("12 of 20 unlocked") and a responsive grid
/// of badge views. Tapping a badge navigates to a detail view.
///
/// Usage:
/// ```swift
/// MilestoneCollectionView(milestones: tracker.allMilestones, columns: 3)
/// ```
struct MilestoneCollectionView: View {
let milestones: [MilestoneWithState]
var columns: Int = 3
@State private var selectedMilestone: MilestoneWithState?
private var gridColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: columns)
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Summary header
summaryHeader
// Badge grid
LazyVGrid(columns: gridColumns, spacing: 20) {
ForEach(milestones) { item in
badgeCell(item)
}
}
}
.padding()
.sheet(item: $selectedMilestone) { item in
MilestoneDetailSheet(milestone: item)
}
}
private var summaryHeader: some View {
let unlocked = milestones.filter(\.isUnlocked).count
let total = milestones.count
return HStack {
Image(systemName: "trophy.fill")
.foregroundStyle(.yellow)
Text("\(unlocked) of \(total) Unlocked")
.font(.headline)
Spacer()
}
}
private func badgeCell(_ item: MilestoneWithState) -> some View {
Button {
selectedMilestone = item
} label: {
VStack(spacing: 8) {
MilestoneBadgeView(milestone: item, size: .medium)
Text(item.milestone.title)
.font(.caption)
.foregroundStyle(item.isUnlocked ? .primary : .secondary)
.lineLimit(2)
.multilineTextAlignment(.center)
.help(item.milestone.title)
}
}
.buttonStyle(.plain)
}
}
/// Detail sheet shown when tapping a milestone badge.
private struct MilestoneDetailSheet: View {
let milestone: MilestoneWithState
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 24) {
MilestoneBadgeView(milestone: milestone, size: .large)
Text(milestone.milestone.title)
.font(.title2.bold())
Text(milestone.milestone.milestoneDescription)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if milestone.isUnlocked, let date = milestone.unlockedDate {
Label(
"Unlocked \(date.formatted(date: .abbreviated, time: .omitted))",
systemImage: "checkmark.seal.fill"
)
.font(.subheadline)
.foregroundStyle(.green)
} else {
let percent = Int(milestone.progressFraction * 100)
ProgressView(value: milestone.progressFraction) {
Text("\(percent)% complete")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 40)
}
Spacer()
}
.padding(.top, 32)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
// Conform MilestoneWithState to Hashable for .sheet(item:) support
extension MilestoneWithState: Hashable {
static func == (lhs: MilestoneWithState, rhs: MilestoneWithState) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}HapticManager.swift
#if os(iOS)
import UIKit
import CoreHaptics
/// Thin wrapper around UIKit haptic generators for celebration feedback.
///
/// Checks device haptic capability before firing. Provides preset
/// patterns for different celebration intensities.
///
/// Usage:
/// ```swift
/// let haptics = HapticManager()
/// haptics.playSuccess() // Milestone unlocked
/// haptics.playLevelUp() // Multiple rapid taps for level-up
/// ```
final class HapticManager: Sendable {
/// Whether the device supports haptic feedback.
static let isSupported: Bool = {
CHHapticEngine.capabilitiesForHardware().supportsHaptics
}()
/// Play a success notification haptic (milestone unlocked).
func playSuccess() {
guard Self.isSupported else { return }
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(.success)
}
/// Play a warning notification haptic (approaching milestone).
func playWarning() {
guard Self.isSupported else { return }
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(.warning)
}
/// Play a medium impact haptic (badge tap, selection).
func playImpact(intensity: CGFloat = 0.7) {
guard Self.isSupported else { return }
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.prepare()
generator.impactOccurred(intensity: intensity)
}
/// Play a level-up haptic pattern: three quick taps with rising intensity.
func playLevelUp() {
guard Self.isSupported else { return }
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.prepare()
let delays: [(TimeInterval, CGFloat)] = [
(0.0, 0.4),
(0.1, 0.7),
(0.2, 1.0)
]
for (delay, intensity) in delays {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
generator.impactOccurred(intensity: intensity)
}
}
}
/// Play a custom haptic pattern using CoreHaptics for richer feedback.
func playCustomPattern(events: [CHHapticEvent]) {
guard Self.isSupported else { return }
do {
let engine = try CHHapticEngine()
try engine.start()
let pattern = try CHHapticPattern(events: events, parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: CHHapticTimeImmediate)
// Engine stops itself when pattern completes
engine.notifyWhenPlayersFinished { _ in
return .stopEngine
}
} catch {
// Haptics are non-critical — fail silently
}
}
}
#endifEnvironment Integration
import SwiftUI
/// Environment key for injecting MilestoneTracker throughout the view hierarchy.
private struct MilestoneTrackerKey: EnvironmentKey {
static let defaultValue = MilestoneTracker()
}
extension EnvironmentValues {
var milestoneTracker: MilestoneTracker {
get { self[MilestoneTrackerKey.self] }
set { self[MilestoneTrackerKey.self] = newValue }
}
}
/// View modifier that checks a milestone threshold and shows a celebration overlay.
///
/// Usage:
/// ```swift
/// ContentView()
/// .celebrateOnMilestone(value: workoutCount, milestoneID: "workouts-50")
/// ```
struct CelebrationModifier: ViewModifier {
let value: Double
let milestoneID: String
@Environment(\.milestoneTracker) private var tracker
@State private var celebratingMilestone: MilestoneWithState?
func body(content: Content) -> some View {
content
.onChange(of: value) { _, newValue in
if let unlocked = tracker.checkThreshold(value: newValue, for: milestoneID) {
celebratingMilestone = unlocked
}
}
.overlay {
if let milestone = celebratingMilestone {
CelebrationOverlay(milestone: milestone) {
celebratingMilestone = nil
}
}
}
}
}
extension View {
/// Automatically trigger a celebration overlay when a value crosses a milestone threshold.
func celebrateOnMilestone(value: Double, milestoneID: String) -> some View {
modifier(CelebrationModifier(value: value, milestoneID: milestoneID))
}
}