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

Confetti (matches ReactFlux/canvas-confetti exactly):
- 100 particles from bottom-right corner
- angle=120°, spread=70° (upper-left fan)
- Real physics: velocity + gravity
- 3 shapes: circles, rectangles, stars
- 12 vibrant colors
- Particles drift, spin, fade naturally
- Checkmark + 'Added!' overlay

Quick Add fix:
- onFoodAdded callback wired through FoodSearchView → AddFoodSheet
- Both AI Chat and Quick Add now dismiss → switch to fitness → confetti

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 10:24:55 -05:00
parent ff9e6101f8
commit c6f6e6f6b9

View File

@@ -24,21 +24,17 @@ struct ContentView: View {
struct MainTabView: View { struct MainTabView: View {
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var showAssistant = false @State private var showAssistant = false
@State private var showSuccess = false @State private var showConfetti = false
var body: some View { var body: some View {
ZStack { ZStack {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
HomeView(selectedTab: $selectedTab) HomeView(selectedTab: $selectedTab)
.tabItem { .tabItem { Label("Home", systemImage: "house.fill") }
Label("Home", systemImage: "house.fill")
}
.tag(0) .tag(0)
FitnessTabView() FitnessTabView()
.tabItem { .tabItem { Label("Fitness", systemImage: "flame.fill") }
Label("Fitness", systemImage: "flame.fill")
}
.tag(1) .tag(1)
} }
.tint(Color.accentWarm) .tint(Color.accentWarm)
@@ -48,9 +44,7 @@ struct MainTabView: View {
Spacer() Spacer()
HStack { HStack {
Spacer() Spacer()
Button { Button { showAssistant = true } label: {
showAssistant = true
} label: {
Image(systemName: "plus") Image(systemName: "plus")
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -64,227 +58,213 @@ struct MainTabView: View {
.padding(.bottom, 70) .padding(.bottom, 70)
} }
// Success overlay // Confetti overlay
if showSuccess { if showConfetti {
SuccessConfettiView() ConfettiView()
.ignoresSafeArea() .ignoresSafeArea()
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
.sheet(isPresented: $showAssistant) { .sheet(isPresented: $showAssistant) {
AssistantSheetView(onFoodAdded: { AssistantSheetView(onFoodAdded: foodAdded)
showAssistant = false }
selectedTab = 1 }
// Show confetti
withAnimation { showSuccess = true } private func foodAdded() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { showAssistant = false
withAnimation { showSuccess = false } selectedTab = 1
} triggerConfetti()
}) }
private func triggerConfetti() {
showConfetti = true
UINotificationFeedbackGenerator().notificationOccurred(.success)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showConfetti = false
} }
} }
} }
// MARK: - Success Confetti Animation (canvas-confetti style) // MARK: - Confetti (canvas-confetti style: burst from bottom-right)
struct SuccessConfettiView: View { struct ConfettiView: View {
@State private var particles: [ConfettiPiece] = [] @State private var particles: [Particle] = []
@State private var checkScale: CGFloat = 0 @State private var showCheck = false
@State private var checkOpacity: Double = 0
struct ConfettiPiece: Identifiable { static let palette: [Color] = [
.red, .orange, .yellow, .green, .blue, .purple, .pink,
Color(red: 1, green: 0.84, blue: 0), // gold
Color(red: 0, green: 0.8, blue: 0.6), // teal
Color(red: 0.93, green: 0.35, blue: 0.35), // coral
Color(red: 0.58, green: 0.44, blue: 0.86), // violet
Color(red: 1, green: 0.6, blue: 0.7), // pink
]
struct Particle: Identifiable {
let id = UUID() let id = UUID()
let color: Color let color: Color
let size: CGFloat let size: CGFloat
let x: CGFloat let shape: Int
// Start position (bottom-right area)
let startX: CGFloat
let startY: CGFloat let startY: CGFloat
let endY: CGFloat // Velocity
let drift: CGFloat let vx: CGFloat
let delay: Double let vy: CGFloat
let duration: Double
let spin: Double let spin: Double
let shape: Int // 0=circle, 1=rect, 2=star let delay: Double
} }
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 { GeometryReader { geo in
// Particles 3 layered bursts ZStack {
ForEach(particles) { p in ForEach(particles) { p in
confettiShape(p) particleView(p)
.modifier(BurstModifier( .modifier(PhysicsModifier(
startX: p.x, startY: p.startY, startX: p.startX, startY: p.startY,
endY: p.endY, drift: p.drift, vx: p.vx, vy: p.vy,
delay: p.delay, duration: p.duration, spin: p.spin spin: p.spin, delay: p.delay,
)) bounds: geo.size
} ))
}
// Checkmark with glow // Success check
VStack(spacing: 8) { if showCheck {
Image(systemName: "checkmark.circle.fill") VStack(spacing: 6) {
.font(.system(size: 56)) Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.emerald) .font(.system(size: 50))
.shadow(color: Color.emerald.opacity(0.4), radius: 20) .foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 10)
Text("Added!") Text("Added!")
.font(.headline.weight(.bold)) .font(.subheadline.weight(.bold))
.foregroundStyle(Color.emerald) .foregroundStyle(.white)
} .shadow(color: .black.opacity(0.3), radius: 4)
.scaleEffect(checkScale) }
.opacity(checkOpacity) .transition(.scale.combined(with: .opacity))
}
.onAppear {
generateParticles()
withAnimation(.spring(response: 0.35, dampingFraction: 0.55)) {
checkScale = 1.0
checkOpacity = 1.0
}
// Fade out checkmark
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation(.easeOut(duration: 0.4)) {
checkOpacity = 0
checkScale = 0.8
} }
} }
.onAppear { generate(size: geo.size) }
UINotificationFeedbackGenerator().notificationOccurred(.success)
} }
} }
@ViewBuilder @ViewBuilder
private func confettiShape(_ p: ConfettiPiece) -> some View { private func particleView(_ p: Particle) -> some View {
switch p.shape { Group {
case 0: switch p.shape % 3 {
Circle().fill(p.color).frame(width: p.size, height: p.size) case 0: Circle().fill(p.color)
case 1: case 1: Rectangle().fill(p.color)
RoundedRectangle(cornerRadius: 2) default: Star(corners: 5, smoothness: 0.45).fill(p.color)
.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)
} }
.frame(width: p.size, height: p.shape % 3 == 1 ? p.size * 0.6 : p.size)
} }
private func generateParticles() { private func generate(size: CGSize) {
var allParticles: [ConfettiPiece] = [] let originX = size.width * 0.9
let originY = size.height * 0.85
// Burst 1: Center explosion (fast, wide spread) // canvas-confetti style: angle=120, spread=70, particleCount=100
for _ in 0..<25 { // angle 120° means shooting upper-left from bottom-right
allParticles.append(ConfettiPiece( let baseAngle: Double = 120
color: Self.colors.randomElement()!, let spread: Double = 70
size: CGFloat.random(in: 6...12),
x: CGFloat.random(in: -180...180), particles = (0..<100).map { _ in
startY: CGFloat.random(in: -50...50), let angle = baseAngle + Double.random(in: -spread/2...spread/2)
endY: CGFloat.random(in: 300...700), let radians = angle * .pi / 180
drift: CGFloat.random(in: -80...80), let speed = CGFloat.random(in: 15...45)
delay: Double.random(in: 0...0.1),
duration: Double.random(in: 0.8...1.2), return Particle(
color: Self.palette.randomElement()!,
size: CGFloat.random(in: 5...11),
shape: Int.random(in: 0...2),
startX: originX + CGFloat.random(in: -20...20),
startY: originY + CGFloat.random(in: -20...20),
vx: cos(radians) * speed,
vy: -sin(radians) * speed, // negative = upward
spin: Double.random(in: -360...360), spin: Double.random(in: -360...360),
shape: Int.random(in: 0...2) delay: Double.random(in: 0...0.15)
)) )
} }
// Burst 2: Slightly delayed, wider (like canvas-confetti layering) withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
for _ in 0..<20 { showCheck = true
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)
))
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
// Burst 3: Slow floaters (small, drifty) withAnimation(.easeOut(duration: 0.3)) { showCheck = false }
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 { // Physics: gravity pulls down, particles drift and fade
struct PhysicsModifier: ViewModifier {
let startX: CGFloat let startX: CGFloat
let startY: CGFloat let startY: CGFloat
let endY: CGFloat let vx: CGFloat
let drift: CGFloat let vy: CGFloat
let delay: Double
let duration: Double
let spin: Double let spin: Double
let delay: Double
let bounds: CGSize
@State private var progress: CGFloat = 0 @State private var time: CGFloat = 0
@State private var opacity: Double = 1 @State private var opacity: Double = 1
func body(content: Content) -> some View { private let gravity: CGFloat = 0.6
private let duration: Double = 1.6
var body: some View {
let x = startX + vx * time
let y = startY + vy * time + 0.5 * gravity * time * time
content content
.offset( .offset(x: x, y: y)
x: startX + (drift * progress),
y: startY + (endY - startY) * progress
)
.opacity(opacity) .opacity(opacity)
.rotationEffect(.degrees(spin * Double(progress))) .rotationEffect(.degrees(spin * Double(time / 30)))
.scaleEffect(1.0 - Double(progress) * 0.3)
.onAppear { .onAppear {
withAnimation(.easeOut(duration: duration).delay(delay)) { withAnimation(.easeOut(duration: duration).delay(delay)) {
progress = 1 time = 30
} }
withAnimation(.easeIn(duration: duration * 0.4).delay(delay + duration * 0.6)) { withAnimation(.easeIn(duration: 0.5).delay(delay + duration * 0.7)) {
opacity = 0 opacity = 0
} }
} }
} }
} }
private extension Color { // Star shape
init(hex: String) { struct Star: Shape {
let scanner = Scanner(string: hex) let corners: Int
var rgbValue: UInt64 = 0 let smoothness: CGFloat
scanner.scanHexInt64(&rgbValue)
self.init( func path(in rect: CGRect) -> Path {
red: Double((rgbValue >> 16) & 0xFF) / 255, let center = CGPoint(x: rect.midX, y: rect.midY)
green: Double((rgbValue >> 8) & 0xFF) / 255, let outerRadius = min(rect.width, rect.height) / 2
blue: Double(rgbValue & 0xFF) / 255 let innerRadius = outerRadius * smoothness
) var path = Path()
let step = .pi / CGFloat(corners)
for i in 0..<corners * 2 {
let angle = CGFloat(i) * step - .pi / 2
let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
let point = CGPoint(
x: center.x + cos(angle) * radius,
y: center.y + sin(angle) * radius
)
if i == 0 { path.move(to: point) }
else { path.addLine(to: point) }
}
path.closeSubpath()
return path
} }
} }
// MARK: - Assistant Sheet // MARK: - Assistant Sheet
struct AssistantSheetView: View { struct AssistantSheetView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedMode = 0 @State private var selectedMode = 0
var onFoodAdded: () -> Void = {} var onFoodAdded: () -> Void = {}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Custom header
VStack(spacing: 12) { VStack(spacing: 12) {
Capsule() Capsule()
.fill(Color.textTertiary.opacity(0.3)) .fill(Color.textTertiary.opacity(0.3))
@@ -322,10 +302,8 @@ struct AssistantSheetView: View {
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index } withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
} label: { } label: {
HStack(spacing: 5) { HStack(spacing: 5) {
Image(systemName: icon) Image(systemName: icon).font(.caption2)
.font(.caption2) Text(title).font(.subheadline.weight(selectedMode == index ? .semibold : .regular))
Text(title)
.font(.subheadline.weight(selectedMode == index ? .semibold : .regular))
} }
.foregroundStyle(selectedMode == index ? Color.textPrimary : Color.textTertiary) .foregroundStyle(selectedMode == index ? Color.textPrimary : Color.textTertiary)
.padding(.horizontal, 16) .padding(.horizontal, 16)