diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 30f0d3d..55210a5 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -265,7 +265,7 @@ CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -282,7 +282,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -300,7 +300,7 @@ CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -317,7 +317,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/RIT Dining/Components/PushScheduler.swift b/RIT Dining/Components/PushScheduler.swift index 15460bc..5eaccf8 100644 --- a/RIT Dining/Components/PushScheduler.swift +++ b/RIT Dining/Components/PushScheduler.swift @@ -6,5 +6,54 @@ // import Foundation +import UserNotifications +/// Function to schedule a notification for a visting chef showing up on campus using the name, location, and timeframe. Returns the UUID string assigned to the notification. +func scheduleVisitingChefNotif(name: String, location: String, startTime: Date, endTime: Date) async -> String { + // Validate that the user has authorized TigerDine to send you notifications before trying to schedule one. + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard (settings.authorizationStatus == .authorized) else { return "" } + + // Build the notification content from the name, location, and timeframe. + let content = UNMutableNotificationContent() + if name == "P.H. Express" { + content.title = "Good Food is Waiting" + } else { + content.title = "\(name) Is On Campus Today" + } + content.body = "\(name) will be at \(location) from \(dateDisplay.string(from: startTime))-\(dateDisplay.string(from: endTime))" + + // Get the time that we're going to schedule the notification for, which is a specified number of hours before the chef + // shows up. This is configurable from the notification settings. + let offset: Int = UserDefaults.standard.integer(forKey: "notificationOffset") + // The ternary happening on this line is stupid, but the UserDefaults key isn't always initialized because it's being used + // through @AppStorage. This will eventually be refactored into something better, but this system should work for now to + // ensure that we never use an offset of 0. + let notifTime = Calendar.current.date(byAdding: .hour, value: -(offset != 0 ? offset : 2), to: startTime)! + let notifTimeComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: notifTime) + let trigger = UNCalendarNotificationTrigger(dateMatching: notifTimeComponents, repeats: false) + + // Create the request with the content and time. + let uuid_string = UUID().uuidString + let request = UNNotificationRequest(identifier: uuid_string, content: content, trigger: trigger) + + // Hook into the notification center and attempt to schedule the notification. + let notificationCenter = UNUserNotificationCenter.current() + do { + try await notificationCenter.add(request) + print("successfully scheduled notification for chef \(name) to be delivered at \(notifTime)") + return uuid_string + } catch { + // Presumably this shouldn't ever happen? That's what I'm hoping for! + print(error) + return "" + } +} +/// Cancel a list of pending visiting chef notifications using their UUIDs. +func cancelVisitingChefNotifs(uuids: [String]) async { + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: uuids) + print("successfully cancelled pending notifications with UUIDs: \(uuids)") +} diff --git a/RIT Dining/Components/TigerCenterParsers.swift b/RIT Dining/Components/TigerCenterParsers.swift index bf9241e..73ef77e 100644 --- a/RIT Dining/Components/TigerCenterParsers.swift +++ b/RIT Dining/Components/TigerCenterParsers.swift @@ -47,6 +47,10 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining nil } + // Generate a maps URL from the mdoId key. This is required because the mapsUrl served by TigerCenter is not compatible with + // the new RIT map that was deployed in December 2025. + let mapsUrl = "https://maps.rit.edu/details/\(location.mdoId)" + // Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week. if location.events.isEmpty { return DiningLocation( @@ -56,7 +60,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining name: location.name, summary: location.summary, desc: desc, - mapsUrl: location.mapsUrl, + mapsUrl: mapsUrl, date: forDate ?? Date(), diningTimes: nil, open: .closed, @@ -102,7 +106,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining name: location.name, summary: location.summary, desc: desc, - mapsUrl: location.mapsUrl, + mapsUrl: mapsUrl, date: forDate ?? Date(), diningTimes: nil, open: .closed, @@ -179,7 +183,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining print("found visiting chef: \(menu.name)") var name: String = menu.name let splitString = name.split(separator: "(", maxSplits: 1) - name = String(splitString[0]) + name = String(splitString[0]).trimmingCharacters(in: .whitespaces) // Time parsing nonsense starts here. Extracts the time from a string like "Chef (4-7p.m.)", splits it at the "-", // strips the non-numerical characters from each part, parses it as a number and adds 12 hours as needed, then creates // a Date instance for that time on today's date. @@ -199,7 +203,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining bySettingHour: openTimeComponents.hour!, minute: openTimeComponents.minute!, second: openTimeComponents.second!, - of: now)! + of: forDate ?? now)! } else { break } @@ -212,7 +216,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining bySettingHour: closeTimeComponents.hour!, minute: closeTimeComponents.minute!, second: closeTimeComponents.second!, - of: now)! + of: forDate ?? now)! } else { break } @@ -261,7 +265,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining name: location.name, summary: location.summary, desc: desc, - mapsUrl: location.mapsUrl, + mapsUrl: mapsUrl, date: forDate ?? Date(), diningTimes: diningTimes, open: openStatus, diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index 707754c..96d1b46 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -31,6 +31,7 @@ struct ContentView: View { private func getDiningData() async { do { try await model.getHoursByDay() + await model.scheduleAllPushes() isLoading = false } catch { isLoading = true @@ -126,7 +127,10 @@ struct ContentView: View { await getDiningData() } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + NavigationLink(destination: VisitingChefPush()) { + Image(systemName: "bell.badge") + } Menu { Button(action: { Task { @@ -135,16 +139,10 @@ struct ContentView: View { }) { Label("Refresh", systemImage: "arrow.clockwise") } - // This is commented out because this feature is still not done. Sorry! -// NavigationLink(destination: VisitingChefPush()) { -// Image(systemName: "bell.badge") -// .foregroundColor(.accentColor) -// Text("Notifications") -// } + Divider() NavigationLink(destination: AboutView()) { Image(systemName: "info.circle") - .foregroundColor(.accentColor) Text("About") } Button(action: { diff --git a/RIT Dining/Data/DiningModel.swift b/RIT Dining/Data/DiningModel.swift new file mode 100644 index 0000000..70b34e1 --- /dev/null +++ b/RIT Dining/Data/DiningModel.swift @@ -0,0 +1,117 @@ +// +// DiningModel.swift +// RIT Dining +// +// Created by Campbell on 10/1/25. +// + +import SwiftUI + +@Observable +class DiningModel { + var locationsByDay = [[DiningLocation]]() + var daysRepresented = [Date]() + var lastRefreshed: Date? + var visitingChefPushes = VisitingChefPushesModel() + var notifyingChefs = NotifyingChefs() + + // This is the actual method responsible for making requests to the API for the current day and next 6 days to collect all + // of the information that the app needs for the various view. Making it part of the model allows it to be updated from + // any view at any time, and prevents excess API requests (if you never refresh, the app will never need to make more than 7 + // calls per launch). + func getHoursByDay() async throws { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let week: [Date] = (0..<7).compactMap { offset in + calendar.date(byAdding: .day, value: offset, to: today) + } + daysRepresented = week + var newLocationsByDay = [[DiningLocation]]() + for day in week { + let dateString = day.formatted(.iso8601 + .year().month().day() + .dateSeparator(.dash)) + switch await getAllDiningInfo(date: dateString) { + case .success(let locations): + var newDiningLocations = [DiningLocation]() + for i in 0.. push.endTime { + visitingChefPushes.pushes.remove(at: visitingChefPushes.pushes.firstIndex(of: push)!) + } + } + } + + func cancelAllPushes() async { + let uuids = visitingChefPushes.pushes.map(\.uuid) + await cancelVisitingChefNotifs(uuids: uuids) + visitingChefPushes.pushes.removeAll() + } + + func schedulePushesForChef(_ chefName: String) async { + for day in locationsByDay { + for location in day { + if let visitingChefs = location.visitingChefs { + for chef in visitingChefs { + if chef.name == chefName && notifyingChefs.contains(chef.name) { + await visitingChefPushes.scheduleNewPush( + name: chef.name, + location: location.name, + startTime: chef.openTime, + endTime: chef.closeTime + ) + } + } + } + } + } + } +} diff --git a/RIT Dining/Data/PushesModel.swift b/RIT Dining/Data/PushesModel.swift new file mode 100644 index 0000000..6bb5164 --- /dev/null +++ b/RIT Dining/Data/PushesModel.swift @@ -0,0 +1,86 @@ +// +// PushesModel.swift +// RIT Dining +// +// Created by Campbell on 11/20/25. +// + +import SwiftUI + +@Observable +class VisitingChefPushesModel { + var pushes: [ScheduledVistingChefPush] = [] { + didSet { + save() + } + } + private let key = "ScheduledVisitingChefPushes" + + init() { + load() + } + + /// Schedule a new push notification with the notification center and save its information to UserDefaults if it succeeded. + func scheduleNewPush(name: String, location: String, startTime: Date, endTime: Date) async { + guard !pushAlreadyRegisered(name: name, location: location, startTime: startTime, endTime: endTime) else { return } + let uuid_string = await scheduleVisitingChefNotif( + name: name, + location: location, + startTime: startTime, + endTime: endTime + ) + // An empty UUID means that the notification wasn't scheduled for one reason or another. This is ignored for now. + if uuid_string != "" { + pushes.append( + ScheduledVistingChefPush( + uuid: uuid_string, + name: name, + location: location, + startTime: startTime, + endTime: endTime + ) + ) + save() + } + } + + /// Cancel all reigstered push notifications for a specified visiting chef. + func cancelPushesForChef(name: String) { + var uuids: [String] = [] + for push in pushes { + if push.name == name { + uuids.append(push.uuid) + } + } + Task { + await cancelVisitingChefNotifs(uuids: uuids) + } + // Once they're canceled, we can drop them from the list. + pushes.removeAll { $0.name == name } + save() + } + + func pushAlreadyRegisered(name: String, location: String, startTime: Date, endTime: Date) -> Bool { + for push in pushes { + if push.name == name && push.location == location && push.startTime == startTime && push.endTime == endTime { + return true + } + } + return false + } + + private func save() { + let encoder = JSONEncoder() + if let data = try? encoder.encode(pushes) { + UserDefaults.standard.set(data, forKey: key) + } + } + + private func load() { + let decoder = JSONDecoder() + if let data = UserDefaults.standard.data(forKey: key), + let decoded = try? decoder.decode([ScheduledVistingChefPush].self, from: data) { + pushes = decoded + } + } +} diff --git a/RIT Dining/Data/TigerCenterModel.swift b/RIT Dining/Data/TigerCenterModel.swift deleted file mode 100644 index b40243e..0000000 --- a/RIT Dining/Data/TigerCenterModel.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// TigerCenterModel.swift -// RIT Dining -// -// Created by Campbell on 10/1/25. -// - -import SwiftUI - -@Observable -class DiningModel { - var locationsByDay = [[DiningLocation]]() - var daysRepresented = [Date]() - var lastRefreshed: Date? - - // This is the actual method responsible for making requests to the API for the current day and next 6 days to collect all - // of the information that the app needs for the various view. Making it part of the model allows it to be updated from - // any view at any time, and prevents excess API requests (if you never refresh, the app will never need to make more than 7 - // calls per launch). - func getHoursByDay() async throws { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let week: [Date] = (0..<7).compactMap { offset in - calendar.date(byAdding: .day, value: offset, to: today) - } - daysRepresented = week - var newLocationsByDay = [[DiningLocation]]() - for day in week { - let dateString = day.formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) - switch await getAllDiningInfo(date: dateString) { - case .success(let locations): - var newDiningLocations = [DiningLocation]() - for i in 0.. (20 * Double(index)) { @@ -134,6 +135,7 @@ struct DetailView: View { } .foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0)) .font(.title3) + #endif } Spacer() VStack(alignment: .trailing) { @@ -156,14 +158,16 @@ struct DetailView: View { .font(.title3) } } - // Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page. - Button(action: { - openURL(URL(string: "https://ondemand.rit.edu")!) - }) { - Image(systemName: "cart") - .font(.title3) - } - .disabled(location.open == .closed || location.open == .openingSoon) +// THIS FEATURE DISABLED AT RIT'S REQUEST FOR SECURITY REASONS. +// No hard feelings or anything though, I get it. +// // Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page. +// Button(action: { +// openURL(URL(string: "https://ondemand.rit.edu")!) +// }) { +// Image(systemName: "cart") +// .font(.title3) +// } +// .disabled(location.open == .closed || location.open == .openingSoon) // Open this location on the RIT map in embedded Safari. Button(action: { showingSafari = true diff --git a/RIT Dining/Views/Menus/MenuView.swift b/RIT Dining/Views/Menus/MenuView.swift index c048ce5..8acb0e6 100644 --- a/RIT Dining/Views/Menus/MenuView.swift +++ b/RIT Dining/Views/Menus/MenuView.swift @@ -200,6 +200,7 @@ struct MenuView: View { } } label: { Image(systemName: "clock") + Text("Meal Periods") } } ToolbarItemGroup(placement: .bottomBar) { diff --git a/RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift b/RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift index fd761d8..9ad41a1 100644 --- a/RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift +++ b/RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift @@ -9,7 +9,8 @@ import SwiftUI struct VisitingChefPush: View { @AppStorage("visitingChefPushEnabled") var pushEnabled: Bool = false - @Environment(NotifyingChefs.self) var notifyingChefs + @AppStorage("notificationOffset") var notificationOffset: Int = 2 + @Environment(DiningModel.self) var model @State private var pushAllowed: Bool = false private let visitingChefs = [ "California Rollin' Sushi", @@ -32,18 +33,39 @@ struct VisitingChefPush: View { Text("Notifications Enabled") } .disabled(!pushAllowed) + .onChange(of: pushEnabled) { + if pushEnabled { + Task { + await model.scheduleAllPushes() + } + } else { + Task { + await model.cancelAllPushes() + } + } + } + Picker("Send Notifications", selection: $notificationOffset) { + Text("1 Hour Before").tag(1) + Text("2 Hours Before").tag(2) + Text("3 Hours Before").tag(3) + } + .disabled(!pushAllowed || !pushEnabled) } - Section(footer: Text("Get notified when a specific visiting chef is on campus and where they'll be.")) { + Section(footer: Text("Get notified when and where a specific visiting chef will be on campus.")) { ForEach(visitingChefs, id: \.self) { chef in Toggle(isOn: Binding( get: { - notifyingChefs.contains(chef) + model.notifyingChefs.contains(chef) }, set: { isOn in if isOn { - notifyingChefs.add(chef) + model.notifyingChefs.add(chef) + Task { + await model.schedulePushesForChef(chef) + } } else { - notifyingChefs.remove(chef) + model.notifyingChefs.remove(chef) + model.visitingChefPushes.cancelPushesForChef(name: chef) } } )) { @@ -52,8 +74,49 @@ struct VisitingChefPush: View { } } .disabled(!pushAllowed || !pushEnabled) + #if DEBUG + Section(header: Text("DEBUG - Scheduled Pushes")) { + Button(action: { + Task { + await model.scheduleAllPushes() + } + }) { + Text("Schedule All") + } + Button(action: { + let uuids = model.visitingChefPushes.pushes.map(\.uuid) + Task { + await cancelVisitingChefNotifs(uuids: uuids) + model.visitingChefPushes.pushes.removeAll() + } + }) { + Text("Cancel All") + } + .tint(.red) + ForEach(model.visitingChefPushes.pushes, id: \.uuid) { push in + VStack(alignment: .leading) { + Text("\(push.name) at \(push.location)") + Text(push.uuid) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(push.startTime) - \(push.endTime)") + .foregroundStyle(.secondary) + } + .swipeActions { + Button(action: { + Task { + await cancelVisitingChefNotifs(uuids: [push.uuid]) + model.visitingChefPushes.pushes.remove(at: model.visitingChefPushes.pushes.firstIndex(of: push)!) + } + }) { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } + } + #endif } - Spacer() } .onAppear { Task {