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,62 +1,220 @@
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
// MARK: - Shared data key (main app writes, widget reads)
// Uses standard UserDefaults for now. Migrate to App Group
// 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) {
let entry = SimpleEntry(date: Date())
completion(entry)
var remaining: Double {
max(calorieGoal - totalCalories, 0)
}
}
struct CalorieProvider: TimelineProvider {
func placeholder(in context: Context) -> CalorieEntry {
CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
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)
}
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 {
let date: Date
}
// MARK: - Widget Views
struct PlatformWidgetEntryView: View {
var entry: Provider.Entry
struct CalorieRingView: View {
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 {
VStack {
Text("Platform")
.font(.headline)
Text(entry.date, style: .time)
.font(.caption)
ZStack {
// 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)
.foregroundStyle(.primary)
Text("\(Int(entry.totalCalories)) of \(Int(entry.calorieGoal))")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("\(Int(entry.remaining)) remaining")
.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 {
let kind: String = "PlatformWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
StaticConfiguration(kind: kind, provider: CalorieProvider()) { entry in
PlatformWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Platform")
.description("Platform widget.")
.supportedFamilies([.systemSmall, .systemMedium])
.configurationDisplayName("Calories")
.description("Today's calorie progress ring.")
.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) {
PlatformWidget()
} timeline: {
SimpleEntry(date: .now)
CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
}