feat: animated macro rings, bars, and staggered meal card entrance
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

MacroRing:
- Animates from 0 to target on appear (spring, 1.0s response)
- Center value fades in + scales up with 0.4s delay
- Updates animate smoothly on data change
- .contentTransition(.numericText()) on calorie count

MacroBar:
- Bar width animates from 0 to target on appear (spring, 0.3s delay)
- Updates animate smoothly on data change
- .contentTransition(.numericText()) on values

TodayView:
- Meal sections stagger in: fade up with 0.08s delay per card
- Re-animates on tab switch (onAppear resets animated flag)
- Re-animates on date change
- Spring physics for natural feel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 12:20:21 -05:00
parent bf2ff59ade
commit d8f0e5d845
3 changed files with 77 additions and 12 deletions

View File

@@ -2,17 +2,14 @@ import SwiftUI
struct TodayView: View { struct TodayView: View {
@State private var vm = TodayViewModel() @State private var vm = TodayViewModel()
@State private var animated = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Date selector
dateSelector dateSelector
// Macro summary
macroSummary macroSummary
// Meal sections
if vm.entries.isEmpty && !vm.isLoading { if vm.entries.isEmpty && !vm.isLoading {
EmptyStateView( EmptyStateView(
icon: "fork.knife", icon: "fork.knife",
@@ -20,7 +17,8 @@ struct TodayView: View {
subtitle: "Tap + to log your first meal" subtitle: "Tap + to log your first meal"
) )
} else { } else {
ForEach(vm.mealGroups, id: \.0) { mealType, entries in ForEach(Array(vm.mealGroups.enumerated()), id: \.1.0) { index, group in
let (mealType, entries) = group
MealSectionView( MealSectionView(
mealType: mealType, mealType: mealType,
entries: entries, entries: entries,
@@ -28,6 +26,9 @@ struct TodayView: View {
Task { await vm.deleteEntry(entry) } Task { await vm.deleteEntry(entry) }
} }
) )
.opacity(animated ? 1 : 0)
.offset(y: animated ? 0 : 20)
.animation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * 0.08), value: animated)
} }
} }
@@ -41,9 +42,27 @@ struct TodayView: View {
.background(Color.canvas) .background(Color.canvas)
.task { .task {
await vm.load() await vm.load()
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
} }
.onChange(of: vm.selectedDate) { .onChange(of: vm.selectedDate) {
Task { await vm.load() } animated = false
Task {
await vm.load()
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
}
}
.onAppear {
// Re-animate when switching back to this tab
animated = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
}
} }
} }

View File

@@ -7,7 +7,9 @@ struct MacroBar: View {
var color: Color = .emerald var color: Color = .emerald
var unit: String = "g" var unit: String = "g"
private var progress: Double { @State private var animatedProgress: Double = 0
private var targetProgress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1.0) return min(max(consumed / goal, 0), 1.0)
} }
@@ -22,6 +24,7 @@ struct MacroBar: View {
Text("\(Int(consumed))/\(Int(goal))\(unit)") Text("\(Int(consumed))/\(Int(goal))\(unit)")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.textPrimary)
.contentTransition(.numericText())
} }
GeometryReader { geo in GeometryReader { geo in
@@ -32,11 +35,26 @@ struct MacroBar: View {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(color) .fill(color)
.frame(width: geo.size.width * progress, height: 8) .frame(width: geo.size.width * animatedProgress, height: 8)
.animation(.easeInOut(duration: 0.5), value: progress)
} }
} }
.frame(height: 8) .frame(height: 8)
} }
.onAppear {
animatedProgress = 0
withAnimation(.spring(response: 0.8, dampingFraction: 0.7).delay(0.3)) {
animatedProgress = targetProgress
}
}
.onChange(of: consumed) { _, _ in
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
.onChange(of: goal) { _, _ in
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
} }
} }

View File

@@ -7,7 +7,9 @@ struct MacroRing: View {
var color: Color = .emerald var color: Color = .emerald
var size: CGFloat = 100 var size: CGFloat = 100
private var progress: Double { @State private var animatedProgress: Double = 0
private var targetProgress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1.0) return min(max(consumed / goal, 0), 1.0)
} }
@@ -18,15 +20,30 @@ struct MacroRing: View {
.stroke(color.opacity(0.15), lineWidth: lineWidth) .stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: animatedProgress)
.stroke( .stroke(
color, color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.6), value: progress)
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.onAppear {
animatedProgress = 0
withAnimation(.spring(response: 1.0, dampingFraction: 0.7).delay(0.2)) {
animatedProgress = targetProgress
}
}
.onChange(of: consumed) { _, _ in
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
.onChange(of: goal) { _, _ in
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
} }
} }
@@ -38,6 +55,8 @@ struct MacroRingWithLabel: View {
var size: CGFloat = 100 var size: CGFloat = 100
var lineWidth: CGFloat = 10 var lineWidth: CGFloat = 10
@State private var showValue = false
var body: some View { var body: some View {
ZStack { ZStack {
MacroRing( MacroRing(
@@ -52,10 +71,19 @@ struct MacroRingWithLabel: View {
Text("\(Int(consumed))") Text("\(Int(consumed))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded)) .font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.textPrimary)
.contentTransition(.numericText())
Text(label) Text(label)
.font(.system(size: size * 0.1, weight: .medium)) .font(.system(size: size * 0.1, weight: .medium))
.foregroundStyle(Color.textSecondary) .foregroundStyle(Color.textSecondary)
} }
.opacity(showValue ? 1 : 0)
.scaleEffect(showValue ? 1 : 0.5)
}
.onAppear {
showValue = false
withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.4)) {
showValue = true
}
} }
} }
} }