ios widget showing what is available at chucks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: pull code out into lib

+167 -397
+8
wasup-chucks.xcodeproj/project.pbxproj
··· 67 67 path = widget; 68 68 sourceTree = "<group>"; 69 69 }; 70 + 0BSHARED12F2D500000AF0585 /* Shared */ = { 71 + isa = PBXFileSystemSynchronizedRootGroup; 72 + path = Shared; 73 + sourceTree = "<group>"; 74 + }; 70 75 /* End PBXFileSystemSynchronizedRootGroup section */ 71 76 72 77 /* Begin PBXFrameworksBuildPhase section */ ··· 94 99 children = ( 95 100 0BBF201F2F2D443D00AF0585 /* wasup-chucks */, 96 101 0BBF203B2F2D46AF00AF0585 /* widget */, 102 + 0BSHARED12F2D500000AF0585 /* Shared */, 97 103 0BBF20362F2D46AF00AF0585 /* Frameworks */, 98 104 0BBF201E2F2D443D00AF0585 /* Products */, 99 105 ); ··· 136 142 ); 137 143 fileSystemSynchronizedGroups = ( 138 144 0BBF201F2F2D443D00AF0585 /* wasup-chucks */, 145 + 0BSHARED12F2D500000AF0585 /* Shared */, 139 146 ); 140 147 name = "wasup-chucks"; 141 148 packageProductDependencies = ( ··· 158 165 ); 159 166 fileSystemSynchronizedGroups = ( 160 167 0BBF203B2F2D46AF00AF0585 /* widget */, 168 + 0BSHARED12F2D500000AF0585 /* Shared */, 161 169 ); 162 170 name = widgetExtension; 163 171 packageProductDependencies = (
+130 -88
wasup-chucks/ChucksModels.swift Shared/ChucksShared.swift
··· 1 1 // 2 - // ChucksModels.swift 2 + // ChucksShared.swift 3 3 // wasup-chucks 4 4 // 5 - // Created by Kieran Klukas on 1/30/26. 5 + // Shared models and services for main app and widget. 6 6 // 7 7 8 8 import Foundation 9 9 10 10 // MARK: - API Models 11 11 12 - struct Allergen: Codable, Hashable { 13 - let url: String 14 - let alt: String 12 + public struct Allergen: Codable, Hashable, Sendable { 13 + public let url: String 14 + public let alt: String 15 + 16 + public init(url: String, alt: String) { 17 + self.url = url 18 + self.alt = alt 19 + } 15 20 } 16 21 17 - struct MenuItem: Codable, Hashable, Identifiable { 18 - let name: String 19 - let allergens: [Allergen] 20 - 21 - var id: String { name } 22 + public struct MenuItem: Codable, Hashable, Identifiable, Sendable { 23 + public let name: String 24 + public let allergens: [Allergen] 25 + 26 + public nonisolated var id: String { name } 27 + 28 + public init(name: String, allergens: [Allergen]) { 29 + self.name = name 30 + self.allergens = allergens 31 + } 22 32 } 23 33 24 - struct VenueMenu: Codable, Hashable, Identifiable { 25 - let venue: String 26 - let meal: String? 27 - let slot: String 28 - let items: [MenuItem] 29 - 30 - var id: String { "\(venue)-\(slot)" } 34 + public struct VenueMenu: Codable, Hashable, Identifiable, Sendable { 35 + public let venue: String 36 + public let meal: String? 37 + public let slot: String 38 + public let items: [MenuItem] 39 + 40 + public nonisolated var id: String { "\(venue)-\(slot)" } 41 + 42 + public init(venue: String, meal: String?, slot: String, items: [MenuItem]) { 43 + self.venue = venue 44 + self.meal = meal 45 + self.slot = slot 46 + self.items = items 47 + } 31 48 } 32 49 33 - typealias MenuResponse = [String: [VenueMenu]] 50 + public typealias MenuResponse = [String: [VenueMenu]] 34 51 35 52 // MARK: - Meal Phase 36 53 37 - enum MealPhase: String, CaseIterable, Sendable { 54 + public enum MealPhase: String, CaseIterable, Sendable { 38 55 case breakfast = "Breakfast" 39 56 case lunch = "Lunch" 40 57 case dinner = "Dinner" 41 58 case closed = "Closed" 42 - 43 - var icon: String { 59 + 60 + public nonisolated var icon: String { 44 61 switch self { 45 62 case .breakfast: return "cup.and.saucer.fill" 46 63 case .lunch: return "takeoutbag.and.cup.and.straw.fill" ··· 48 65 case .closed: return "moon.zzz.fill" 49 66 } 50 67 } 51 - 52 - var shortName: String { 68 + 69 + public nonisolated var shortName: String { 53 70 switch self { 54 71 case .breakfast: return "Breakfast" 55 72 case .lunch: return "Lunch" ··· 57 74 case .closed: return "Closed" 58 75 } 59 76 } 60 - 61 - var apiSlot: String { 77 + 78 + public nonisolated var apiSlot: String { 62 79 switch self { 63 80 case .breakfast: return "breakfast" 64 81 case .lunch: return "lunch" ··· 70 87 71 88 // MARK: - Meal Schedule 72 89 73 - struct MealSchedule: Identifiable { 74 - var id: String { phase.rawValue } 75 - let phase: MealPhase 76 - let startHour: Int 77 - let startMinute: Int 78 - let endHour: Int 79 - let endMinute: Int 80 - 81 - var startMinutes: Int { startHour * 60 + startMinute } 82 - var endMinutes: Int { endHour * 60 + endMinute } 83 - 90 + public struct MealSchedule: Identifiable, Sendable { 91 + public nonisolated var id: String { phase.rawValue } 92 + public let phase: MealPhase 93 + public let startHour: Int 94 + public let startMinute: Int 95 + public let endHour: Int 96 + public let endMinute: Int 97 + 98 + public nonisolated var startMinutes: Int { startHour * 60 + startMinute } 99 + public nonisolated var endMinutes: Int { endHour * 60 + endMinute } 100 + 101 + public init(phase: MealPhase, startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) { 102 + self.phase = phase 103 + self.startHour = startHour 104 + self.startMinute = startMinute 105 + self.endHour = endHour 106 + self.endMinute = endMinute 107 + } 108 + 84 109 // Mon-Fri: Hot Breakfast 7-8:15, Continental 8:15-9:30, Lunch 10:30-2:30, Dinner 4:30-7:30 85 110 // Treating Hot + Continental as one "Breakfast" period for simplicity 86 - static let weekdaySchedule: [MealSchedule] = [ 111 + public static let weekdaySchedule: [MealSchedule] = [ 87 112 MealSchedule(phase: .breakfast, startHour: 7, startMinute: 0, endHour: 9, endMinute: 30), 88 113 MealSchedule(phase: .lunch, startHour: 10, startMinute: 30, endHour: 14, endMinute: 30), 89 114 MealSchedule(phase: .dinner, startHour: 16, startMinute: 30, endHour: 19, endMinute: 30) 90 115 ] 91 - 116 + 92 117 // Saturday: Continental 8-9, Lunch 11-1, Dinner 4:30-6:30 93 - static let saturdaySchedule: [MealSchedule] = [ 118 + public static let saturdaySchedule: [MealSchedule] = [ 94 119 MealSchedule(phase: .breakfast, startHour: 8, startMinute: 0, endHour: 9, endMinute: 0), 95 120 MealSchedule(phase: .lunch, startHour: 11, startMinute: 0, endHour: 13, endMinute: 0), 96 121 MealSchedule(phase: .dinner, startHour: 16, startMinute: 30, endHour: 18, endMinute: 30) 97 122 ] 98 - 123 + 99 124 // Sunday: Hot Breakfast 8-9, Lunch 11:30-2, Dinner 5-7:30 100 - static let sundaySchedule: [MealSchedule] = [ 125 + public static let sundaySchedule: [MealSchedule] = [ 101 126 MealSchedule(phase: .breakfast, startHour: 8, startMinute: 0, endHour: 9, endMinute: 0), 102 127 MealSchedule(phase: .lunch, startHour: 11, startMinute: 30, endHour: 14, endMinute: 0), 103 128 MealSchedule(phase: .dinner, startHour: 17, startMinute: 0, endHour: 19, endMinute: 30) 104 129 ] 105 - 106 - static func schedule(for weekday: Int) -> [MealSchedule] { 130 + 131 + public nonisolated static func schedule(for weekday: Int) -> [MealSchedule] { 107 132 switch weekday { 108 133 case 1: return sundaySchedule 109 134 case 7: return saturdaySchedule ··· 114 139 115 140 // MARK: - Cedarville Timezone 116 141 117 - struct CedarvilleTime { 118 - static var calendar: Calendar { 142 + public struct CedarvilleTime: Sendable { 143 + public nonisolated static var calendar: Calendar { 119 144 var calendar = Calendar(identifier: .gregorian) 120 - calendar.timeZone = TimeZone(identifier: "America/New_York")! 145 + calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .current 121 146 return calendar 122 147 } 123 148 } 124 149 125 150 // MARK: - Chuck's Status 126 151 127 - struct ChucksStatus { 128 - let currentPhase: MealPhase 129 - let timeRemaining: TimeInterval? 130 - let nextPhase: MealPhase? 131 - let nextPhaseStart: Date? 132 - let isOpen: Bool 133 - let currentMealEnd: Date? 134 - 135 - static func calculate(for date: Date = Date()) -> ChucksStatus { 152 + public struct ChucksStatus: Sendable { 153 + public let currentPhase: MealPhase 154 + public let timeRemaining: TimeInterval? 155 + public let nextPhase: MealPhase? 156 + public let nextPhaseStart: Date? 157 + public let isOpen: Bool 158 + public let currentMealEnd: Date? 159 + 160 + public init(currentPhase: MealPhase, timeRemaining: TimeInterval?, nextPhase: MealPhase?, nextPhaseStart: Date?, isOpen: Bool, currentMealEnd: Date?) { 161 + self.currentPhase = currentPhase 162 + self.timeRemaining = timeRemaining 163 + self.nextPhase = nextPhase 164 + self.nextPhaseStart = nextPhaseStart 165 + self.isOpen = isOpen 166 + self.currentMealEnd = currentMealEnd 167 + } 168 + 169 + public nonisolated static func calculate(for date: Date = Date()) -> ChucksStatus { 136 170 let calendar = CedarvilleTime.calendar 137 171 let weekday = calendar.component(.weekday, from: date) 138 172 let schedule = MealSchedule.schedule(for: weekday) 139 - 173 + 140 174 let hour = calendar.component(.hour, from: date) 141 175 let minute = calendar.component(.minute, from: date) 142 176 let currentMinutes = hour * 60 + minute 143 - 177 + 144 178 for (index, meal) in schedule.enumerated() { 145 179 if currentMinutes >= meal.startMinutes && currentMinutes < meal.endMinutes { 146 180 let endDate = calendar.date(bySettingHour: meal.endHour, minute: meal.endMinute, second: 0, of: date)! 147 181 let remaining = endDate.timeIntervalSince(date) 148 - 182 + 149 183 let nextPhase: MealPhase? 150 184 let nextStart: Date? 151 185 if index + 1 < schedule.count { ··· 156 190 nextPhase = .closed 157 191 nextStart = nil 158 192 } 159 - 193 + 160 194 return ChucksStatus( 161 195 currentPhase: meal.phase, 162 196 timeRemaining: remaining, ··· 166 200 currentMealEnd: endDate 167 201 ) 168 202 } 169 - 203 + 170 204 if currentMinutes < meal.startMinutes { 171 205 let startDate = calendar.date(bySettingHour: meal.startHour, minute: meal.startMinute, second: 0, of: date)! 172 206 let timeUntil = startDate.timeIntervalSince(date) 173 - 207 + 174 208 return ChucksStatus( 175 209 currentPhase: .closed, 176 210 timeRemaining: timeUntil, ··· 181 215 ) 182 216 } 183 217 } 184 - 218 + 185 219 let tomorrow = calendar.date(byAdding: .day, value: 1, to: date)! 186 220 let tomorrowWeekday = calendar.component(.weekday, from: tomorrow) 187 221 let tomorrowSchedule = MealSchedule.schedule(for: tomorrowWeekday) 188 - 222 + 189 223 if let firstMeal = tomorrowSchedule.first { 190 224 var nextStart = calendar.date(bySettingHour: firstMeal.startHour, minute: firstMeal.startMinute, second: 0, of: tomorrow)! 191 225 if nextStart <= date { 192 226 nextStart = calendar.date(byAdding: .day, value: 1, to: nextStart)! 193 227 } 194 228 let timeUntil = nextStart.timeIntervalSince(date) 195 - 229 + 196 230 return ChucksStatus( 197 231 currentPhase: .closed, 198 232 timeRemaining: timeUntil, ··· 202 236 currentMealEnd: nil 203 237 ) 204 238 } 205 - 239 + 206 240 return ChucksStatus( 207 241 currentPhase: .closed, 208 242 timeRemaining: nil, ··· 218 252 219 253 extension TimeInterval { 220 254 /// Compact format for widgets: "2h" or "45m" or "30s" 221 - var compactCountdown: String { 255 + public nonisolated var compactCountdown: String { 222 256 let totalSeconds = Int(self) 223 257 let hours = totalSeconds / 3600 224 258 let minutes = (totalSeconds % 3600) / 60 225 259 let seconds = totalSeconds % 60 226 - 260 + 227 261 if hours > 0 { 228 262 return "\(hours)h" 229 263 } else if minutes > 0 { ··· 232 266 return "\(seconds)s" 233 267 } 234 268 } 235 - 269 + 236 270 /// Expanded format for app: "2h 15m" or "45m" or "30s" 237 - var expandedCountdown: String { 271 + public nonisolated var expandedCountdown: String { 238 272 let totalSeconds = Int(self) 239 273 let hours = totalSeconds / 3600 240 274 let minutes = (totalSeconds % 3600) / 60 241 275 let seconds = totalSeconds % 60 242 - 276 + 243 277 if hours > 0 && minutes > 0 { 244 278 return "\(hours)h \(minutes)m" 245 279 } else if hours > 0 { ··· 254 288 255 289 // MARK: - API Service 256 290 257 - actor ChucksService { 258 - static let shared = ChucksService() 259 - 291 + public actor ChucksService { 292 + public static let shared = ChucksService() 293 + 260 294 private let baseURL = "https://diningdata.cedarville.edu/api/menus" 261 295 private var cachedMenu: MenuResponse? 262 296 private var cacheDate: Date? 263 297 private let cacheExpiration: TimeInterval = 3600 264 - 265 - func fetchMenu(days: Int = 5) async throws -> MenuResponse { 298 + 299 + public init() {} 300 + 301 + public func fetchMenu(days: Int = 5) async throws -> MenuResponse { 266 302 if let cached = cachedMenu, 267 303 let date = cacheDate, 268 304 Date().timeIntervalSince(date) < cacheExpiration { 269 305 return cached 270 306 } 271 - 307 + 272 308 guard let url = URL(string: "\(baseURL)?days=\(days)") else { 273 309 throw ChucksError.invalidURL 274 310 } 275 - 311 + 276 312 var request = URLRequest(url: url) 277 313 request.setValue("*/*", forHTTPHeaderField: "Accept") 278 314 request.setValue("https://www.cedarville.edu", forHTTPHeaderField: "Origin") 279 315 request.setValue("https://www.cedarville.edu/offices/the-commons", forHTTPHeaderField: "Referer") 280 - 316 + 281 317 let (data, response) = try await URLSession.shared.data(for: request) 282 - 318 + 283 319 guard let httpResponse = response as? HTTPURLResponse, 284 320 httpResponse.statusCode == 200 else { 285 321 throw ChucksError.networkError 286 322 } 287 - 323 + 288 324 let menu: MenuResponse 289 325 do { 290 326 menu = try JSONDecoder().decode(MenuResponse.self, from: data) ··· 293 329 } 294 330 cachedMenu = menu 295 331 cacheDate = Date() 296 - 332 + 297 333 return menu 298 334 } 299 - 300 - func getSpecials(for date: Date, phase: MealPhase) async throws -> [MenuItem] { 335 + 336 + public func getSpecials(for date: Date, phase: MealPhase) async throws -> [MenuItem] { 301 337 let menu = try await fetchMenu() 302 338 let dateFormatter = DateFormatter() 303 339 dateFormatter.dateFormat = "yyyy-MM-dd" 304 340 dateFormatter.timeZone = TimeZone(identifier: "America/New_York") 305 341 let dateKey = dateFormatter.string(from: date) 306 - 342 + 307 343 guard let dayMenu = menu[dateKey] else { 308 344 return [] 309 345 } 310 - 311 - let slot = await phase.apiSlot 346 + 347 + let slot = phase.apiSlot 312 348 let homeCooking = dayMenu.filter { $0.venue == "Home Cooking" && $0.slot == slot } 313 349 return homeCooking.flatMap { $0.items } 314 350 } 351 + 352 + public func getSpecialsWithVenue(for date: Date, phase: MealPhase) async throws -> (items: [MenuItem], venueName: String) { 353 + let venueName = "Home Cooking" 354 + let items = try await getSpecials(for: date, phase: phase) 355 + return (items, venueName) 356 + } 315 357 } 316 358 317 - enum ChucksError: Error, LocalizedError { 359 + public enum ChucksError: Error, LocalizedError, Sendable { 318 360 case invalidURL 319 361 case networkError 320 362 case decodingError 321 363 322 - var errorDescription: String? { 364 + public nonisolated var errorDescription: String? { 323 365 switch self { 324 366 case .invalidURL: 325 367 return "Invalid URL"
+1 -1
wasup-chucks/ContentView.swift
··· 207 207 @Binding var selectedMeal: MealSchedule? 208 208 209 209 var schedule: [MealSchedule] { 210 - MealSchedule.schedule(for: Calendar.current.component(.weekday, from: Date())) 210 + MealSchedule.schedule(for: CedarvilleTime.calendar.component(.weekday, from: Date())) 211 211 } 212 212 213 213 var body: some View {
+28 -308
widget/widget.swift
··· 8 8 import WidgetKit 9 9 import SwiftUI 10 10 11 - // MARK: - Models 12 - 13 - struct Allergen: Codable, Hashable { 14 - let url: String 15 - let alt: String 16 - } 17 - 18 - struct MenuItem: Codable, Hashable, Identifiable { 19 - let name: String 20 - let allergens: [Allergen] 21 - 22 - var id: String { name } 23 - } 24 - 25 - struct VenueMenu: Codable, Hashable { 26 - let venue: String 27 - let meal: String? 28 - let slot: String 29 - let items: [MenuItem] 30 - } 31 - 32 - typealias MenuResponse = [String: [VenueMenu]] 33 - 34 - enum MealPhase: String, CaseIterable { 35 - case breakfast = "Breakfast" 36 - case lunch = "Lunch" 37 - case dinner = "Dinner" 38 - case closed = "Closed" 39 - 40 - var icon: String { 41 - switch self { 42 - case .breakfast: return "cup.and.saucer.fill" 43 - case .lunch: return "takeoutbag.and.cup.and.straw.fill" 44 - case .dinner: return "fork.knife.circle.fill" 45 - case .closed: return "moon.zzz.fill" 46 - } 47 - } 48 - 49 - var shortName: String { 50 - switch self { 51 - case .breakfast: return "Breakfast" 52 - case .lunch: return "Lunch" 53 - case .dinner: return "Dinner" 54 - case .closed: return "Closed" 55 - } 56 - } 57 - 58 - var apiSlot: String { 59 - switch self { 60 - case .breakfast: return "breakfast" 61 - case .lunch: return "lunch" 62 - case .dinner: return "dinner" 63 - case .closed: return "" 64 - } 65 - } 66 - } 67 - 68 - struct MealSchedule { 69 - let phase: MealPhase 70 - let startHour: Int 71 - let startMinute: Int 72 - let endHour: Int 73 - let endMinute: Int 74 - 75 - var startMinutes: Int { startHour * 60 + startMinute } 76 - var endMinutes: Int { endHour * 60 + endMinute } 77 - 78 - // Mon-Fri: Hot Breakfast 7-8:15, Continental 8:15-9:30, Lunch 10:30-2:30, Dinner 4:30-7:30 79 - // Treating Hot + Continental as one "Breakfast" period for simplicity 80 - static let weekdaySchedule: [MealSchedule] = [ 81 - MealSchedule(phase: .breakfast, startHour: 7, startMinute: 0, endHour: 9, endMinute: 30), 82 - MealSchedule(phase: .lunch, startHour: 10, startMinute: 30, endHour: 14, endMinute: 30), 83 - MealSchedule(phase: .dinner, startHour: 16, startMinute: 30, endHour: 19, endMinute: 30) 84 - ] 85 - 86 - // Saturday: Continental 8-9, Lunch 11-1, Dinner 4:30-6:30 87 - static let saturdaySchedule: [MealSchedule] = [ 88 - MealSchedule(phase: .breakfast, startHour: 8, startMinute: 0, endHour: 9, endMinute: 0), 89 - MealSchedule(phase: .lunch, startHour: 11, startMinute: 0, endHour: 13, endMinute: 0), 90 - MealSchedule(phase: .dinner, startHour: 16, startMinute: 30, endHour: 18, endMinute: 30) 91 - ] 92 - 93 - // Sunday: Hot Breakfast 8-9, Lunch 11:30-2, Dinner 5-7:30 94 - static let sundaySchedule: [MealSchedule] = [ 95 - MealSchedule(phase: .breakfast, startHour: 8, startMinute: 0, endHour: 9, endMinute: 0), 96 - MealSchedule(phase: .lunch, startHour: 11, startMinute: 30, endHour: 14, endMinute: 0), 97 - MealSchedule(phase: .dinner, startHour: 17, startMinute: 0, endHour: 19, endMinute: 30) 98 - ] 99 - 100 - static func schedule(for weekday: Int) -> [MealSchedule] { 101 - switch weekday { 102 - case 1: return sundaySchedule 103 - case 7: return saturdaySchedule 104 - default: return weekdaySchedule 105 - } 106 - } 107 - } 108 - 109 - struct CedarvilleTime { 110 - static var calendar: Calendar { 111 - var calendar = Calendar(identifier: .gregorian) 112 - calendar.timeZone = TimeZone(identifier: "America/New_York")! 113 - return calendar 114 - } 115 - } 116 - 117 - struct ChucksStatus { 118 - let currentPhase: MealPhase 119 - let timeRemaining: TimeInterval? 120 - let nextPhase: MealPhase? 121 - let nextPhaseStart: Date? 122 - let isOpen: Bool 123 - let currentMealEnd: Date? 124 - 125 - static func calculate(for date: Date = Date()) -> ChucksStatus { 126 - let calendar = CedarvilleTime.calendar 127 - let weekday = calendar.component(.weekday, from: date) 128 - let schedule = MealSchedule.schedule(for: weekday) 129 - 130 - let hour = calendar.component(.hour, from: date) 131 - let minute = calendar.component(.minute, from: date) 132 - let currentMinutes = hour * 60 + minute 133 - 134 - // Check if currently in a meal period 135 - for (index, meal) in schedule.enumerated() { 136 - if currentMinutes >= meal.startMinutes && currentMinutes < meal.endMinutes { 137 - let endDate = calendar.date(bySettingHour: meal.endHour, minute: meal.endMinute, second: 0, of: date)! 138 - let remaining = endDate.timeIntervalSince(date) 139 - 140 - let nextPhase: MealPhase? 141 - let nextStart: Date? 142 - if index + 1 < schedule.count { 143 - let next = schedule[index + 1] 144 - nextPhase = next.phase 145 - nextStart = calendar.date(bySettingHour: next.startHour, minute: next.startMinute, second: 0, of: date) 146 - } else { 147 - nextPhase = .closed 148 - nextStart = nil 149 - } 150 - 151 - return ChucksStatus( 152 - currentPhase: meal.phase, 153 - timeRemaining: remaining, 154 - nextPhase: nextPhase, 155 - nextPhaseStart: nextStart, 156 - isOpen: true, 157 - currentMealEnd: endDate 158 - ) 159 - } 160 - 161 - // Check if before this meal period 162 - if currentMinutes < meal.startMinutes { 163 - let startDate = calendar.date(bySettingHour: meal.startHour, minute: meal.startMinute, second: 0, of: date)! 164 - let timeUntil = startDate.timeIntervalSince(date) 165 - 166 - return ChucksStatus( 167 - currentPhase: .closed, 168 - timeRemaining: timeUntil, 169 - nextPhase: meal.phase, 170 - nextPhaseStart: startDate, 171 - isOpen: false, 172 - currentMealEnd: nil 173 - ) 174 - } 175 - } 176 - 177 - // After all meals today, find tomorrow's first meal 178 - let tomorrow = calendar.date(byAdding: .day, value: 1, to: date)! 179 - let tomorrowWeekday = calendar.component(.weekday, from: tomorrow) 180 - let tomorrowSchedule = MealSchedule.schedule(for: tomorrowWeekday) 181 - 182 - if let firstMeal = tomorrowSchedule.first { 183 - var nextStart = calendar.date(bySettingHour: firstMeal.startHour, minute: firstMeal.startMinute, second: 0, of: tomorrow)! 184 - if nextStart <= date { 185 - nextStart = calendar.date(byAdding: .day, value: 1, to: nextStart)! 186 - } 187 - let timeUntil = nextStart.timeIntervalSince(date) 188 - 189 - return ChucksStatus( 190 - currentPhase: .closed, 191 - timeRemaining: timeUntil, 192 - nextPhase: firstMeal.phase, 193 - nextPhaseStart: nextStart, 194 - isOpen: false, 195 - currentMealEnd: nil 196 - ) 197 - } 198 - 199 - return ChucksStatus( 200 - currentPhase: .closed, 201 - timeRemaining: nil, 202 - nextPhase: nil, 203 - nextPhaseStart: nil, 204 - isOpen: false, 205 - currentMealEnd: nil 206 - ) 207 - } 208 - } 209 - 210 - extension TimeInterval { 211 - /// Compact format for widgets: "2h" or "45m" or "30s" 212 - var compactCountdown: String { 213 - let totalSeconds = Int(self) 214 - let hours = totalSeconds / 3600 215 - let minutes = (totalSeconds % 3600) / 60 216 - let seconds = totalSeconds % 60 217 - 218 - if hours > 0 { 219 - return "\(hours)h" 220 - } else if minutes > 0 { 221 - return "\(minutes)m" 222 - } else { 223 - return "\(seconds)s" 224 - } 225 - } 226 - } 227 - 228 - // MARK: - API Service 229 - 230 - actor ChucksService { 231 - static let shared = ChucksService() 232 - 233 - private let baseURL = "https://diningdata.cedarville.edu/api/menus" 234 - private var cachedMenu: MenuResponse? 235 - private var cacheDate: Date? 236 - private let cacheExpiration: TimeInterval = 3600 237 - 238 - func fetchMenu(days: Int = 5) async throws -> MenuResponse { 239 - if let cached = cachedMenu, 240 - let date = cacheDate, 241 - Date().timeIntervalSince(date) < cacheExpiration { 242 - return cached 243 - } 244 - 245 - guard let url = URL(string: "\(baseURL)?days=\(days)") else { 246 - throw ChucksError.invalidURL 247 - } 248 - 249 - var request = URLRequest(url: url) 250 - request.setValue("*/*", forHTTPHeaderField: "Accept") 251 - request.setValue("https://www.cedarville.edu", forHTTPHeaderField: "Origin") 252 - request.setValue("https://www.cedarville.edu/offices/the-commons", forHTTPHeaderField: "Referer") 253 - 254 - let (data, response) = try await URLSession.shared.data(for: request) 255 - 256 - guard let httpResponse = response as? HTTPURLResponse, 257 - httpResponse.statusCode == 200 else { 258 - throw ChucksError.networkError 259 - } 260 - 261 - let menu = try JSONDecoder().decode(MenuResponse.self, from: data) 262 - cachedMenu = menu 263 - cacheDate = Date() 264 - 265 - return menu 266 - } 267 - 268 - func getSpecials(for date: Date, phase: MealPhase) async throws -> (items: [MenuItem], venueName: String) { 269 - let venueName = "Home Cooking" 270 - let menu = try await fetchMenu() 271 - let dateFormatter = DateFormatter() 272 - dateFormatter.dateFormat = "yyyy-MM-dd" 273 - dateFormatter.timeZone = TimeZone(identifier: "America/New_York") 274 - let dateKey = dateFormatter.string(from: date) 275 - 276 - guard let dayMenu = menu[dateKey] else { 277 - return ([], venueName) 278 - } 279 - 280 - let venue = dayMenu.filter { $0.venue == venueName && $0.slot == phase.apiSlot } 281 - return (venue.flatMap { $0.items }, venueName) 282 - } 283 - } 284 - 285 - enum ChucksError: Error { 286 - case invalidURL 287 - case networkError 288 - case decodingError 289 - } 290 - 291 11 // MARK: - Widget Entry & Provider 292 12 293 13 struct ChucksEntry: TimelineEntry { ··· 306 26 venueName: "Home Cooking" 307 27 ) 308 28 } 309 - 29 + 310 30 func getSnapshot(in context: Context, completion: @escaping (ChucksEntry) -> Void) { 311 31 let entry = ChucksEntry( 312 32 date: Date(), ··· 316 36 ) 317 37 completion(entry) 318 38 } 319 - 39 + 320 40 func getTimeline(in context: Context, completion: @escaping (Timeline<ChucksEntry>) -> Void) { 321 41 Task { 322 42 let status = ChucksStatus.calculate() 323 43 var specials: [MenuItem] = [] 324 44 var venueName = "Home Cooking" 325 - 45 + 326 46 let phase = status.isOpen ? status.currentPhase : (status.nextPhase ?? .lunch) 327 47 let menuDate = status.isOpen ? Date() : (status.nextPhaseStart ?? Date()) 328 48 if phase != .closed { 329 49 do { 330 - let result = try await ChucksService.shared.getSpecials(for: menuDate, phase: phase) 50 + let result = try await ChucksService.shared.getSpecialsWithVenue(for: menuDate, phase: phase) 331 51 specials = result.items 332 52 venueName = result.venueName 333 53 } catch { 334 54 print("Failed to fetch specials: \(error)") 335 55 } 336 56 } 337 - 57 + 338 58 let entry = ChucksEntry( 339 59 date: Date(), 340 60 status: status, 341 61 specials: specials, 342 62 venueName: venueName 343 63 ) 344 - 64 + 345 65 let nextUpdate: Date 346 66 if let remaining = status.timeRemaining { 347 67 nextUpdate = min( ··· 351 71 } else { 352 72 nextUpdate = Date().addingTimeInterval(15 * 60) 353 73 } 354 - 74 + 355 75 let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) 356 76 completion(timeline) 357 77 } ··· 361 81 // MARK: - Small Widget View 362 82 struct SmallWidgetView: View { 363 83 let entry: ChucksEntry 364 - 84 + 365 85 private var statusColor: Color { 366 86 entry.status.isOpen ? .green : .orange 367 87 } 368 - 88 + 369 89 var body: some View { 370 90 ZStack { 371 91 VStack { ··· 382 102 .accessibilityLabel(entry.status.isOpen ? "Chuck's is open for \(entry.status.currentPhase.shortName)" : "Chuck's is closed") 383 103 Spacer() 384 104 } 385 - 105 + 386 106 VStack(spacing: 4) { 387 107 if let remaining = entry.status.timeRemaining { 388 108 Text(remaining.compactCountdown) ··· 390 110 .monospacedDigit() 391 111 .minimumScaleFactor(0.5) 392 112 } 393 - 113 + 394 114 if entry.status.isOpen { 395 115 Text("until \(entry.status.currentPhase.shortName) ends") 396 116 .font(.caption) ··· 413 133 // MARK: - Medium Widget View 414 134 struct MediumWidgetView: View { 415 135 let entry: ChucksEntry 416 - 136 + 417 137 private var statusColor: Color { 418 138 entry.status.isOpen ? .green : .orange 419 139 } 420 - 140 + 421 141 var body: some View { 422 142 HStack(spacing: 16) { 423 143 VStack(spacing: 4) { ··· 431 151 } 432 152 .accessibilityElement(children: .combine) 433 153 .accessibilityLabel(entry.status.isOpen ? "Chuck's is open" : "Chuck's is closed") 434 - 154 + 435 155 if let remaining = entry.status.timeRemaining { 436 156 Text(remaining.compactCountdown) 437 157 .font(.system(size: 44, weight: .bold, design: .rounded)) 438 158 .monospacedDigit() 439 159 .minimumScaleFactor(0.5) 440 160 } 441 - 161 + 442 162 if entry.status.isOpen { 443 163 Text("until \(entry.status.currentPhase.shortName) ends") 444 164 .font(.caption2) ··· 451 171 } 452 172 } 453 173 .frame(maxWidth: .infinity) 454 - 174 + 455 175 Divider() 456 - 176 + 457 177 VStack(alignment: .leading, spacing: 4) { 458 178 Text(entry.venueName) 459 179 .font(.caption.weight(.semibold)) 460 180 .foregroundStyle(.secondary) 461 - 181 + 462 182 if entry.specials.isEmpty { 463 183 Spacer() 464 184 Text("No specials available") ··· 484 204 // MARK: - Large Widget View 485 205 struct LargeWidgetView: View { 486 206 let entry: ChucksEntry 487 - 207 + 488 208 private var statusColor: Color { 489 209 entry.status.isOpen ? .green : .orange 490 210 } 491 - 211 + 492 212 var body: some View { 493 213 VStack(spacing: 16) { 494 214 HStack(alignment: .center) { ··· 497 217 .font(.title2) 498 218 .foregroundStyle(statusColor) 499 219 .accessibilityHidden(true) 500 - 220 + 501 221 VStack(alignment: .leading, spacing: 2) { 502 222 Text(entry.status.isOpen ? "Open" : "Closed") 503 223 .font(.caption.weight(.semibold)) 504 224 .foregroundStyle(statusColor) 505 - 225 + 506 226 Text(entry.status.isOpen ? entry.status.currentPhase.shortName : (entry.status.nextPhase?.shortName ?? "")) 507 227 .font(.headline.weight(.bold)) 508 228 } 509 229 } 510 230 .accessibilityElement(children: .combine) 511 231 .accessibilityLabel(entry.status.isOpen ? "Chuck's is open for \(entry.status.currentPhase.shortName)" : "Chuck's is closed, next meal is \(entry.status.nextPhase?.shortName ?? "tomorrow")") 512 - 232 + 513 233 Spacer() 514 - 234 + 515 235 if let remaining = entry.status.timeRemaining { 516 236 VStack(alignment: .trailing, spacing: 2) { 517 237 Text(remaining.compactCountdown) 518 238 .font(.system(size: 48, weight: .bold, design: .rounded)) 519 239 .monospacedDigit() 520 - 240 + 521 241 Text(entry.status.isOpen ? "until \(entry.status.currentPhase.shortName) ends" : "until open") 522 242 .font(.caption) 523 243 .foregroundStyle(.secondary) 524 244 } 525 245 } 526 246 } 527 - 247 + 528 248 Divider() 529 - 249 + 530 250 VStack(alignment: .leading, spacing: 8) { 531 251 Text(entry.venueName) 532 252 .font(.subheadline.weight(.semibold)) 533 253 .foregroundStyle(.secondary) 534 - 254 + 535 255 if entry.specials.isEmpty { 536 256 Spacer() 537 257 HStack {