feat: canvas-confetti style celebration + Quick Add callback
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

Confetti:
- 60 particles in 3 layered bursts (like canvas-confetti)
- Circles, rectangles, stars in 12 vibrant colors
- Physics: drift, spin, gravity, fade, scale
- Checkmark + 'Added!' text with spring animation
- Haptic feedback

Quick Add:
- FoodSearchView + AddFoodSheet now have onFoodAdded callback
- After adding from Quick Add: dismiss → fitness tab → confetti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 09:43:13 -05:00
parent b01409628f
commit 1dfa05fe4f
3 changed files with 155 additions and 49 deletions

View File

@@ -85,91 +85,194 @@ struct MainTabView: View {
} }
} }
// MARK: - Success Confetti Animation // MARK: - Success Confetti Animation (canvas-confetti style)
struct SuccessConfettiView: View { struct SuccessConfettiView: View {
@State private var particles: [ConfettiParticle] = [] @State private var particles: [ConfettiPiece] = []
@State private var checkScale: CGFloat = 0 @State private var checkScale: CGFloat = 0
@State private var checkOpacity: Double = 0
struct ConfettiParticle: Identifiable { struct ConfettiPiece: Identifiable {
let id = UUID() let id = UUID()
let emoji: String let color: Color
let size: CGFloat
let x: CGFloat let x: CGFloat
let startY: CGFloat
let endY: CGFloat
let drift: CGFloat
let delay: Double let delay: Double
let duration: Double let duration: Double
let rotation: Double let spin: Double
let shape: Int // 0=circle, 1=rect, 2=star
} }
static let colors: [Color] = [
Color(hex: "FF6B6B"), Color(hex: "4ECDC4"), Color(hex: "FFE66D"),
Color(hex: "95E1D3"), Color(hex: "F38181"), Color(hex: "AA96DA"),
Color(hex: "FCBAD3"), Color(hex: "A8D8EA"), Color(hex: "FF9671"),
Color(hex: "FFC75F"), Color(hex: "F9F871"), Color(hex: "845EC2"),
]
var body: some View { var body: some View {
ZStack { ZStack {
// Semi-transparent backdrop // Particles 3 layered bursts
Color.black.opacity(0.1)
// Confetti particles
ForEach(particles) { p in ForEach(particles) { p in
Text(p.emoji) confettiShape(p)
.font(.title) .modifier(BurstModifier(
.offset(x: p.x) startX: p.x, startY: p.startY,
.modifier(FallingModifier(delay: p.delay, duration: p.duration, rotation: p.rotation)) endY: p.endY, drift: p.drift,
delay: p.delay, duration: p.duration, spin: p.spin
))
} }
// Checkmark // Checkmark with glow
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60)) .font(.system(size: 56))
.foregroundStyle(Color.emerald) .foregroundStyle(Color.emerald)
.shadow(color: Color.emerald.opacity(0.4), radius: 20)
Text("Added!")
.font(.headline.weight(.bold))
.foregroundStyle(Color.emerald)
}
.scaleEffect(checkScale) .scaleEffect(checkScale)
.shadow(color: Color.emerald.opacity(0.3), radius: 20) .opacity(checkOpacity)
} }
.onAppear { .onAppear {
// Generate particles generateParticles()
let emojis = ["🎉", "", "🍎", "🥑", "💚", "⭐️", "🎊"]
particles = (0..<15).map { i in withAnimation(.spring(response: 0.35, dampingFraction: 0.55)) {
ConfettiParticle(
emoji: emojis[i % emojis.length],
x: CGFloat.random(in: -160...160),
delay: Double.random(in: 0...0.3),
duration: Double.random(in: 0.8...1.4),
rotation: Double.random(in: -180...180)
)
}
// Animate checkmark
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
checkScale = 1.0 checkScale = 1.0
checkOpacity = 1.0
} }
// Haptic
let generator = UINotificationFeedbackGenerator() // Fade out checkmark
generator.notificationOccurred(.success) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
} withAnimation(.easeOut(duration: 0.4)) {
checkOpacity = 0
checkScale = 0.8
} }
} }
struct FallingModifier: ViewModifier { UINotificationFeedbackGenerator().notificationOccurred(.success)
}
}
@ViewBuilder
private func confettiShape(_ p: ConfettiPiece) -> some View {
switch p.shape {
case 0:
Circle().fill(p.color).frame(width: p.size, height: p.size)
case 1:
RoundedRectangle(cornerRadius: 2)
.fill(p.color)
.frame(width: p.size, height: p.size * 0.5)
default:
Image(systemName: "star.fill")
.font(.system(size: p.size * 0.6))
.foregroundStyle(p.color)
}
}
private func generateParticles() {
var allParticles: [ConfettiPiece] = []
// Burst 1: Center explosion (fast, wide spread)
for _ in 0..<25 {
allParticles.append(ConfettiPiece(
color: Self.colors.randomElement()!,
size: CGFloat.random(in: 6...12),
x: CGFloat.random(in: -180...180),
startY: CGFloat.random(in: -50...50),
endY: CGFloat.random(in: 300...700),
drift: CGFloat.random(in: -80...80),
delay: Double.random(in: 0...0.1),
duration: Double.random(in: 0.8...1.2),
spin: Double.random(in: -360...360),
shape: Int.random(in: 0...2)
))
}
// Burst 2: Slightly delayed, wider (like canvas-confetti layering)
for _ in 0..<20 {
allParticles.append(ConfettiPiece(
color: Self.colors.randomElement()!,
size: CGFloat.random(in: 5...10),
x: CGFloat.random(in: -200...200),
startY: CGFloat.random(in: -80...0),
endY: CGFloat.random(in: 400...800),
drift: CGFloat.random(in: -120...120),
delay: Double.random(in: 0.1...0.25),
duration: Double.random(in: 1.0...1.5),
spin: Double.random(in: -540...540),
shape: Int.random(in: 0...2)
))
}
// Burst 3: Slow floaters (small, drifty)
for _ in 0..<15 {
allParticles.append(ConfettiPiece(
color: Self.colors.randomElement()!.opacity(0.7),
size: CGFloat.random(in: 4...8),
x: CGFloat.random(in: -160...160),
startY: CGFloat.random(in: -120...-40),
endY: CGFloat.random(in: 500...900),
drift: CGFloat.random(in: -60...60),
delay: Double.random(in: 0.2...0.4),
duration: Double.random(in: 1.3...2.0),
spin: Double.random(in: -720...720),
shape: Int.random(in: 0...1)
))
}
particles = allParticles
}
}
struct BurstModifier: ViewModifier {
let startX: CGFloat
let startY: CGFloat
let endY: CGFloat
let drift: CGFloat
let delay: Double let delay: Double
let duration: Double let duration: Double
let rotation: Double let spin: Double
@State private var yOffset: CGFloat = -400 @State private var progress: CGFloat = 0
@State private var opacity: Double = 1 @State private var opacity: Double = 1
@State private var angle: Double = 0
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.offset(y: yOffset) .offset(
x: startX + (drift * progress),
y: startY + (endY - startY) * progress
)
.opacity(opacity) .opacity(opacity)
.rotationEffect(.degrees(angle)) .rotationEffect(.degrees(spin * Double(progress)))
.scaleEffect(1.0 - Double(progress) * 0.3)
.onAppear { .onAppear {
withAnimation(.easeIn(duration: duration).delay(delay)) { withAnimation(.easeOut(duration: duration).delay(delay)) {
yOffset = 600 progress = 1
}
withAnimation(.easeIn(duration: duration * 0.4).delay(delay + duration * 0.6)) {
opacity = 0 opacity = 0
angle = rotation
} }
} }
} }
} }
// Fix: Array doesn't have .length in Swift private extension Color {
extension Array { init(hex: String) {
var length: Int { count } let scanner = Scanner(string: hex)
var rgbValue: UInt64 = 0
scanner.scanHexInt64(&rgbValue)
self.init(
red: Double((rgbValue >> 16) & 0xFF) / 255,
green: Double((rgbValue >> 8) & 0xFF) / 255,
blue: Double(rgbValue & 0xFF) / 255
)
}
} }
// MARK: - Assistant Sheet // MARK: - Assistant Sheet
@@ -207,7 +310,7 @@ struct AssistantSheetView: View {
if selectedMode == 0 { if selectedMode == 0 {
AssistantChatView(onFoodAdded: onFoodAdded) AssistantChatView(onFoodAdded: onFoodAdded)
} else { } else {
FoodSearchView(isSheet: true) FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
} }
} }
.background(Color.canvas) .background(Color.canvas)

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct AddFoodSheet: View { struct AddFoodSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let food: Food let food: Food
var onFoodAdded: (() -> Void)?
@State private var quantity: Double = 1.0 @State private var quantity: Double = 1.0
@State private var selectedMeal: MealType = .snack @State private var selectedMeal: MealType = .snack
@@ -234,6 +235,7 @@ struct AddFoodSheet: View {
) )
_ = try await FitnessRepository.shared.createEntry(request) _ = try await FitnessRepository.shared.createEntry(request)
dismiss() dismiss()
onFoodAdded?()
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct FoodSearchView: View { struct FoodSearchView: View {
var isSheet: Bool = false var isSheet: Bool = false
var onFoodAdded: (() -> Void)?
@State private var vm = FoodSearchViewModel() @State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food? @State private var selectedFood: Food?
@@ -48,7 +49,7 @@ struct FoodSearchView: View {
vm.search() vm.search()
} }
.sheet(item: $selectedFood) { food in .sheet(item: $selectedFood) { food in
AddFoodSheet(food: food) AddFoodSheet(food: food, onFoodAdded: onFoodAdded)
} }
} }