From c6f6e6f6b90e1e5a050394f53d833d2e1a6abb9b Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 10:24:55 -0500 Subject: [PATCH] feat: canvas-confetti exact replica + Quick Add callback fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/Platform/Platform/ContentView.swift | 316 +++++++++++------------- 1 file changed, 147 insertions(+), 169 deletions(-) diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index 48ad55f..cd3ffa7 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -24,21 +24,17 @@ struct ContentView: View { struct MainTabView: View { @State private var selectedTab = 0 @State private var showAssistant = false - @State private var showSuccess = false + @State private var showConfetti = false var body: some View { ZStack { TabView(selection: $selectedTab) { HomeView(selectedTab: $selectedTab) - .tabItem { - Label("Home", systemImage: "house.fill") - } + .tabItem { Label("Home", systemImage: "house.fill") } .tag(0) FitnessTabView() - .tabItem { - Label("Fitness", systemImage: "flame.fill") - } + .tabItem { Label("Fitness", systemImage: "flame.fill") } .tag(1) } .tint(Color.accentWarm) @@ -48,9 +44,7 @@ struct MainTabView: View { Spacer() HStack { Spacer() - Button { - showAssistant = true - } label: { + Button { showAssistant = true } label: { Image(systemName: "plus") .font(.title2.weight(.semibold)) .foregroundStyle(.white) @@ -64,227 +58,213 @@ struct MainTabView: View { .padding(.bottom, 70) } - // Success overlay - if showSuccess { - SuccessConfettiView() + // Confetti overlay + if showConfetti { + ConfettiView() .ignoresSafeArea() .allowsHitTesting(false) } } .sheet(isPresented: $showAssistant) { - AssistantSheetView(onFoodAdded: { - showAssistant = false - selectedTab = 1 - // Show confetti - withAnimation { showSuccess = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { - withAnimation { showSuccess = false } - } - }) + AssistantSheetView(onFoodAdded: foodAdded) + } + } + + private func foodAdded() { + showAssistant = 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 { - @State private var particles: [ConfettiPiece] = [] - @State private var checkScale: CGFloat = 0 - @State private var checkOpacity: Double = 0 +struct ConfettiView: View { + @State private var particles: [Particle] = [] + @State private var showCheck = false - 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 color: Color let size: CGFloat - let x: CGFloat + let shape: Int + // Start position (bottom-right area) + let startX: CGFloat let startY: CGFloat - let endY: CGFloat - let drift: CGFloat - let delay: Double - let duration: Double + // Velocity + let vx: CGFloat + let vy: CGFloat 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 { - ZStack { - // Particles — 3 layered bursts - ForEach(particles) { p in - confettiShape(p) - .modifier(BurstModifier( - startX: p.x, startY: p.startY, - endY: p.endY, drift: p.drift, - delay: p.delay, duration: p.duration, spin: p.spin - )) - } + GeometryReader { geo in + ZStack { + ForEach(particles) { p in + particleView(p) + .modifier(PhysicsModifier( + startX: p.startX, startY: p.startY, + vx: p.vx, vy: p.vy, + spin: p.spin, delay: p.delay, + bounds: geo.size + )) + } - // Checkmark with glow - VStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 56)) - .foregroundStyle(Color.emerald) - .shadow(color: Color.emerald.opacity(0.4), radius: 20) - - Text("Added!") - .font(.headline.weight(.bold)) - .foregroundStyle(Color.emerald) - } - .scaleEffect(checkScale) - .opacity(checkOpacity) - } - .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 + // Success check + if showCheck { + VStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 50)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 10) + Text("Added!") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 4) + } + .transition(.scale.combined(with: .opacity)) } } - - UINotificationFeedbackGenerator().notificationOccurred(.success) + .onAppear { generate(size: geo.size) } } } @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 particleView(_ p: Particle) -> some View { + Group { + switch p.shape % 3 { + case 0: Circle().fill(p.color) + case 1: Rectangle().fill(p.color) + default: Star(corners: 5, smoothness: 0.45).fill(p.color) + } } + .frame(width: p.size, height: p.shape % 3 == 1 ? p.size * 0.6 : p.size) } - private func generateParticles() { - var allParticles: [ConfettiPiece] = [] + private func generate(size: CGSize) { + let originX = size.width * 0.9 + let originY = size.height * 0.85 - // 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), + // canvas-confetti style: angle=120, spread=70, particleCount=100 + // angle 120° means shooting upper-left from bottom-right + let baseAngle: Double = 120 + let spread: Double = 70 + + particles = (0..<100).map { _ in + let angle = baseAngle + Double.random(in: -spread/2...spread/2) + let radians = angle * .pi / 180 + let speed = CGFloat.random(in: 15...45) + + 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), - shape: Int.random(in: 0...2) - )) + delay: Double.random(in: 0...0.15) + ) } - // 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) - )) + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + showCheck = true } - - // 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) - )) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation(.easeOut(duration: 0.3)) { showCheck = false } } - - particles = allParticles } } -struct BurstModifier: ViewModifier { +// Physics: gravity pulls down, particles drift and fade +struct PhysicsModifier: ViewModifier { let startX: CGFloat let startY: CGFloat - let endY: CGFloat - let drift: CGFloat - let delay: Double - let duration: Double + let vx: CGFloat + let vy: CGFloat 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 - 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 - .offset( - x: startX + (drift * progress), - y: startY + (endY - startY) * progress - ) + .offset(x: x, y: y) .opacity(opacity) - .rotationEffect(.degrees(spin * Double(progress))) - .scaleEffect(1.0 - Double(progress) * 0.3) + .rotationEffect(.degrees(spin * Double(time / 30))) .onAppear { 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 } } } } -private extension Color { - init(hex: String) { - 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 - ) +// Star shape +struct Star: Shape { + let corners: Int + let smoothness: CGFloat + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let outerRadius = min(rect.width, rect.height) / 2 + let innerRadius = outerRadius * smoothness + var path = Path() + let step = .pi / CGFloat(corners) + + for i in 0.. Void = {} var body: some View { VStack(spacing: 0) { - // Custom header VStack(spacing: 12) { Capsule() .fill(Color.textTertiary.opacity(0.3)) @@ -322,10 +302,8 @@ struct AssistantSheetView: View { withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index } } label: { HStack(spacing: 5) { - Image(systemName: icon) - .font(.caption2) - Text(title) - .font(.subheadline.weight(selectedMode == index ? .semibold : .regular)) + Image(systemName: icon).font(.caption2) + Text(title).font(.subheadline.weight(selectedMode == index ? .semibold : .regular)) } .foregroundStyle(selectedMode == index ? Color.textPrimary : Color.textTertiary) .padding(.horizontal, 16)