feat: calorie ring widget — home screen + lock screen
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user