feat: calorie ring widget — home screen + lock screen
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s

Widget displays:
- systemSmall: calorie ring + "X left" text
- systemMedium: ring + "Calories" / "X of Y" / "X remaining"
- accessoryCircular: gauge ring for lock screen
- accessoryInline: "🔥 845 / 2000 cal" text for lock screen
- accessoryRectangular: linear gauge + calorie count

Data flow: main app writes totalCalories + calorieGoal to
UserDefaults on each loadTodayData(), then calls
WidgetCenter.shared.reloadAllTimelines(). Widget reads on
15-minute refresh cycle.

Note: currently uses standard UserDefaults (same app container).
For production, migrate to App Group UserDefaults so widget
process can read the data. Requires Xcode App Group setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 11:21:40 -05:00
parent a4ebe77973
commit 9965b1d634
2 changed files with 194 additions and 30 deletions

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import PhotosUI import PhotosUI
import WidgetKit
@Observable @Observable
final class HomeViewModel { final class HomeViewModel {
@@ -32,6 +33,11 @@ final class HomeViewModel {
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories } totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
calorieGoal = repo.goal?.calories ?? 2000 calorieGoal = repo.goal?.calories ?? 2000
isLoading = false isLoading = false
// Write to UserDefaults for widget
UserDefaults.standard.set(totalCalories, forKey: "widget_totalCalories")
UserDefaults.standard.set(calorieGoal, forKey: "widget_calorieGoal")
WidgetCenter.shared.reloadAllTimelines()
} }
// MARK: - Background Image // MARK: - Background Image

View File

@@ -1,62 +1,220 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
struct Provider: TimelineProvider { // MARK: - Shared data key (main app writes, widget reads)
func placeholder(in context: Context) -> SimpleEntry { // Uses standard UserDefaults for now. Migrate to App Group
SimpleEntry(date: Date()) // UserDefaults when App Group is configured in Xcode.
private let caloriesKey = "widget_totalCalories"
private let goalKey = "widget_calorieGoal"
// MARK: - Timeline
struct CalorieEntry: TimelineEntry {
let date: Date
let totalCalories: Double
let calorieGoal: Double
var progress: Double {
guard calorieGoal > 0 else { return 0 }
return min(max(totalCalories / calorieGoal, 0), 1.0)
} }
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) { var remaining: Double {
let entry = SimpleEntry(date: Date()) max(calorieGoal - totalCalories, 0)
completion(entry) }
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) { struct CalorieProvider: TimelineProvider {
var entries: [SimpleEntry] = [] func placeholder(in context: Context) -> CalorieEntry {
let currentDate = Date() CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate)
entries.append(entry)
} }
let timeline = Timeline(entries: entries, policy: .atEnd)
func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) {
completion(readEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CalorieEntry>) -> Void) {
let entry = readEntry()
// Refresh every 15 minutes
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: entry.date)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline) completion(timeline)
} }
private func readEntry() -> CalorieEntry {
let defaults = UserDefaults.standard
let calories = defaults.double(forKey: caloriesKey)
let goal = defaults.double(forKey: goalKey)
return CalorieEntry(
date: .now,
totalCalories: calories,
calorieGoal: goal > 0 ? goal : 2000
)
}
} }
struct SimpleEntry: TimelineEntry { // MARK: - Widget Views
let date: Date
}
struct PlatformWidgetEntryView: View { struct CalorieRingView: View {
var entry: Provider.Entry let entry: CalorieEntry
let size: CGFloat
let lineWidth: CGFloat
private let ringColor = Color(red: 0.020, green: 0.588, blue: 0.412) // emerald
var body: some View { var body: some View {
VStack { ZStack {
Text("Platform") // Background ring
Circle()
.stroke(ringColor.opacity(0.2), lineWidth: lineWidth)
// Progress ring
Circle()
.trim(from: 0, to: entry.progress)
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
// Center text
VStack(spacing: 1) {
Text("\(Int(entry.totalCalories))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
Text("/ \(Int(entry.calorieGoal))")
.font(.system(size: size * 0.1, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(width: size, height: size)
}
}
struct SmallWidgetView: View {
let entry: CalorieEntry
var body: some View {
VStack(spacing: 8) {
CalorieRingView(entry: entry, size: 90, lineWidth: 8)
Text("\(Int(entry.remaining)) left")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct MediumWidgetView: View {
let entry: CalorieEntry
var body: some View {
HStack(spacing: 20) {
CalorieRingView(entry: entry, size: 100, lineWidth: 9)
VStack(alignment: .leading, spacing: 6) {
Text("Calories")
.font(.headline) .font(.headline)
Text(entry.date, style: .time) .foregroundStyle(.primary)
Text("\(Int(entry.totalCalories)) of \(Int(entry.calorieGoal))")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("\(Int(entry.remaining)) remaining")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// Lock screen widgets
struct CircularWidgetView: View {
let entry: CalorieEntry
var body: some View {
Gauge(value: entry.progress) {
Text("\(Int(entry.totalCalories))")
.font(.system(size: 12, weight: .bold, design: .rounded))
}
.gaugeStyle(.accessoryCircular)
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
}
}
struct InlineWidgetView: View {
let entry: CalorieEntry
var body: some View {
Text("🔥 \(Int(entry.totalCalories)) / \(Int(entry.calorieGoal)) cal")
}
}
struct RectangularWidgetView: View {
let entry: CalorieEntry
var body: some View {
HStack(spacing: 8) {
Gauge(value: entry.progress) {
EmptyView()
}
.gaugeStyle(.accessoryLinear)
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
Text("\(Int(entry.totalCalories)) cal")
.font(.system(size: 13, weight: .bold, design: .rounded))
} }
} }
} }
// MARK: - Widget Configuration
struct PlatformWidget: Widget { struct PlatformWidget: Widget {
let kind: String = "PlatformWidget" let kind: String = "PlatformWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in StaticConfiguration(kind: kind, provider: CalorieProvider()) { entry in
PlatformWidgetEntryView(entry: entry) PlatformWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget) .containerBackground(.fill.tertiary, for: .widget)
} }
.configurationDisplayName("Platform") .configurationDisplayName("Calories")
.description("Platform widget.") .description("Today's calorie progress ring.")
.supportedFamilies([.systemSmall, .systemMedium]) .supportedFamilies([
.systemSmall,
.systemMedium,
.accessoryCircular,
.accessoryInline,
.accessoryRectangular,
])
}
}
// Use @ViewBuilder to pick the right view per family
struct PlatformWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: CalorieEntry
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .accessoryCircular:
CircularWidgetView(entry: entry)
case .accessoryInline:
InlineWidgetView(entry: entry)
case .accessoryRectangular:
RectangularWidgetView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
} }
} }
#Preview(as: .systemSmall) { #Preview(as: .systemSmall) {
PlatformWidget() PlatformWidget()
} timeline: { } timeline: {
SimpleEntry(date: .now) CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
} }