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: simplify the ui

+395 -347
+6
ios/Shared/ChucksShared.swift
··· 298 298 299 299 private static let itemsKey = "favoriteItems" 300 300 private static let keywordsKey = "favoriteKeywords" 301 + private static let notificationsKey = "notificationsEnabled" 301 302 302 303 @Published public var favoriteItems: Set<String> { 303 304 didSet { Self.defaults.set(Array(favoriteItems), forKey: Self.itemsKey) } ··· 307 308 didSet { Self.defaults.set(Array(favoriteKeywords), forKey: Self.keywordsKey) } 308 309 } 309 310 311 + @Published public var notificationsEnabled: Bool { 312 + didSet { Self.defaults.set(notificationsEnabled, forKey: Self.notificationsKey) } 313 + } 314 + 310 315 public init() { 311 316 let items = Self.defaults.stringArray(forKey: Self.itemsKey) ?? [] 312 317 let keywords = Self.defaults.stringArray(forKey: Self.keywordsKey) ?? [] 313 318 self.favoriteItems = Set(items) 314 319 self.favoriteKeywords = Set(keywords) 320 + self.notificationsEnabled = Self.defaults.object(forKey: Self.notificationsKey) as? Bool ?? true 315 321 } 316 322 317 323 public func isFavorite(_ item: MenuItem) -> Bool {
+4 -4
ios/wasup-chucks.xcodeproj/project.pbxproj
··· 386 386 ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 387 387 CODE_SIGN_ENTITLEMENTS = "wasup-chucks/wasup-chucks.entitlements"; 388 388 CODE_SIGN_STYLE = Automatic; 389 - CURRENT_PROJECT_VERSION = 6; 389 + CURRENT_PROJECT_VERSION = 7; 390 390 DEVELOPMENT_TEAM = M67B42LX8D; 391 391 ENABLE_PREVIEWS = YES; 392 392 GENERATE_INFOPLIST_FILE = YES; ··· 422 422 ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 423 423 CODE_SIGN_ENTITLEMENTS = "wasup-chucks/wasup-chucks.entitlements"; 424 424 CODE_SIGN_STYLE = Automatic; 425 - CURRENT_PROJECT_VERSION = 6; 425 + CURRENT_PROJECT_VERSION = 7; 426 426 DEVELOPMENT_TEAM = M67B42LX8D; 427 427 ENABLE_PREVIEWS = YES; 428 428 GENERATE_INFOPLIST_FILE = YES; ··· 457 457 ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; 458 458 CODE_SIGN_ENTITLEMENTS = widgetExtension.entitlements; 459 459 CODE_SIGN_STYLE = Automatic; 460 - CURRENT_PROJECT_VERSION = 6; 460 + CURRENT_PROJECT_VERSION = 7; 461 461 DEVELOPMENT_TEAM = M67B42LX8D; 462 462 GENERATE_INFOPLIST_FILE = YES; 463 463 INFOPLIST_FILE = widget/Info.plist; ··· 488 488 ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; 489 489 CODE_SIGN_ENTITLEMENTS = widgetExtension.entitlements; 490 490 CODE_SIGN_STYLE = Automatic; 491 - CURRENT_PROJECT_VERSION = 6; 491 + CURRENT_PROJECT_VERSION = 7; 492 492 DEVELOPMENT_TEAM = M67B42LX8D; 493 493 GENERATE_INFOPLIST_FILE = YES; 494 494 INFOPLIST_FILE = widget/Info.plist;
+381 -343
ios/wasup-chucks/ContentView.swift
··· 14 14 @State private var status = ChucksStatus.calculate() 15 15 @State private var todayMenu: [VenueMenu] = [] 16 16 @State private var allMenus: MenuResponse = [:] 17 - @State private var selectedDateIndex: Int = 0 18 - @State private var selectedFutureMeal: MealPhase = .breakfast 19 17 @State private var isLoading = true 20 18 @State private var loadError: Error? = nil 21 - @State private var selectedMeal: MealSchedule? = nil 22 19 @StateObject private var favoritesStore = FavoritesStore() 23 - @State private var showFavoritesManager = false 24 - @Environment(\.horizontalSizeClass) private var horizontalSizeClass 25 20 26 21 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 27 22 23 + var body: some View { 24 + TabView { 25 + TodayTab( 26 + status: status, 27 + todayMenu: todayMenu, 28 + isLoading: isLoading, 29 + loadError: loadError, 30 + favoritesStore: favoritesStore, 31 + onRefresh: { 32 + await ChucksService.shared.invalidateCache() 33 + await loadMenu() 34 + }, 35 + onRetry: { Task { await loadMenu() } } 36 + ) 37 + .tabItem { 38 + Label("Today", systemImage: "fork.knife") 39 + } 40 + 41 + MoreTab( 42 + allMenus: allMenus, 43 + isLoading: isLoading, 44 + favoritesStore: favoritesStore 45 + ) 46 + .tabItem { 47 + Label("More", systemImage: "ellipsis.circle") 48 + } 49 + } 50 + .tint(.orange) 51 + .onReceive(timer) { _ in 52 + status = ChucksStatus.calculate() 53 + } 54 + .task { 55 + await loadMenu() 56 + } 57 + .onChange(of: favoritesStore.favoriteItems) { _ in 58 + scheduleNotificationsIfNeeded() 59 + } 60 + .onChange(of: favoritesStore.favoriteKeywords) { _ in 61 + scheduleNotificationsIfNeeded() 62 + } 63 + .onChange(of: favoritesStore.notificationsEnabled) { _ in 64 + scheduleNotificationsIfNeeded() 65 + } 66 + } 67 + 68 + private func scheduleNotificationsIfNeeded() { 69 + guard favoritesStore.notificationsEnabled else { 70 + NotificationScheduler.shared.cancelAll() 71 + return 72 + } 73 + if !favoritesStore.favoriteItems.isEmpty || !favoritesStore.favoriteKeywords.isEmpty { 74 + NotificationScheduler.shared.requestPermissionIfNeeded() 75 + } 76 + NotificationScheduler.shared.reschedule( 77 + menus: allMenus, 78 + favoriteItems: favoritesStore.favoriteItems, 79 + favoriteKeywords: favoritesStore.favoriteKeywords 80 + ) 81 + } 82 + 83 + func loadMenu() async { 84 + isLoading = true 85 + loadError = nil 86 + do { 87 + let menu = try await ChucksService.shared.fetchMenu() 88 + allMenus = menu 89 + let dateFormatter = DateFormatter() 90 + dateFormatter.dateFormat = "yyyy-MM-dd" 91 + dateFormatter.timeZone = TimeZone(identifier: "America/New_York") 92 + let dateKey = dateFormatter.string(from: Date()) 93 + todayMenu = menu[dateKey] ?? [] 94 + scheduleNotificationsIfNeeded() 95 + } catch { 96 + loadError = error 97 + print("Failed to load menu: \(error)") 98 + } 99 + isLoading = false 100 + } 101 + } 102 + 103 + // MARK: - Today Tab 104 + 105 + private struct TodayTab: View { 106 + let status: ChucksStatus 107 + let todayMenu: [VenueMenu] 108 + let isLoading: Bool 109 + let loadError: Error? 110 + @ObservedObject var favoritesStore: FavoritesStore 111 + let onRefresh: () async -> Void 112 + let onRetry: () -> Void 113 + 114 + @State private var selectedMeal: MealSchedule? = nil 115 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass 116 + 28 117 var currentSlot: String { 29 118 if status.isOpen { 30 119 return status.currentPhase.apiSlot ··· 38 127 horizontalSizeClass == .regular 39 128 } 40 129 41 - var availableDates: [String] { 42 - allMenus.keys.sorted() 130 + var body: some View { 131 + NavigationStack { 132 + ScrollView { 133 + VStack(spacing: 16) { 134 + if isRegularWidth { 135 + HStack(spacing: 16) { 136 + StatusCard(status: status) 137 + .frame(maxHeight: .infinity, alignment: .top) 138 + ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 139 + .frame(maxHeight: .infinity, alignment: .top) 140 + } 141 + .fixedSize(horizontal: false, vertical: true) 142 + } else { 143 + StatusCard(status: status) 144 + ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 145 + } 146 + 147 + if isLoading { 148 + ProgressView() 149 + .frame(maxWidth: .infinity, minHeight: 200) 150 + } else if let error = loadError { 151 + ErrorCard(error: error, retry: onRetry) 152 + } else { 153 + CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth, favoritesStore: favoritesStore) 154 + } 155 + } 156 + .padding(.horizontal, 16) 157 + .padding(.bottom, 16) 158 + .frame(maxWidth: isRegularWidth ? 900 : .infinity) 159 + .frame(maxWidth: .infinity) 160 + } 161 + .refreshable { 162 + await onRefresh() 163 + } 164 + .navigationTitle("Wasup Chuck's") 165 + .navigationBarTitleDisplayMode(.inline) 166 + .sheet(item: $selectedMeal) { meal in 167 + MealDetailSheet(meal: meal, menu: todayMenu, favoritesStore: favoritesStore) 168 + } 169 + } 43 170 } 171 + } 172 + 173 + // MARK: - More Tab 174 + 175 + private struct UpcomingDay: Identifiable { 176 + let id: String 177 + let label: String 178 + let schedule: [MealSchedule] 179 + let menu: [VenueMenu] 180 + let favoriteCount: Int 181 + } 182 + 183 + private struct MoreTab: View { 184 + let allMenus: MenuResponse 185 + let isLoading: Bool 186 + @ObservedObject var favoritesStore: FavoritesStore 187 + @State private var selectedDay: UpcomingDay? = nil 188 + @State private var newKeyword = "" 44 189 45 - private func dateLabel(for index: Int) -> String { 46 - guard index < availableDates.count else { return "" } 47 - if index == 0 { return "Today" } 48 - if index == 1 { return "Tomorrow" } 49 - let dateKey = availableDates[index] 50 - let formatter = DateFormatter() 51 - formatter.dateFormat = "yyyy-MM-dd" 52 - formatter.timeZone = TimeZone(identifier: "America/New_York") 53 - guard let date = formatter.date(from: dateKey) else { return dateKey } 54 - let displayFormatter = DateFormatter() 55 - displayFormatter.dateFormat = "EEEE, MMM d" 56 - displayFormatter.timeZone = TimeZone(identifier: "America/New_York") 57 - return displayFormatter.string(from: date) 190 + var upcomingDays: [UpcomingDay] { 191 + let calendar = CedarvilleTime.calendar 192 + let dateFormatter = DateFormatter() 193 + dateFormatter.dateFormat = "yyyy-MM-dd" 194 + dateFormatter.timeZone = calendar.timeZone 195 + let todayKey = dateFormatter.string(from: Date()) 196 + 197 + return allMenus.keys 198 + .sorted() 199 + .filter { $0 > todayKey } 200 + .compactMap { dateKey -> UpcomingDay? in 201 + guard let date = dateFormatter.date(from: dateKey) else { return nil } 202 + let weekday = calendar.component(.weekday, from: date) 203 + let schedule = MealSchedule.schedule(for: weekday) 204 + let menu = allMenus[dateKey] ?? [] 205 + 206 + let tomorrow = dateFormatter.string( 207 + from: calendar.date(byAdding: .day, value: 1, to: Date())! 208 + ) 209 + 210 + let label: String 211 + if dateKey == tomorrow { 212 + label = "Tomorrow" 213 + } else { 214 + let displayFormatter = DateFormatter() 215 + displayFormatter.timeZone = calendar.timeZone 216 + displayFormatter.dateFormat = "EEEE, MMM d" 217 + label = displayFormatter.string(from: date) 218 + } 219 + 220 + var favoriteCount = 0 221 + for venue in menu { 222 + for item in venue.items where favoritesStore.isFavorite(item) { 223 + favoriteCount += 1 224 + } 225 + } 226 + 227 + return UpcomingDay( 228 + id: dateKey, 229 + label: label, 230 + schedule: schedule, 231 + menu: menu, 232 + favoriteCount: favoriteCount 233 + ) 234 + } 235 + } 236 + 237 + var sortedKeywords: [String] { 238 + favoritesStore.favoriteKeywords.sorted() 239 + } 240 + 241 + var sortedItems: [String] { 242 + favoritesStore.favoriteItems.sorted() 58 243 } 59 244 60 245 var body: some View { 61 246 NavigationStack { 62 - Group { 63 - if availableDates.count > 1 { 64 - TabView(selection: $selectedDateIndex) { 65 - ForEach(availableDates.indices, id: \.self) { index in 66 - dayPage(for: index) 67 - .tag(index) 247 + List { 248 + // Upcoming Days 249 + if isLoading { 250 + Section("Upcoming Days") { 251 + HStack { 252 + Spacer() 253 + ProgressView() 254 + Spacer() 68 255 } 69 256 } 70 - .tabViewStyle(.page(indexDisplayMode: .never)) 71 - .ignoresSafeArea(.container, edges: .bottom) 72 - .onChange(of: selectedDateIndex) { _ in 73 - selectedFutureMeal = .breakfast 257 + } else if !upcomingDays.isEmpty { 258 + Section("Upcoming Days") { 259 + ForEach(upcomingDays) { day in 260 + Button { 261 + selectedDay = day 262 + } label: { 263 + UpcomingDayRow(day: day) 264 + } 265 + .tint(.primary) 266 + } 74 267 } 75 - } else { 76 - dayPage(for: 0) 77 268 } 78 - } 79 - .navigationTitle("Wasup Chuck's") 80 - .navigationBarTitleDisplayMode(.inline) 81 - .toolbar { 82 - if availableDates.count > 1 { 83 - ToolbarItem(placement: .topBarLeading) { 269 + 270 + // Favorite Keywords 271 + Section { 272 + HStack { 273 + TextField("Add keyword (e.g. pizza, fish)", text: $newKeyword) 274 + .textInputAutocapitalization(.never) 275 + .autocorrectionDisabled() 276 + .submitLabel(.done) 277 + .onSubmit { addKeyword() } 84 278 Button { 85 - withAnimation { selectedDateIndex -= 1 } 86 - selectedFutureMeal = .breakfast 279 + addKeyword() 87 280 } label: { 88 - Image(systemName: "chevron.left") 281 + Image(systemName: "plus.circle.fill") 282 + .foregroundStyle(.orange) 283 + } 284 + .disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 285 + } 286 + 287 + ForEach(sortedKeywords, id: \.self) { keyword in 288 + HStack { 289 + Image(systemName: "tag.fill") 290 + .foregroundStyle(.orange) 291 + .font(.caption) 292 + Text(keyword) 89 293 } 90 - .disabled(selectedDateIndex <= 0) 91 294 } 92 - ToolbarItem(placement: .principal) { 93 - Text(dateLabel(for: selectedDateIndex)) 94 - .font(.headline) 95 - .animation(.easeInOut, value: selectedDateIndex) 295 + .onDelete { offsets in 296 + for index in offsets { 297 + favoritesStore.removeKeyword(sortedKeywords[index]) 298 + } 96 299 } 97 - ToolbarItem(placement: .topBarTrailing) { 98 - HStack(spacing: 12) { 99 - Button { 100 - showFavoritesManager = true 101 - } label: { 300 + } header: { 301 + Text("Favorite Keywords") 302 + } footer: { 303 + Text("Items containing a keyword will be highlighted as favorites.") 304 + } 305 + 306 + // Favorited Items 307 + if !sortedItems.isEmpty { 308 + Section("Favorited Items") { 309 + ForEach(sortedItems, id: \.self) { item in 310 + HStack { 102 311 Image(systemName: "star.fill") 312 + .foregroundStyle(.orange) 313 + .font(.caption) 314 + Text(item) 103 315 } 104 - .tint(.orange) 105 - Button { 106 - withAnimation { selectedDateIndex += 1 } 107 - selectedFutureMeal = .breakfast 108 - } label: { 109 - Image(systemName: "chevron.right") 316 + } 317 + .onDelete { offsets in 318 + for index in offsets { 319 + favoritesStore.toggleItem(sortedItems[index]) 110 320 } 111 - .disabled(selectedDateIndex >= availableDates.count - 1) 112 321 } 113 322 } 114 - } else { 115 - ToolbarItem(placement: .topBarTrailing) { 116 - Button { 117 - showFavoritesManager = true 118 - } label: { 119 - Image(systemName: "star.fill") 120 - } 121 - .tint(.orange) 323 + } 324 + 325 + // Notifications 326 + Section { 327 + Toggle(isOn: $favoritesStore.notificationsEnabled) { 328 + Label("Favorite Notifications", systemImage: "bell.fill") 122 329 } 330 + .tint(.orange) 331 + } footer: { 332 + Text("Get notified 1 hour before a meal that has your favorites.") 123 333 } 124 - } 125 - .onReceive(timer) { _ in 126 - status = ChucksStatus.calculate() 127 - } 128 - .task { 129 - await loadMenu() 130 - } 131 - .sheet(item: $selectedMeal) { meal in 132 - MealDetailSheet(meal: meal, menu: todayMenu, favoritesStore: favoritesStore) 133 - } 134 - .sheet(isPresented: $showFavoritesManager) { 135 - FavoritesManagerSheet(favoritesStore: favoritesStore) 136 - } 137 - .onChange(of: favoritesStore.favoriteItems) { _ in 138 - if !favoritesStore.favoriteItems.isEmpty || !favoritesStore.favoriteKeywords.isEmpty { 139 - NotificationScheduler.shared.requestPermissionIfNeeded() 334 + 335 + // About 336 + Section { 337 + VStack(spacing: 4) { 338 + Text("Made with \u{2665} by Kieran Klukas") 339 + .font(.caption2) 340 + .foregroundStyle(.tertiary) 341 + Link("Privacy Policy", destination: URL(string: "https://dunkirk.sh/wasup-chucks/")!) 342 + .font(.caption2) 343 + .foregroundStyle(.tertiary) 344 + } 345 + .frame(maxWidth: .infinity) 346 + .listRowBackground(Color.clear) 140 347 } 141 - NotificationScheduler.shared.reschedule( 142 - menus: allMenus, 143 - favoriteItems: favoritesStore.favoriteItems, 144 - favoriteKeywords: favoritesStore.favoriteKeywords 145 - ) 146 348 } 147 - .onChange(of: favoritesStore.favoriteKeywords) { _ in 148 - if !favoritesStore.favoriteItems.isEmpty || !favoritesStore.favoriteKeywords.isEmpty { 149 - NotificationScheduler.shared.requestPermissionIfNeeded() 150 - } 151 - NotificationScheduler.shared.reschedule( 152 - menus: allMenus, 153 - favoriteItems: favoritesStore.favoriteItems, 154 - favoriteKeywords: favoritesStore.favoriteKeywords 155 - ) 349 + .navigationTitle("More") 350 + .navigationBarTitleDisplayMode(.inline) 351 + .sheet(item: $selectedDay) { day in 352 + DayMenuSheet(day: day, favoritesStore: favoritesStore) 156 353 } 157 354 } 158 355 } 159 356 160 - @ViewBuilder 161 - func dayPage(for index: Int) -> some View { 162 - ScrollView { 163 - VStack(spacing: 16) { 164 - if index == 0 { 165 - TodayContent( 166 - status: status, 167 - todayMenu: todayMenu, 168 - selectedMeal: $selectedMeal, 169 - currentSlot: currentSlot, 170 - isLoading: isLoading, 171 - loadError: loadError, 172 - isRegularWidth: isRegularWidth, 173 - favoritesStore: favoritesStore, 174 - onRetry: { Task { await loadMenu() } } 175 - ) 176 - } else { 177 - FutureDayContent( 178 - schedule: scheduleForDate(at: index), 179 - selectedFutureMeal: $selectedFutureMeal, 180 - menu: menuForDate(at: index), 181 - isLoading: isLoading, 182 - loadError: loadError, 183 - isRegularWidth: isRegularWidth, 184 - favoritesStore: favoritesStore, 185 - onRetry: { Task { await loadMenu() } } 186 - ) 187 - } 357 + private func addKeyword() { 358 + let trimmed = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines) 359 + guard !trimmed.isEmpty else { return } 360 + favoritesStore.addKeyword(trimmed) 361 + newKeyword = "" 362 + } 363 + } 188 364 189 - // Footer 190 - VStack(spacing: 4) { 191 - Text("Made with \u{2665} by Kieran Klukas") 192 - .font(.caption2) 193 - .foregroundStyle(.tertiary) 194 - Link("Privacy Policy", destination: URL(string: "https://dunkirk.sh/wasup-chucks/")!) 365 + private struct UpcomingDayRow: View { 366 + let day: UpcomingDay 367 + 368 + var body: some View { 369 + HStack { 370 + VStack(alignment: .leading, spacing: 4) { 371 + Text(day.label) 372 + .font(.body.weight(.medium)) 373 + Text(day.schedule.map { "\($0.phase.shortName) \(formatTime($0.startHour, $0.startMinute))" }.joined(separator: " · ")) 374 + .font(.caption) 375 + .foregroundStyle(.secondary) 376 + } 377 + Spacer() 378 + if day.favoriteCount > 0 { 379 + HStack(spacing: 2) { 380 + Image(systemName: "star.fill") 195 381 .font(.caption2) 196 - .foregroundStyle(.tertiary) 382 + Text("\(day.favoriteCount)") 383 + .font(.caption.weight(.medium)) 197 384 } 198 - .padding(.top, 16) 385 + .foregroundStyle(.orange) 199 386 } 200 - .padding(.horizontal, 16) 201 - .padding(.bottom, 16) 202 - .frame(maxWidth: isRegularWidth ? 900 : .infinity) 203 - .frame(maxWidth: .infinity) 204 - } 205 - .refreshable { 206 - selectedDateIndex = 0 207 - await ChucksService.shared.invalidateCache() 208 - await loadMenu() 387 + Image(systemName: "chevron.right") 388 + .font(.caption) 389 + .foregroundStyle(.tertiary) 209 390 } 210 391 } 211 392 212 - func menuForDate(at index: Int) -> [VenueMenu] { 213 - guard index < availableDates.count else { return [] } 214 - return allMenus[availableDates[index]] ?? [] 215 - } 216 - 217 - func scheduleForDate(at index: Int) -> [MealSchedule] { 218 - guard index < availableDates.count else { return [] } 219 - let dateKey = availableDates[index] 220 - let formatter = DateFormatter() 221 - formatter.dateFormat = "yyyy-MM-dd" 222 - formatter.timeZone = TimeZone(identifier: "America/New_York") 223 - guard let date = formatter.date(from: dateKey) else { return [] } 224 - let weekday = CedarvilleTime.calendar.component(.weekday, from: date) 225 - return MealSchedule.schedule(for: weekday) 226 - } 227 - 228 - func loadMenu() async { 229 - isLoading = true 230 - loadError = nil 231 - do { 232 - let menu = try await ChucksService.shared.fetchMenu() 233 - allMenus = menu 234 - let dateFormatter = DateFormatter() 235 - dateFormatter.dateFormat = "yyyy-MM-dd" 236 - dateFormatter.timeZone = TimeZone(identifier: "America/New_York") 237 - let dateKey = dateFormatter.string(from: Date()) 238 - todayMenu = menu[dateKey] ?? [] 239 - NotificationScheduler.shared.reschedule( 240 - menus: allMenus, 241 - favoriteItems: favoritesStore.favoriteItems, 242 - favoriteKeywords: favoritesStore.favoriteKeywords 243 - ) 244 - } catch { 245 - loadError = error 246 - print("Failed to load menu: \(error)") 393 + private func formatTime(_ hour: Int, _ minute: Int) -> String { 394 + let period = hour >= 12 ? "p" : "a" 395 + let displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour) 396 + if minute == 0 { 397 + return "\(displayHour)\(period)" 247 398 } 248 - isLoading = false 399 + return "\(displayHour):\(String(format: "%02d", minute))\(period)" 249 400 } 250 401 } 251 402 252 - // MARK: - Page Content Views 403 + // MARK: - Day Menu Sheet 253 404 254 - private struct TodayContent: View { 255 - let status: ChucksStatus 256 - let todayMenu: [VenueMenu] 257 - @Binding var selectedMeal: MealSchedule? 258 - let currentSlot: String 259 - let isLoading: Bool 260 - let loadError: Error? 261 - let isRegularWidth: Bool 405 + private struct DayMenuSheet: View { 406 + let day: UpcomingDay 262 407 @ObservedObject var favoritesStore: FavoritesStore 263 - let onRetry: () -> Void 408 + @State private var selectedMeal: MealPhase = .breakfast 409 + @Environment(\.dismiss) private var dismiss 410 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass 411 + 412 + private var isRegularWidth: Bool { 413 + horizontalSizeClass == .regular 414 + } 264 415 265 416 var body: some View { 266 - Group { 267 - if isRegularWidth { 268 - HStack(spacing: 16) { 269 - StatusCard(status: status) 270 - .frame(maxHeight: .infinity, alignment: .top) 271 - ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 272 - .frame(maxHeight: .infinity, alignment: .top) 417 + NavigationStack { 418 + ScrollView { 419 + VStack(spacing: 16) { 420 + ScheduleCard( 421 + schedule: day.schedule, 422 + selectedMealPhase: $selectedMeal 423 + ) 424 + 425 + CurrentMealView( 426 + menu: day.menu, 427 + slot: selectedMeal.apiSlot, 428 + isOpen: true, 429 + isRegularWidth: isRegularWidth, 430 + favoritesStore: favoritesStore 431 + ) 273 432 } 274 - .fixedSize(horizontal: false, vertical: true) 275 - } else { 276 - StatusCard(status: status) 277 - ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 433 + .padding(.horizontal, 16) 434 + .padding(.bottom, 16) 435 + .frame(maxWidth: isRegularWidth ? 900 : .infinity) 436 + .frame(maxWidth: .infinity) 278 437 } 279 - 280 - if isLoading { 281 - ProgressView() 282 - .frame(maxWidth: .infinity, minHeight: 200) 283 - } else if let error = loadError { 284 - ErrorCard(error: error, retry: onRetry) 285 - } else { 286 - CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth, favoritesStore: favoritesStore) 438 + .navigationTitle(day.label) 439 + .navigationBarTitleDisplayMode(.inline) 440 + .toolbar { 441 + ToolbarItem(placement: .topBarTrailing) { 442 + Button("Done") { dismiss() } 443 + .fontWeight(.semibold) 444 + } 287 445 } 288 446 } 289 - } 290 - } 291 - 292 - private struct FutureDayContent: View { 293 - let schedule: [MealSchedule] 294 - @Binding var selectedFutureMeal: MealPhase 295 - let menu: [VenueMenu] 296 - let isLoading: Bool 297 - let loadError: Error? 298 - let isRegularWidth: Bool 299 - @ObservedObject var favoritesStore: FavoritesStore 300 - let onRetry: () -> Void 301 - 302 - var body: some View { 303 - Group { 304 - ScheduleCard( 305 - schedule: schedule, 306 - selectedMealPhase: $selectedFutureMeal 307 - ) 308 - 309 - if isLoading { 310 - ProgressView() 311 - .frame(maxWidth: .infinity, minHeight: 200) 312 - } else if let error = loadError { 313 - ErrorCard(error: error, retry: onRetry) 314 - } else { 315 - CurrentMealView(menu: menu, slot: selectedFutureMeal.apiSlot, isOpen: true, isRegularWidth: isRegularWidth, favoritesStore: favoritesStore) 447 + .presentationDetents([.medium, .large]) 448 + .presentationDragIndicator(.visible) 449 + .onAppear { 450 + if let first = day.schedule.first { 451 + selectedMeal = first.phase 316 452 } 317 453 } 318 454 } ··· 322 458 323 459 struct StatusCard: View { 324 460 let status: ChucksStatus 325 - 461 + 326 462 private var statusColor: Color { 327 463 status.isOpen ? .green : .orange 328 464 } 329 - 465 + 330 466 var body: some View { 331 467 VStack(spacing: 8) { 332 468 HStack(spacing: 8) { ··· 334 470 .font(.title2) 335 471 .foregroundStyle(statusColor) 336 472 .accessibilityHidden(true) 337 - 473 + 338 474 VStack(alignment: .leading, spacing: 2) { 339 475 Text(status.isOpen ? "Open" : "Closed") 340 476 .font(.subheadline.weight(.semibold)) ··· 343 479 .font(.caption) 344 480 .foregroundStyle(.secondary) 345 481 } 346 - 482 + 347 483 Spacer() 348 484 } 349 485 .accessibilityElement(children: .combine) 350 486 .accessibilityLabel(status.isOpen ? "Chuck's is currently open for \(status.currentPhase.rawValue)" : "Chuck's is closed") 351 - 487 + 352 488 if let remaining = status.timeRemaining { 353 489 Text(remaining.expandedCountdown) 354 490 .font(.system(size: 64, weight: .bold, design: .rounded)) 355 491 .monospacedDigit() 356 492 .modifier(NumericContentTransition()) 357 - 493 + 358 494 Text(status.isOpen ? "until \(status.currentPhase.shortName) ends" : "until \(status.nextPhase?.shortName ?? "open")") 359 495 .font(.subheadline) 360 496 .foregroundStyle(.secondary) ··· 813 949 } 814 950 } 815 951 816 - // MARK: - Favorites Manager Sheet 817 - 818 - struct FavoritesManagerSheet: View { 819 - @ObservedObject var favoritesStore: FavoritesStore 820 - @Environment(\.dismiss) private var dismiss 821 - @State private var newKeyword = "" 822 - 823 - var sortedKeywords: [String] { 824 - favoritesStore.favoriteKeywords.sorted() 825 - } 826 - 827 - var sortedItems: [String] { 828 - favoritesStore.favoriteItems.sorted() 829 - } 830 - 831 - var body: some View { 832 - NavigationStack { 833 - List { 834 - Section { 835 - HStack { 836 - TextField("Add keyword (e.g. fish, pizza)", text: $newKeyword) 837 - .textInputAutocapitalization(.never) 838 - .autocorrectionDisabled() 839 - .submitLabel(.done) 840 - .onSubmit { addKeyword() } 841 - Button { 842 - addKeyword() 843 - } label: { 844 - Image(systemName: "plus.circle.fill") 845 - .foregroundStyle(.orange) 846 - } 847 - .disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 848 - } 849 - } header: { 850 - Text("Keywords") 851 - } footer: { 852 - Text("Items containing a keyword will be highlighted as favorites.") 853 - } 854 - 855 - if !sortedKeywords.isEmpty { 856 - Section("Current Keywords") { 857 - ForEach(sortedKeywords, id: \.self) { keyword in 858 - HStack { 859 - Image(systemName: "tag.fill") 860 - .foregroundStyle(.orange) 861 - .font(.caption) 862 - Text(keyword) 863 - } 864 - } 865 - .onDelete { offsets in 866 - for index in offsets { 867 - favoritesStore.removeKeyword(sortedKeywords[index]) 868 - } 869 - } 870 - } 871 - } 872 - 873 - if !sortedItems.isEmpty { 874 - Section("Favorited Items") { 875 - ForEach(sortedItems, id: \.self) { item in 876 - HStack { 877 - Image(systemName: "star.fill") 878 - .foregroundStyle(.orange) 879 - .font(.caption) 880 - Text(item) 881 - } 882 - } 883 - .onDelete { offsets in 884 - for index in offsets { 885 - favoritesStore.toggleItem(sortedItems[index]) 886 - } 887 - } 888 - } 889 - } 890 - } 891 - .navigationTitle("Favorites") 892 - .navigationBarTitleDisplayMode(.inline) 893 - .toolbar { 894 - ToolbarItem(placement: .topBarTrailing) { 895 - Button("Done") { 896 - dismiss() 897 - } 898 - .fontWeight(.semibold) 899 - } 900 - } 901 - } 902 - .presentationDetents([.medium, .large]) 903 - .presentationDragIndicator(.visible) 904 - } 905 - 906 - private func addKeyword() { 907 - let trimmed = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines) 908 - guard !trimmed.isEmpty else { return } 909 - favoritesStore.addKeyword(trimmed) 910 - newKeyword = "" 911 - } 912 - } 913 - 914 - // MARK: - Allergen Row 952 + // MARK: - Allergen Views 915 953 916 954 struct AllergenRow: View { 917 955 let allergens: [Allergen] 918 - 956 + 919 957 var body: some View { 920 958 if !allergens.isEmpty { 921 959 HStack(spacing: 4) { ··· 927 965 .accessibilityLabel("Contains: \(allergens.map { allergenName($0.alt) }.joined(separator: ", "))") 928 966 } 929 967 } 930 - 968 + 931 969 private func allergenName(_ alt: String) -> String { 932 970 switch alt { 933 971 case "gluten": return "gluten" ··· 947 985 948 986 struct AllergenBadge: View { 949 987 let allergen: Allergen 950 - 988 + 951 989 var symbol: String { 952 990 switch allergen.alt { 953 991 case "gluten": return "G" ··· 963 1001 default: return "?" 964 1002 } 965 1003 } 966 - 1004 + 967 1005 var color: Color { 968 1006 switch allergen.alt { 969 1007 case "vegetarian", "gluten-free": return .green 970 1008 default: return .orange 971 1009 } 972 1010 } 973 - 1011 + 974 1012 var body: some View { 975 1013 Text(symbol) 976 1014 .font(.system(size: 9, weight: .bold, design: .rounded))
+4
ios/wasup-chucks/NotificationScheduler.swift
··· 12 12 static let shared = NotificationScheduler() 13 13 private init() {} 14 14 15 + func cancelAll() { 16 + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() 17 + } 18 + 15 19 func requestPermissionIfNeeded() { 16 20 let center = UNUserNotificationCenter.current() 17 21 center.getNotificationSettings { settings in