1. Session expires: widget gets 401 → clears stale cookie from App Group → stops retrying with bad auth → shows cached data until user opens app and re-authenticates 2. Account switch: login() now calls clearWidgetAuth() BEFORE syncCookieToWidget() — clears previous user's cached calories before writing new user's cookie. No brief display of wrong data. 3. Logout: already correct — clearWidgetAuth removes cookie + cached data, widget shows 0/2000 4. Minimum data: only session cookie + 2 cached numbers + timestamp in App Group. No passwords, no user IDs, no PII. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
9.6 KiB
Swift
312 lines
9.6 KiB
Swift
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<CalorieEntry>) -> 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 else { return nil }
|
|
|
|
// Session expired — clear stale cookie so we don't retry
|
|
if http.statusCode == 401 {
|
|
sharedDefaults.removeObject(forKey: "widget_sessionCookie")
|
|
return nil
|
|
}
|
|
|
|
guard (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)
|
|
}
|