feat: canvas-confetti exact replica + Quick Add callback fix
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:
@@ -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..<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
|
||||
|
||||
struct AssistantSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedMode = 0
|
||||
var onFoodAdded: () -> 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)
|
||||
|
||||
Reference in New Issue
Block a user