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: add favorites notifications

+184 -4
+62
ios/Shared/ChucksShared.swift
··· 339 339 } 340 340 } 341 341 342 + // MARK: - Favorite Meal Matching 343 + 344 + public struct FavoriteMealMatch: Sendable { 345 + public let dateKey: String 346 + public let meal: MealPhase 347 + public let matchedItems: [String] 348 + 349 + public nonisolated init(dateKey: String, meal: MealPhase, matchedItems: [String]) { 350 + self.dateKey = dateKey 351 + self.meal = meal 352 + self.matchedItems = matchedItems 353 + } 354 + } 355 + 356 + public func findFavoriteMatches( 357 + in menus: MenuResponse, 358 + favoriteItems: Set<String>, 359 + favoriteKeywords: Set<String> 360 + ) -> [FavoriteMealMatch] { 361 + guard !favoriteItems.isEmpty || !favoriteKeywords.isEmpty else { return [] } 362 + 363 + var results: [FavoriteMealMatch] = [] 364 + 365 + for (dateKey, venues) in menus { 366 + // Group venues by meal slot 367 + let slots: [String: [VenueMenu]] = Dictionary(grouping: venues, by: { $0.slot }) 368 + 369 + for (slot, slotVenues) in slots { 370 + guard let meal = mealPhase(for: slot) else { continue } 371 + 372 + var matched: [String] = [] 373 + for venue in slotVenues { 374 + for item in venue.items { 375 + if favoriteItems.contains(item.name) { 376 + matched.append(item.name) 377 + continue 378 + } 379 + let lowered = item.name.lowercased() 380 + if favoriteKeywords.contains(where: { lowered.contains($0.lowercased()) }) { 381 + matched.append(item.name) 382 + } 383 + } 384 + } 385 + 386 + if !matched.isEmpty { 387 + results.append(FavoriteMealMatch(dateKey: dateKey, meal: meal, matchedItems: matched)) 388 + } 389 + } 390 + } 391 + 392 + return results 393 + } 394 + 395 + private func mealPhase(for slot: String) -> MealPhase? { 396 + switch slot { 397 + case "breakfast": return .breakfast 398 + case "lunch": return .lunch 399 + case "dinner": return .dinner 400 + default: return nil 401 + } 402 + } 403 + 342 404 // MARK: - Persistent Cache 343 405 344 406 private enum MenuCacheIO: Sendable {
+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 = 5; 389 + CURRENT_PROJECT_VERSION = 6; 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 = 5; 425 + CURRENT_PROJECT_VERSION = 6; 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 = 5; 460 + CURRENT_PROJECT_VERSION = 6; 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 = 5; 491 + CURRENT_PROJECT_VERSION = 6; 492 492 DEVELOPMENT_TEAM = M67B42LX8D; 493 493 GENERATE_INFOPLIST_FILE = YES; 494 494 INFOPLIST_FILE = widget/Info.plist;
+25
ios/wasup-chucks/ContentView.swift
··· 134 134 .sheet(isPresented: $showFavoritesManager) { 135 135 FavoritesManagerSheet(favoritesStore: favoritesStore) 136 136 } 137 + .onChange(of: favoritesStore.favoriteItems) { _ in 138 + if !favoritesStore.favoriteItems.isEmpty || !favoritesStore.favoriteKeywords.isEmpty { 139 + NotificationScheduler.shared.requestPermissionIfNeeded() 140 + } 141 + NotificationScheduler.shared.reschedule( 142 + menus: allMenus, 143 + favoriteItems: favoritesStore.favoriteItems, 144 + favoriteKeywords: favoritesStore.favoriteKeywords 145 + ) 146 + } 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 + ) 156 + } 137 157 } 138 158 } 139 159 ··· 216 236 dateFormatter.timeZone = TimeZone(identifier: "America/New_York") 217 237 let dateKey = dateFormatter.string(from: Date()) 218 238 todayMenu = menu[dateKey] ?? [] 239 + NotificationScheduler.shared.reschedule( 240 + menus: allMenus, 241 + favoriteItems: favoritesStore.favoriteItems, 242 + favoriteKeywords: favoritesStore.favoriteKeywords 243 + ) 219 244 } catch { 220 245 loadError = error 221 246 print("Failed to load menu: \(error)")
+72
ios/wasup-chucks/NotificationScheduler.swift
··· 1 + // 2 + // NotificationScheduler.swift 3 + // wasup-chucks 4 + // 5 + // Local notification scheduling for favorite menu items. 6 + // 7 + 8 + import Foundation 9 + import UserNotifications 10 + 11 + final class NotificationScheduler { 12 + static let shared = NotificationScheduler() 13 + private init() {} 14 + 15 + func requestPermissionIfNeeded() { 16 + let center = UNUserNotificationCenter.current() 17 + center.getNotificationSettings { settings in 18 + if settings.authorizationStatus == .notDetermined { 19 + center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } 20 + } 21 + } 22 + } 23 + 24 + func reschedule(menus: MenuResponse, favoriteItems: Set<String>, favoriteKeywords: Set<String>) { 25 + let center = UNUserNotificationCenter.current() 26 + center.removeAllPendingNotificationRequests() 27 + 28 + let matches = findFavoriteMatches(in: menus, favoriteItems: favoriteItems, favoriteKeywords: favoriteKeywords) 29 + guard !matches.isEmpty else { return } 30 + 31 + let calendar = CedarvilleTime.calendar 32 + let now = Date() 33 + let dateFormatter = DateFormatter() 34 + dateFormatter.dateFormat = "yyyy-MM-dd" 35 + dateFormatter.timeZone = calendar.timeZone 36 + 37 + for match in matches { 38 + guard let date = dateFormatter.date(from: match.dateKey) else { continue } 39 + 40 + let weekday = calendar.component(.weekday, from: date) 41 + let schedule = MealSchedule.schedule(for: weekday) 42 + guard let mealSchedule = schedule.first(where: { $0.phase == match.meal }) else { continue } 43 + 44 + // Notification time = 1 hour before meal start 45 + guard var notifyDate = calendar.date(bySettingHour: mealSchedule.startHour, minute: mealSchedule.startMinute, second: 0, of: date) else { continue } 46 + notifyDate = notifyDate.addingTimeInterval(-3600) 47 + 48 + guard notifyDate > now else { continue } 49 + 50 + let content = UNMutableNotificationContent() 51 + content.sound = .default 52 + 53 + let mealName = match.meal.rawValue 54 + content.title = "\(mealName) has your favorites!" 55 + 56 + let itemNames = match.matchedItems 57 + if itemNames.count <= 2 { 58 + content.body = "\(itemNames.joined(separator: ", ")) at Chuck's today." 59 + } else { 60 + let shown = itemNames.prefix(2).joined(separator: ", ") 61 + content.body = "\(shown) +\(itemNames.count - 2) more at Chuck's today." 62 + } 63 + 64 + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: notifyDate) 65 + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) 66 + 67 + let requestID = "fav-\(match.dateKey)-\(mealName)" 68 + let request = UNNotificationRequest(identifier: requestID, content: content, trigger: trigger) 69 + center.add(request) 70 + } 71 + } 72 + }
+21
ios/wasup-chucks/wasup_chucksApp.swift
··· 6 6 // 7 7 8 8 import SwiftUI 9 + import UserNotifications 9 10 10 11 @main 11 12 struct wasup_chucksApp: App { 13 + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate 14 + 12 15 var body: some Scene { 13 16 WindowGroup { 14 17 ContentView() 15 18 } 16 19 } 17 20 } 21 + 22 + final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 23 + func application( 24 + _ application: UIApplication, 25 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 26 + ) -> Bool { 27 + UNUserNotificationCenter.current().delegate = self 28 + return true 29 + } 30 + 31 + func userNotificationCenter( 32 + _ center: UNUserNotificationCenter, 33 + willPresent notification: UNNotification, 34 + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void 35 + ) { 36 + completionHandler([.banner, .sound]) 37 + } 38 + }