feat: canvas-confetti style celebration + Quick Add callback
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:
@@ -85,91 +85,194 @@ struct MainTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Success Confetti Animation
|
||||
// MARK: - Success Confetti Animation (canvas-confetti style)
|
||||
|
||||
struct SuccessConfettiView: View {
|
||||
@State private var particles: [ConfettiParticle] = []
|
||||
@State private var particles: [ConfettiPiece] = []
|
||||
@State private var checkScale: CGFloat = 0
|
||||
@State private var checkOpacity: Double = 0
|
||||
|
||||
struct ConfettiParticle: Identifiable {
|
||||
struct ConfettiPiece: Identifiable {
|
||||
let id = UUID()
|
||||
let emoji: String
|
||||
let color: Color
|
||||
let size: CGFloat
|
||||
let x: CGFloat
|
||||
let startY: CGFloat
|
||||
let endY: CGFloat
|
||||
let drift: CGFloat
|
||||
let delay: 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 {
|
||||
ZStack {
|
||||
// Semi-transparent backdrop
|
||||
Color.black.opacity(0.1)
|
||||
|
||||
// Confetti particles
|
||||
// Particles — 3 layered bursts
|
||||
ForEach(particles) { p in
|
||||
Text(p.emoji)
|
||||
.font(.title)
|
||||
.offset(x: p.x)
|
||||
.modifier(FallingModifier(delay: p.delay, duration: p.duration, rotation: p.rotation))
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
// Checkmark
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(Color.emerald)
|
||||
.scaleEffect(checkScale)
|
||||
.shadow(color: Color.emerald.opacity(0.3), radius: 20)
|
||||
// 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 {
|
||||
// Generate particles
|
||||
let emojis = ["🎉", "✨", "🍎", "🥑", "💚", "⭐️", "🎊"]
|
||||
particles = (0..<15).map { i in
|
||||
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)) {
|
||||
generateParticles()
|
||||
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.55)) {
|
||||
checkScale = 1.0
|
||||
checkOpacity = 1.0
|
||||
}
|
||||
// Haptic
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.success)
|
||||
|
||||
// Fade out checkmark
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
checkOpacity = 0
|
||||
checkScale = 0.8
|
||||
}
|
||||
}
|
||||
|
||||
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 FallingModifier: ViewModifier {
|
||||
struct BurstModifier: ViewModifier {
|
||||
let startX: CGFloat
|
||||
let startY: CGFloat
|
||||
let endY: CGFloat
|
||||
let drift: CGFloat
|
||||
let delay: 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 angle: Double = 0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(y: yOffset)
|
||||
.offset(
|
||||
x: startX + (drift * progress),
|
||||
y: startY + (endY - startY) * progress
|
||||
)
|
||||
.opacity(opacity)
|
||||
.rotationEffect(.degrees(angle))
|
||||
.rotationEffect(.degrees(spin * Double(progress)))
|
||||
.scaleEffect(1.0 - Double(progress) * 0.3)
|
||||
.onAppear {
|
||||
withAnimation(.easeIn(duration: duration).delay(delay)) {
|
||||
yOffset = 600
|
||||
withAnimation(.easeOut(duration: duration).delay(delay)) {
|
||||
progress = 1
|
||||
}
|
||||
withAnimation(.easeIn(duration: duration * 0.4).delay(delay + duration * 0.6)) {
|
||||
opacity = 0
|
||||
angle = rotation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix: Array doesn't have .length in Swift
|
||||
extension Array {
|
||||
var length: Int { count }
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Assistant Sheet
|
||||
@@ -207,7 +310,7 @@ struct AssistantSheetView: View {
|
||||
if selectedMode == 0 {
|
||||
AssistantChatView(onFoodAdded: onFoodAdded)
|
||||
} else {
|
||||
FoodSearchView(isSheet: true)
|
||||
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct AddFoodSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let food: Food
|
||||
var onFoodAdded: (() -> Void)?
|
||||
|
||||
@State private var quantity: Double = 1.0
|
||||
@State private var selectedMeal: MealType = .snack
|
||||
@@ -234,6 +235,7 @@ struct AddFoodSheet: View {
|
||||
)
|
||||
_ = try await FitnessRepository.shared.createEntry(request)
|
||||
dismiss()
|
||||
onFoodAdded?()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
struct FoodSearchView: View {
|
||||
var isSheet: Bool = false
|
||||
var onFoodAdded: (() -> Void)?
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: Food?
|
||||
|
||||
@@ -48,7 +49,7 @@ struct FoodSearchView: View {
|
||||
vm.search()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food)
|
||||
AddFoodSheet(food: food, onFoodAdded: onFoodAdded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user