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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<CalorieEntry>) -> 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: {
|
||||
|
||||
Reference in New Issue
Block a user