From e21a26db184971ae1b22fec68baf4a59c4d87166 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 11:29:52 -0500 Subject: [PATCH] feat: widget fetches calories from API independently + shared auth Widget: - Fetches /api/fitness/entries/totals and /api/fitness/goals/for-date directly from gateway using shared session cookie - Falls back to cached data in App Group UserDefaults if network fails - Refreshes every 15 minutes via WidgetKit timeline - Each phone shows the logged-in user's own data Auth sharing: - AuthManager.syncCookieToWidget() copies the session cookie to App Group UserDefaults on login and auth check - Widget reads cookie and makes authenticated API calls - Logout clears widget auth + cached data Data in App Group (group.com.quadjourney.platform): - widget_sessionCookie: auth token for API calls - widget_totalCalories: cached fallback - widget_calorieGoal: cached fallback - widget_lastUpdate: cache timestamp HomeViewModel also writes cache on each loadTodayData() as fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Platform/Platform/Core/AuthManager.swift | 32 ++++ .../Features/Home/HomeViewModel.swift | 8 +- .../PlatformWidget/PlatformWidget.swift | 176 +++++++++++++----- 3 files changed, 167 insertions(+), 49 deletions(-) diff --git a/ios/Platform/Platform/Core/AuthManager.swift b/ios/Platform/Platform/Core/AuthManager.swift index 801f1e9..ee220b9 100644 --- a/ios/Platform/Platform/Core/AuthManager.swift +++ b/ios/Platform/Platform/Core/AuthManager.swift @@ -1,4 +1,5 @@ import Foundation +import WidgetKit @Observable final class AuthManager { @@ -10,6 +11,12 @@ final class AuthManager { private let api = APIClient.shared private let loggedInKey = "isLoggedIn" + // App Group for sharing auth with widget + private static let appGroup = "group.com.quadjourney.platform" + static var sharedDefaults: UserDefaults { + UserDefaults(suiteName: appGroup) ?? .standard + } + init() { isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey) } @@ -46,9 +53,11 @@ final class AuthManager { if response.authenticated, let user = response.user { currentUser = user isLoggedIn = true + syncCookieToWidget() } else { isLoggedIn = false UserDefaults.standard.set(false, forKey: loggedInKey) + clearWidgetAuth() } } catch { isLoggedIn = false @@ -68,6 +77,8 @@ final class AuthManager { currentUser = response.user isLoggedIn = true UserDefaults.standard.set(true, forKey: loggedInKey) + syncCookieToWidget() + WidgetCenter.shared.reloadAllTimelines() } } catch let apiError as APIError { error = apiError.localizedDescription @@ -83,5 +94,26 @@ final class AuthManager { currentUser = nil isLoggedIn = false UserDefaults.standard.set(false, forKey: loggedInKey) + clearWidgetAuth() + WidgetCenter.shared.reloadAllTimelines() + } + + // MARK: - Widget Auth Sync + + /// Copy the session cookie to App Group UserDefaults so the widget can authenticate. + private func syncCookieToWidget() { + guard let url = URL(string: "https://dash.quadjourney.com"), + let cookies = HTTPCookieStorage.shared.cookies(for: url) else { return } + + for cookie in cookies where cookie.name == "session" { + Self.sharedDefaults.set(cookie.value, forKey: "widget_sessionCookie") + return + } + } + + private func clearWidgetAuth() { + Self.sharedDefaults.removeObject(forKey: "widget_sessionCookie") + Self.sharedDefaults.removeObject(forKey: "widget_totalCalories") + Self.sharedDefaults.removeObject(forKey: "widget_calorieGoal") } } diff --git a/ios/Platform/Platform/Features/Home/HomeViewModel.swift b/ios/Platform/Platform/Features/Home/HomeViewModel.swift index 252dd7d..080d4ce 100644 --- a/ios/Platform/Platform/Features/Home/HomeViewModel.swift +++ b/ios/Platform/Platform/Features/Home/HomeViewModel.swift @@ -34,9 +34,11 @@ final class HomeViewModel { 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") + // Write to App Group UserDefaults for widget (fallback cache) + let shared = AuthManager.sharedDefaults + shared.set(totalCalories, forKey: "widget_totalCalories") + shared.set(calorieGoal, forKey: "widget_calorieGoal") + shared.set(Date(), forKey: "widget_lastUpdate") WidgetCenter.shared.reloadAllTimelines() } diff --git a/ios/Platform/PlatformWidget/PlatformWidget.swift b/ios/Platform/PlatformWidget/PlatformWidget.swift index b9cd6b1..6303459 100644 --- a/ios/Platform/PlatformWidget/PlatformWidget.swift +++ b/ios/Platform/PlatformWidget/PlatformWidget.swift @@ -1,14 +1,28 @@ import WidgetKit import SwiftUI -// 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. +// 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 caloriesKey = "widget_totalCalories" -private let goalKey = "widget_calorieGoal" +private let appGroup = "group.com.quadjourney.platform" +private let gatewayURL = "https://dash.quadjourney.com" -// MARK: - Timeline +private var sharedDefaults: UserDefaults { + UserDefaults(suiteName: appGroup) ?? .standard +} + +// MARK: - Timeline Entry struct CalorieEntry: TimelineEntry { let date: Date @@ -23,35 +37,108 @@ struct CalorieEntry: TimelineEntry { 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 { - CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000) + .placeholder } func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) { - completion(readEntry()) + if context.isPreview { + completion(.placeholder) + return + } + // Return cached data for snapshot + completion(readCachedEntry()) } 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) + 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) + } } - private func readEntry() -> CalorieEntry { - let defaults = UserDefaults.standard - let calories = defaults.double(forKey: caloriesKey) - let goal = defaults.double(forKey: goalKey) + // 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 @@ -61,21 +148,18 @@ struct CalorieRingView: View { let size: CGFloat let lineWidth: CGFloat - private let ringColor = Color(red: 0.020, green: 0.588, blue: 0.412) // emerald + private let ringColor = Color(red: 0.020, green: 0.588, blue: 0.412) var body: some View { 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)) @@ -129,7 +213,6 @@ struct MediumWidgetView: View { } } -// Lock screen widgets struct CircularWidgetView: View { let entry: CalorieEntry @@ -147,7 +230,7 @@ struct InlineWidgetView: View { let entry: CalorieEntry var body: some View { - Text("🔥 \(Int(entry.totalCalories)) / \(Int(entry.calorieGoal)) cal") + Text("\(Int(entry.totalCalories)) / \(Int(entry.calorieGoal)) cal") } } @@ -168,6 +251,30 @@ struct RectangularWidgetView: View { } } +// 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 { @@ -190,29 +297,6 @@ struct PlatformWidget: Widget { } } -// 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: {