feat: calorie tap → fitness, confetti on food add, reduce dashboard padding
- Calorie widget taps switches to Fitness tab - After adding food: dismiss sheet, switch to Fitness, confetti animation - Confetti: emoji particles falling + checkmark + haptic feedback - Dashboard top padding reduced from 60pt to 16pt - HomeView accepts selectedTab binding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,11 +24,12 @@ struct ContentView: View {
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
|
@State private var showSuccess = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
HomeView()
|
HomeView(selectedTab: $selectedTab)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Home", systemImage: "house.fill")
|
Label("Home", systemImage: "house.fill")
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,11 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
|
|
||||||
|
// Floating + button
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
showAssistant = true
|
showAssistant = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -54,23 +60,129 @@ struct MainTabView: View {
|
|||||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 20)
|
.padding(.trailing, 20)
|
||||||
|
}
|
||||||
.padding(.bottom, 70)
|
.padding(.bottom, 70)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success overlay
|
||||||
|
if showSuccess {
|
||||||
|
SuccessConfettiView()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showAssistant) {
|
.sheet(isPresented: $showAssistant) {
|
||||||
AssistantSheetView()
|
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
|
||||||
|
|
||||||
|
struct SuccessConfettiView: View {
|
||||||
|
@State private var particles: [ConfettiParticle] = []
|
||||||
|
@State private var checkScale: CGFloat = 0
|
||||||
|
|
||||||
|
struct ConfettiParticle: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let emoji: String
|
||||||
|
let x: CGFloat
|
||||||
|
let delay: Double
|
||||||
|
let duration: Double
|
||||||
|
let rotation: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Semi-transparent backdrop
|
||||||
|
Color.black.opacity(0.1)
|
||||||
|
|
||||||
|
// Confetti particles
|
||||||
|
ForEach(particles) { p in
|
||||||
|
Text(p.emoji)
|
||||||
|
.font(.title)
|
||||||
|
.offset(x: p.x)
|
||||||
|
.modifier(FallingModifier(delay: p.delay, duration: p.duration, rotation: p.rotation))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkmark
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(Color.emerald)
|
||||||
|
.scaleEffect(checkScale)
|
||||||
|
.shadow(color: Color.emerald.opacity(0.3), radius: 20)
|
||||||
|
}
|
||||||
|
.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)) {
|
||||||
|
checkScale = 1.0
|
||||||
|
}
|
||||||
|
// Haptic
|
||||||
|
let generator = UINotificationFeedbackGenerator()
|
||||||
|
generator.notificationOccurred(.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FallingModifier: ViewModifier {
|
||||||
|
let delay: Double
|
||||||
|
let duration: Double
|
||||||
|
let rotation: Double
|
||||||
|
|
||||||
|
@State private var yOffset: CGFloat = -400
|
||||||
|
@State private var opacity: Double = 1
|
||||||
|
@State private var angle: Double = 0
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.offset(y: yOffset)
|
||||||
|
.opacity(opacity)
|
||||||
|
.rotationEffect(.degrees(angle))
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeIn(duration: duration).delay(delay)) {
|
||||||
|
yOffset = 600
|
||||||
|
opacity = 0
|
||||||
|
angle = rotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix: Array doesn't have .length in Swift
|
||||||
|
extension Array {
|
||||||
|
var length: Int { count }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Assistant Sheet
|
||||||
|
|
||||||
struct AssistantSheetView: View {
|
struct AssistantSheetView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var selectedMode = 0
|
@State private var selectedMode = 0
|
||||||
|
var onFoodAdded: () -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Custom header — warm background
|
// Custom header
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
// Drag handle
|
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.textTertiary.opacity(0.3))
|
.fill(Color.textTertiary.opacity(0.3))
|
||||||
.frame(width: 36, height: 5)
|
.frame(width: 36, height: 5)
|
||||||
@@ -80,7 +192,6 @@ struct AssistantSheetView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
|
||||||
// Tab picker — warm styled
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
tabButton("AI Chat", icon: "sparkles", index: 0)
|
tabButton("AI Chat", icon: "sparkles", index: 0)
|
||||||
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
||||||
@@ -93,9 +204,8 @@ struct AssistantSheetView: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
|
|
||||||
// Content
|
|
||||||
if selectedMode == 0 {
|
if selectedMode == 0 {
|
||||||
AssistantChatView()
|
AssistantChatView(onFoodAdded: onFoodAdded)
|
||||||
} else {
|
} else {
|
||||||
FoodSearchView(isSheet: true)
|
FoodSearchView(isSheet: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PhotosUI
|
|||||||
|
|
||||||
struct AssistantChatView: View {
|
struct AssistantChatView: View {
|
||||||
@State private var vm = AssistantViewModel()
|
@State private var vm = AssistantViewModel()
|
||||||
|
var onFoodAdded: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -113,6 +114,13 @@ struct AssistantChatView: View {
|
|||||||
.onChange(of: vm.selectedPhoto) {
|
.onChange(of: vm.selectedPhoto) {
|
||||||
Task { await vm.handlePhotoSelection() }
|
Task { await vm.handlePhotoSelection() }
|
||||||
}
|
}
|
||||||
|
.onChange(of: vm.applied) {
|
||||||
|
if vm.applied {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
onFoodAdded?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chat Bubble
|
// MARK: - Chat Bubble
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import PhotosUI
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(AuthManager.self) private var auth
|
@Environment(AuthManager.self) private var auth
|
||||||
@State private var vm = HomeViewModel()
|
@State private var vm = HomeViewModel()
|
||||||
@State private var showAssistant = false
|
|
||||||
@State private var ringAnimated = false
|
@State private var ringAnimated = false
|
||||||
|
@Binding var selectedTab: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -54,12 +54,15 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 60)
|
.padding(.top, 16)
|
||||||
|
|
||||||
// Widget grid — half width
|
// Widget grid — half width, tap to go to fitness
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
Button { selectedTab = 1 } label: {
|
||||||
calorieWidget
|
calorieWidget
|
||||||
// Future widget placeholder — invisible spacer
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|||||||
Reference in New Issue
Block a user