338 lines
11 KiB
Swift
338 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@Environment(AuthManager.self) private var auth
|
|
|
|
var body: some View {
|
|
Group {
|
|
if auth.isCheckingAuth {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.canvas)
|
|
} else if auth.isLoggedIn {
|
|
MainTabView()
|
|
} else {
|
|
LoginView()
|
|
}
|
|
}
|
|
.task {
|
|
await auth.checkAuth()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainTabView: View {
|
|
@State private var selectedTab = 0
|
|
@State private var showAssistant = false
|
|
@State private var showSuccess = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
TabView(selection: $selectedTab) {
|
|
HomeView(selectedTab: $selectedTab)
|
|
.tabItem {
|
|
Label("Home", systemImage: "house.fill")
|
|
}
|
|
.tag(0)
|
|
|
|
FitnessTabView()
|
|
.tabItem {
|
|
Label("Fitness", systemImage: "flame.fill")
|
|
}
|
|
.tag(1)
|
|
}
|
|
.tint(Color.accentWarm)
|
|
|
|
// Floating + button
|
|
VStack {
|
|
Spacer()
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
showAssistant = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(.title2.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 56, height: 56)
|
|
.background(Color.accentWarm)
|
|
.clipShape(Circle())
|
|
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
|
}
|
|
.padding(.trailing, 20)
|
|
}
|
|
.padding(.bottom, 70)
|
|
}
|
|
|
|
// Success overlay
|
|
if showSuccess {
|
|
SuccessConfettiView()
|
|
.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 }
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Success Confetti Animation (canvas-confetti style)
|
|
|
|
struct SuccessConfettiView: View {
|
|
@State private var particles: [ConfettiPiece] = []
|
|
@State private var checkScale: CGFloat = 0
|
|
@State private var checkOpacity: Double = 0
|
|
|
|
struct ConfettiPiece: Identifiable {
|
|
let id = UUID()
|
|
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 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 {
|
|
// 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
|
|
))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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 duration: Double
|
|
let spin: Double
|
|
|
|
@State private var progress: CGFloat = 0
|
|
@State private var opacity: Double = 1
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.offset(
|
|
x: startX + (drift * progress),
|
|
y: startY + (endY - startY) * progress
|
|
)
|
|
.opacity(opacity)
|
|
.rotationEffect(.degrees(spin * Double(progress)))
|
|
.scaleEffect(1.0 - Double(progress) * 0.3)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: duration).delay(delay)) {
|
|
progress = 1
|
|
}
|
|
withAnimation(.easeIn(duration: duration * 0.4).delay(delay + duration * 0.6)) {
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
.frame(width: 36, height: 5)
|
|
.padding(.top, 8)
|
|
|
|
Text("Add Food")
|
|
.font(.headline)
|
|
.foregroundStyle(Color.textPrimary)
|
|
|
|
HStack(spacing: 4) {
|
|
tabButton("AI Chat", icon: "sparkles", index: 0)
|
|
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
|
}
|
|
.padding(4)
|
|
.background(Color.textTertiary.opacity(0.08))
|
|
.clipShape(Capsule())
|
|
.padding(.horizontal, 40)
|
|
}
|
|
.padding(.bottom, 12)
|
|
.background(Color.canvas)
|
|
|
|
if selectedMode == 0 {
|
|
AssistantChatView(onFoodAdded: onFoodAdded)
|
|
} else {
|
|
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
|
|
}
|
|
}
|
|
.background(Color.canvas)
|
|
.presentationDetents([.large])
|
|
}
|
|
|
|
private func tabButton(_ title: String, icon: String, index: Int) -> some View {
|
|
Button {
|
|
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))
|
|
}
|
|
.foregroundStyle(selectedMode == index ? Color.textPrimary : Color.textTertiary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(selectedMode == index ? Color.surfaceCard : Color.clear)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|