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 Foundation
import WidgetKit
@Observable @Observable
final class AuthManager { final class AuthManager {
@@ -10,6 +11,12 @@ final class AuthManager {
private let api = APIClient.shared private let api = APIClient.shared
private let loggedInKey = "isLoggedIn" 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() { init() {
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey) isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
} }
@@ -46,9 +53,11 @@ final class AuthManager {
if response.authenticated, let user = response.user { if response.authenticated, let user = response.user {
currentUser = user currentUser = user
isLoggedIn = true isLoggedIn = true
syncCookieToWidget()
} else { } else {
isLoggedIn = false isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey) UserDefaults.standard.set(false, forKey: loggedInKey)
clearWidgetAuth()
} }
} catch { } catch {
isLoggedIn = false isLoggedIn = false
@@ -68,6 +77,8 @@ final class AuthManager {
currentUser = response.user currentUser = response.user
isLoggedIn = true isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey) UserDefaults.standard.set(true, forKey: loggedInKey)
syncCookieToWidget()
WidgetCenter.shared.reloadAllTimelines()
} }
} catch let apiError as APIError { } catch let apiError as APIError {
error = apiError.localizedDescription error = apiError.localizedDescription
@@ -83,5 +94,26 @@ final class AuthManager {
currentUser = nil currentUser = nil
isLoggedIn = false isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey) 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 calorieGoal = repo.goal?.calories ?? 2000
isLoading = false isLoading = false
// Write to UserDefaults for widget // Write to App Group UserDefaults for widget (fallback cache)
UserDefaults.standard.set(totalCalories, forKey: "widget_totalCalories") let shared = AuthManager.sharedDefaults
UserDefaults.standard.set(calorieGoal, forKey: "widget_calorieGoal") shared.set(totalCalories, forKey: "widget_totalCalories")
shared.set(calorieGoal, forKey: "widget_calorieGoal")
shared.set(Date(), forKey: "widget_lastUpdate")
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
} }

View File

@@ -1,14 +1,28 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
// MARK: - Shared data key (main app writes, widget reads) // MARK: - App Group shared storage
// Uses standard UserDefaults for now. Migrate to App Group //
// UserDefaults when App Group is configured in Xcode. // 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 appGroup = "group.com.quadjourney.platform"
private let goalKey = "widget_calorieGoal" private let gatewayURL = "https://dash.quadjourney.com"
// MARK: - Timeline private var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
// MARK: - Timeline Entry
struct CalorieEntry: TimelineEntry { struct CalorieEntry: TimelineEntry {
let date: Date let date: Date
@@ -23,35 +37,108 @@ struct CalorieEntry: TimelineEntry {
var remaining: Double { var remaining: Double {
max(calorieGoal - totalCalories, 0) max(calorieGoal - totalCalories, 0)
} }
static let placeholder = CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
} }
// MARK: - Timeline Provider
struct CalorieProvider: TimelineProvider { struct CalorieProvider: TimelineProvider {
func placeholder(in context: Context) -> CalorieEntry { func placeholder(in context: Context) -> CalorieEntry {
CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000) .placeholder
} }
func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) { 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<CalorieEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<CalorieEntry>) -> Void) {
let entry = readEntry() Task {
// Refresh every 15 minutes let entry: CalorieEntry
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: entry.date)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) // Try fetching fresh data from API
completion(timeline) 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 { // MARK: - API Fetch
let defaults = UserDefaults.standard
let calories = defaults.double(forKey: caloriesKey) private func fetchFromAPI() async -> CalorieEntry? {
let goal = defaults.double(forKey: goalKey) 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( return CalorieEntry(
date: .now, date: .now,
totalCalories: calories, totalCalories: calories,
calorieGoal: goal > 0 ? goal : 2000 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 // MARK: - Widget Views
@@ -61,21 +148,18 @@ struct CalorieRingView: View {
let size: CGFloat let size: CGFloat
let lineWidth: 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 { var body: some View {
ZStack { ZStack {
// Background ring
Circle() Circle()
.stroke(ringColor.opacity(0.2), lineWidth: lineWidth) .stroke(ringColor.opacity(0.2), lineWidth: lineWidth)
// Progress ring
Circle() Circle()
.trim(from: 0, to: entry.progress) .trim(from: 0, to: entry.progress)
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
// Center text
VStack(spacing: 1) { VStack(spacing: 1) {
Text("\(Int(entry.totalCalories))") Text("\(Int(entry.totalCalories))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded)) .font(.system(size: size * 0.22, weight: .bold, design: .rounded))
@@ -129,7 +213,6 @@ struct MediumWidgetView: View {
} }
} }
// Lock screen widgets
struct CircularWidgetView: View { struct CircularWidgetView: View {
let entry: CalorieEntry let entry: CalorieEntry
@@ -147,7 +230,7 @@ struct InlineWidgetView: View {
let entry: CalorieEntry let entry: CalorieEntry
var body: some View { 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 // MARK: - Widget Configuration
struct PlatformWidget: Widget { 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) { #Preview(as: .systemSmall) {
PlatformWidget() PlatformWidget()
} timeline: { } timeline: {