feat: widget fetches calories from API independently + shared auth
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 11:29:52 -05:00
parent 2d4cafa16e
commit e21a26db18
3 changed files with 167 additions and 49 deletions

View File

@@ -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")
}
}

View File

@@ -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()
}