316 lines
10 KiB
Swift
316 lines
10 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 showConfetti = 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)
|
|
}
|
|
|
|
// Confetti overlay
|
|
if showConfetti {
|
|
ConfettiView()
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showAssistant) {
|
|
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: - Confetti (canvas-confetti style: burst from bottom-right)
|
|
|
|
struct ConfettiView: View {
|
|
@State private var particles: [Particle] = []
|
|
@State private var showCheck = false
|
|
|
|
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 shape: Int
|
|
// Start position (bottom-right area)
|
|
let startX: CGFloat
|
|
let startY: CGFloat
|
|
// Velocity
|
|
let vx: CGFloat
|
|
let vy: CGFloat
|
|
let spin: Double
|
|
let delay: Double
|
|
}
|
|
|
|
var body: some View {
|
|
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
|
|
))
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
.onAppear { generate(size: geo.size) }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
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 generate(size: CGSize) {
|
|
let originX = size.width * 0.9
|
|
let originY = size.height * 0.85
|
|
|
|
// 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),
|
|
delay: Double.random(in: 0...0.15)
|
|
)
|
|
}
|
|
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
|
showCheck = true
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
withAnimation(.easeOut(duration: 0.3)) { showCheck = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Physics: gravity pulls down, particles drift and fade
|
|
struct PhysicsModifier: ViewModifier {
|
|
let startX: CGFloat
|
|
let startY: CGFloat
|
|
let vx: CGFloat
|
|
let vy: CGFloat
|
|
let spin: Double
|
|
let delay: Double
|
|
let bounds: CGSize
|
|
|
|
@State private var time: CGFloat = 0
|
|
@State private var opacity: Double = 1
|
|
|
|
private let gravity: CGFloat = 0.6
|
|
private let duration: Double = 1.6
|
|
|
|
func body(content: Content) -> some View {
|
|
let x = startX + vx * time
|
|
let y = startY + vy * time + 0.5 * gravity * time * time
|
|
|
|
content
|
|
.offset(x: x, y: y)
|
|
.opacity(opacity)
|
|
.rotationEffect(Angle.degrees(spin * Double(time / 30)))
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: duration).delay(delay)) {
|
|
time = 30
|
|
}
|
|
withAnimation(.easeIn(duration: 0.5).delay(delay + duration * 0.7)) {
|
|
opacity = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
@State private var selectedMode = 0
|
|
var onFoodAdded: () -> Void = {}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
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())
|
|
}
|
|
}
|
|
}
|