diff --git a/ios/Platform/Platform/Features/Home/HomeViewModel.swift b/ios/Platform/Platform/Features/Home/HomeViewModel.swift index 6854927..252dd7d 100644 --- a/ios/Platform/Platform/Features/Home/HomeViewModel.swift +++ b/ios/Platform/Platform/Features/Home/HomeViewModel.swift @@ -1,5 +1,6 @@ import SwiftUI import PhotosUI +import WidgetKit @Observable final class HomeViewModel { @@ -32,6 +33,11 @@ final class HomeViewModel { totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories } calorieGoal = repo.goal?.calories ?? 2000 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 diff --git a/ios/Platform/PlatformWidget/PlatformWidget.swift b/ios/Platform/PlatformWidget/PlatformWidget.swift index 0fa25da..b9cd6b1 100644 --- a/ios/Platform/PlatformWidget/PlatformWidget.swift +++ b/ios/Platform/PlatformWidget/PlatformWidget.swift @@ -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) -> 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) -> 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) }