import WidgetKit import SwiftUI // MARK: - App Group shared storage // // Auth flow: // 1. Main app logs in → gets session cookie from gateway // 2. Main app stores cookie value in App Group UserDefaults // 3. Widget reads cookie from App Group UserDefaults // 4. Widget makes API calls with that cookie // // Data stored in App Group: // - "widget_sessionCookie": String (the session=xxx cookie value) // - "widget_totalCalories": Double (fallback cache) // - "widget_calorieGoal": Double (fallback cache) // - "widget_lastUpdate": Date (when cache was last written) private let appGroup = "group.com.quadjourney.platform" private let gatewayURL = "https://dash.quadjourney.com" private var sharedDefaults: UserDefaults { UserDefaults(suiteName: appGroup) ?? .standard } // MARK: - Timeline Entry 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) } var remaining: Double { max(calorieGoal - totalCalories, 0) } static let placeholder = CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000) } // MARK: - Timeline Provider struct CalorieProvider: TimelineProvider { func placeholder(in context: Context) -> CalorieEntry { .placeholder } func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) { if context.isPreview { completion(.placeholder) return } // Return cached data for snapshot completion(readCachedEntry()) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { let entry: CalorieEntry // Try fetching fresh data from API if let fresh = await fetchFromAPI() { entry = fresh // Cache for fallback sharedDefaults.set(fresh.totalCalories, forKey: "widget_totalCalories") sharedDefaults.set(fresh.calorieGoal, forKey: "widget_calorieGoal") sharedDefaults.set(Date(), forKey: "widget_lastUpdate") } else { // Network failed — use cached data entry = readCachedEntry() } // Refresh every 15 minutes (WidgetKit may throttle to ~every hour) let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)! let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) completion(timeline) } } // MARK: - API Fetch private func fetchFromAPI() async -> CalorieEntry? { guard let cookie = sharedDefaults.string(forKey: "widget_sessionCookie"), !cookie.isEmpty else { return nil // No auth — user hasn't logged in yet } let today = formatDate(.now) async let totalsData = apiGet("/api/fitness/entries/totals?date=\(today)", cookie: cookie) async let goalData = apiGet("/api/fitness/goals/for-date?date=\(today)", cookie: cookie) guard let totals = await totalsData, let goal = await goalData else { return nil } let calories = totals["total_calories"] as? Double ?? (totals["total_calories"] as? Int).map(Double.init) ?? 0 let goalCalories = goal["calories"] as? Double ?? (goal["calories"] as? Int).map(Double.init) ?? 2000 return CalorieEntry(date: .now, totalCalories: calories, calorieGoal: goalCalories) } private func apiGet(_ path: String, cookie: String) async -> [String: Any]? { guard let url = URL(string: gatewayURL + path) else { return nil } var request = URLRequest(url: url) request.setValue("session=\(cookie)", forHTTPHeaderField: "Cookie") request.timeoutInterval = 10 do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { return nil } return try JSONSerialization.jsonObject(with: data) as? [String: Any] } catch { return nil } } private func readCachedEntry() -> CalorieEntry { let calories = sharedDefaults.double(forKey: "widget_totalCalories") let goal = sharedDefaults.double(forKey: "widget_calorieGoal") return CalorieEntry( date: .now, totalCalories: calories, calorieGoal: goal > 0 ? goal : 2000 ) } private func formatDate(_ date: Date) -> String { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f.string(from: date) } } // MARK: - Widget Views 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) var body: some View { ZStack { Circle() .stroke(ringColor.opacity(0.2), lineWidth: lineWidth) Circle() .trim(from: 0, to: entry.progress) .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) 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) } } 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: - Entry View (family-aware) 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) } } } // MARK: - Widget Configuration struct PlatformWidget: Widget { let kind: String = "PlatformWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CalorieProvider()) { entry in PlatformWidgetEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Calories") .description("Today's calorie progress ring.") .supportedFamilies([ .systemSmall, .systemMedium, .accessoryCircular, .accessoryInline, .accessoryRectangular, ]) } } #Preview(as: .systemSmall) { PlatformWidget() } timeline: { CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000) }