mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-01-17 12:05:57 -05:00
Added notifications for visiting chefs
You can now get notified when visiting chefs are on campus! A menu is available from the toolbar on the main screen that allows you to enable notifications and configure what visiting chefs you want to be notified for. You can also toggle if you want to be notified 1, 2, or 3 hours before the chef arrives on campus. Other changes in this commit: - Updated maps URL to be compatible with the new RIT map (however they don't open correctly- this is outside of my control) - Removed button linking to OnDemand at the request of RIT ITS
This commit is contained in:
parent
d7096980d7
commit
207fa788e1
@ -265,7 +265,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -282,7 +282,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -300,7 +300,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -317,7 +317,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@ -6,5 +6,54 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
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)")
|
||||||
|
}
|
||||||
|
|||||||
@ -47,6 +47,10 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
nil
|
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.
|
// 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 {
|
if location.events.isEmpty {
|
||||||
return DiningLocation(
|
return DiningLocation(
|
||||||
@ -56,7 +60,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: mapsUrl,
|
||||||
date: forDate ?? Date(),
|
date: forDate ?? Date(),
|
||||||
diningTimes: nil,
|
diningTimes: nil,
|
||||||
open: .closed,
|
open: .closed,
|
||||||
@ -102,7 +106,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: mapsUrl,
|
||||||
date: forDate ?? Date(),
|
date: forDate ?? Date(),
|
||||||
diningTimes: nil,
|
diningTimes: nil,
|
||||||
open: .closed,
|
open: .closed,
|
||||||
@ -179,7 +183,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
print("found visiting chef: \(menu.name)")
|
print("found visiting chef: \(menu.name)")
|
||||||
var name: String = menu.name
|
var name: String = menu.name
|
||||||
let splitString = name.split(separator: "(", maxSplits: 1)
|
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 "-",
|
// 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
|
// 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.
|
// a Date instance for that time on today's date.
|
||||||
@ -199,7 +203,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
bySettingHour: openTimeComponents.hour!,
|
bySettingHour: openTimeComponents.hour!,
|
||||||
minute: openTimeComponents.minute!,
|
minute: openTimeComponents.minute!,
|
||||||
second: openTimeComponents.second!,
|
second: openTimeComponents.second!,
|
||||||
of: now)!
|
of: forDate ?? now)!
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -212,7 +216,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
bySettingHour: closeTimeComponents.hour!,
|
bySettingHour: closeTimeComponents.hour!,
|
||||||
minute: closeTimeComponents.minute!,
|
minute: closeTimeComponents.minute!,
|
||||||
second: closeTimeComponents.second!,
|
second: closeTimeComponents.second!,
|
||||||
of: now)!
|
of: forDate ?? now)!
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -261,7 +265,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: mapsUrl,
|
||||||
date: forDate ?? Date(),
|
date: forDate ?? Date(),
|
||||||
diningTimes: diningTimes,
|
diningTimes: diningTimes,
|
||||||
open: openStatus,
|
open: openStatus,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ struct ContentView: View {
|
|||||||
private func getDiningData() async {
|
private func getDiningData() async {
|
||||||
do {
|
do {
|
||||||
try await model.getHoursByDay()
|
try await model.getHoursByDay()
|
||||||
|
await model.scheduleAllPushes()
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@ -126,7 +127,10 @@ struct ContentView: View {
|
|||||||
await getDiningData()
|
await getDiningData()
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
|
NavigationLink(destination: VisitingChefPush()) {
|
||||||
|
Image(systemName: "bell.badge")
|
||||||
|
}
|
||||||
Menu {
|
Menu {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
Task {
|
Task {
|
||||||
@ -135,16 +139,10 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
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()
|
Divider()
|
||||||
NavigationLink(destination: AboutView()) {
|
NavigationLink(destination: AboutView()) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
|||||||
117
RIT Dining/Data/DiningModel.swift
Normal file
117
RIT Dining/Data/DiningModel.swift
Normal file
@ -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..<locations.locations.count {
|
||||||
|
let diningInfo = parseLocationInfo(location: locations.locations[i], forDate: day)
|
||||||
|
newDiningLocations.append(diningInfo)
|
||||||
|
}
|
||||||
|
newLocationsByDay.append(newDiningLocations)
|
||||||
|
case .failure(let error):
|
||||||
|
throw(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationsByDay = newLocationsByDay
|
||||||
|
lastRefreshed = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterates through all of the locations and updates their open status indicator based on the current time. Does a replace
|
||||||
|
// to make sure that it updates any views observing this model.
|
||||||
|
func updateOpenStatuses() {
|
||||||
|
locationsByDay = locationsByDay.map { day in
|
||||||
|
day.map { location in
|
||||||
|
var location = location
|
||||||
|
location.updateOpenStatus()
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleAllPushes() async {
|
||||||
|
for day in locationsByDay {
|
||||||
|
for location in day {
|
||||||
|
if let visitingChefs = location.visitingChefs {
|
||||||
|
for chef in visitingChefs {
|
||||||
|
if notifyingChefs.contains(chef.name) {
|
||||||
|
await visitingChefPushes.scheduleNewPush(
|
||||||
|
name: chef.name,
|
||||||
|
location: location.name,
|
||||||
|
startTime: chef.openTime,
|
||||||
|
endTime: chef.closeTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Run a cleanup task once we're done scheduling.
|
||||||
|
await cleanupPushes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old push notifications that have already gone by so we're not still tracking them forever and ever.
|
||||||
|
func cleanupPushes() async {
|
||||||
|
let now = Date()
|
||||||
|
for push in visitingChefPushes.pushes {
|
||||||
|
if now > 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
RIT Dining/Data/PushesModel.swift
Normal file
86
RIT Dining/Data/PushesModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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..<locations.locations.count {
|
|
||||||
let diningInfo = parseLocationInfo(location: locations.locations[i], forDate: day)
|
|
||||||
newDiningLocations.append(diningInfo)
|
|
||||||
}
|
|
||||||
newLocationsByDay.append(newDiningLocations)
|
|
||||||
case .failure(let error):
|
|
||||||
throw(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
locationsByDay = newLocationsByDay
|
|
||||||
lastRefreshed = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterates through all of the locations and updates their open status indicator based on the current time. Does a replace
|
|
||||||
// to make sure that it updates any views observing this model.
|
|
||||||
func updateOpenStatuses() {
|
|
||||||
locationsByDay = locationsByDay.map { day in
|
|
||||||
day.map { location in
|
|
||||||
var location = location
|
|
||||||
location.updateOpenStatus()
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
RIT Dining/Data/Types/PushTypes.swift
Normal file
17
RIT Dining/Data/Types/PushTypes.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// PushTypes.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// Created by Campbell on 11/20/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Struct to represent a visiting chef notification that has already been scheduled, allowing it to be loaded again later to recall what notifications have been scheduled.
|
||||||
|
struct ScheduledVistingChefPush: Codable, Equatable {
|
||||||
|
let uuid: String
|
||||||
|
let name: String
|
||||||
|
let location: String
|
||||||
|
let startTime: Date
|
||||||
|
let endTime: Date
|
||||||
|
}
|
||||||
@ -116,6 +116,7 @@ struct DetailView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(Range(1...5), id: \.self) { index in
|
ForEach(Range(1...5), id: \.self) { index in
|
||||||
if occupancyPercentage > (20 * Double(index)) {
|
if occupancyPercentage > (20 * Double(index)) {
|
||||||
@ -134,6 +135,7 @@ struct DetailView: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0))
|
.foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0))
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(alignment: .trailing) {
|
VStack(alignment: .trailing) {
|
||||||
@ -156,14 +158,16 @@ struct DetailView: View {
|
|||||||
.font(.title3)
|
.font(.title3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page.
|
// THIS FEATURE DISABLED AT RIT'S REQUEST FOR SECURITY REASONS.
|
||||||
Button(action: {
|
// No hard feelings or anything though, I get it.
|
||||||
openURL(URL(string: "https://ondemand.rit.edu")!)
|
// // Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page.
|
||||||
}) {
|
// Button(action: {
|
||||||
Image(systemName: "cart")
|
// openURL(URL(string: "https://ondemand.rit.edu")!)
|
||||||
.font(.title3)
|
// }) {
|
||||||
}
|
// Image(systemName: "cart")
|
||||||
.disabled(location.open == .closed || location.open == .openingSoon)
|
// .font(.title3)
|
||||||
|
// }
|
||||||
|
// .disabled(location.open == .closed || location.open == .openingSoon)
|
||||||
// Open this location on the RIT map in embedded Safari.
|
// Open this location on the RIT map in embedded Safari.
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingSafari = true
|
showingSafari = true
|
||||||
|
|||||||
@ -200,6 +200,7 @@ struct MenuView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
|
Text("Meal Periods")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct VisitingChefPush: View {
|
struct VisitingChefPush: View {
|
||||||
@AppStorage("visitingChefPushEnabled") var pushEnabled: Bool = false
|
@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
|
@State private var pushAllowed: Bool = false
|
||||||
private let visitingChefs = [
|
private let visitingChefs = [
|
||||||
"California Rollin' Sushi",
|
"California Rollin' Sushi",
|
||||||
@ -32,18 +33,39 @@ struct VisitingChefPush: View {
|
|||||||
Text("Notifications Enabled")
|
Text("Notifications Enabled")
|
||||||
}
|
}
|
||||||
.disabled(!pushAllowed)
|
.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
|
ForEach(visitingChefs, id: \.self) { chef in
|
||||||
Toggle(isOn: Binding(
|
Toggle(isOn: Binding(
|
||||||
get: {
|
get: {
|
||||||
notifyingChefs.contains(chef)
|
model.notifyingChefs.contains(chef)
|
||||||
},
|
},
|
||||||
set: { isOn in
|
set: { isOn in
|
||||||
if isOn {
|
if isOn {
|
||||||
notifyingChefs.add(chef)
|
model.notifyingChefs.add(chef)
|
||||||
|
Task {
|
||||||
|
await model.schedulePushesForChef(chef)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
notifyingChefs.remove(chef)
|
model.notifyingChefs.remove(chef)
|
||||||
|
model.visitingChefPushes.cancelPushesForChef(name: chef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
@ -52,8 +74,49 @@ struct VisitingChefPush: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!pushAllowed || !pushEnabled)
|
.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 {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user