Replace all instances of "RIT Dining" with "TigerDine"

The project and some files were still named that way, so that's been fixed now. The bundle ID is stuck that way forever but oh well. Nobody will see that.
This commit is contained in:
2025-12-24 16:41:18 -05:00
parent 6fd11575af
commit 23ebc9d848
47 changed files with 33 additions and 427 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@@ -0,0 +1,37 @@
{
"fill" : {
"automatic-gradient" : "gray:0.90568,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "TigerDine Temp Logo Emblem.png",
"name" : "TigerDine Temp Logo Emblem",
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.412",
"red" : "0.969"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "RIT Dining Temp Logo.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "TigerDine Temp Logo-iOS-Default-1024x1024@1x.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "TigerDine Temp Logo-iOS-Dark-1024x1024@1x.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "kofiLogo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "paypalLogo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,75 @@
//
// FDMealPlannerParsers.swift
// TigerDine
//
// Created by Campbell on 11/3/25.
//
import Foundation
func parseFDMealPlannerMenu(menu: FDMealsParser) -> [FDMenuItem] {
var menuItems: [FDMenuItem] = []
if menu.result.isEmpty {
return menuItems
}
// We only need to operate on index 0, because the request code is designed to only get the menu for a single day so there
// will only be a single index to operate on.
if let allMenuRecipes = menu.result[0].allMenuRecipes {
for recipe in allMenuRecipes {
// Prevent duplicate items from being added, because for some reason the exact same item with the exact same information
// might be included in FD MealPlanner more than once.
if menuItems.contains(where: { $0.id == recipe.componentId }) {
continue
}
// englishAlternateName holds the proper name of the item, but it's blank for some items for some reason. If that's the
// case, then we should fall back on componentName, which is less user-friendly but works as a backup.
let realName = if recipe.englishAlternateName != "" {
recipe.englishAlternateName.trimmingCharacters(in: .whitespaces)
} else {
recipe.componentName.trimmingCharacters(in: .whitespaces)
}
let allergens = recipe.allergenName != "" ? recipe.allergenName.components(separatedBy: ",") : []
// Get the list of dietary markers (Vegan, Vegetarian, Pork, Beef), and drop "Vegetarian" if "Vegan" is also included since
// that's kinda redundant.
var dietaryMarkers = recipe.recipeProductDietaryName != "" ? recipe.recipeProductDietaryName.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } : []
if dietaryMarkers.contains("Vegan") {
dietaryMarkers.remove(at: dietaryMarkers.firstIndex(of: "Vegetarian")!)
}
let calories = Int(Double(recipe.calories)!.rounded())
// Collect and organize all the nutritional entries. I ordered them based off how they were ordered in the nutritional
// facts panel on the side of the bag of goldfish that lives on my desk, so presumably they're ordered correctly.
let nutritionalEntries = [
FDNutritionalEntry(type: "Total Fat", amount: Double(recipe.fat) ?? 0.0, unit: recipe.fatUOM),
FDNutritionalEntry(type: "Saturated Fat", amount: Double(recipe.saturatedFat) ?? 0.0, unit: recipe.saturatedFatUOM),
FDNutritionalEntry(type: "Trans Fat", amount: Double(recipe.transFattyAcid) ?? 0.0, unit: recipe.transFattyAcidUOM),
FDNutritionalEntry(type: "Cholesterol", amount: Double(recipe.cholesterol) ?? 0.0, unit: recipe.cholesterolUOM),
FDNutritionalEntry(type: "Sodium", amount: Double(recipe.sodium) ?? 0.0, unit: recipe.sodiumUOM),
FDNutritionalEntry(type: "Total Carbohydrates", amount: Double(recipe.carbohydrates) ?? 0.0, unit: recipe.carbohydratesUOM),
FDNutritionalEntry(type: "Dietary Fiber", amount: Double(recipe.dietaryFiber) ?? 0.0, unit: recipe.dietaryFiberUOM),
FDNutritionalEntry(type: "Total Sugars", amount: Double(recipe.totalSugars) ?? 0.0, unit: recipe.totalSugarsUOM),
FDNutritionalEntry(type: "Protein", amount: Double(recipe.protein) ?? 0.0, unit: recipe.proteinUOM),
FDNutritionalEntry(type: "Calcium", amount: Double(recipe.calcium) ?? 0.0, unit: recipe.calciumUOM),
FDNutritionalEntry(type: "Iron", amount: Double(recipe.iron) ?? 0.0, unit: recipe.ironUOM),
FDNutritionalEntry(type: "Vitamin A", amount: Double(recipe.vitaminA) ?? 0.0, unit: recipe.vitaminAUOM),
FDNutritionalEntry(type: "Vitamin C", amount: Double(recipe.vitaminC) ?? 0.0, unit: recipe.vitaminCUOM),
]
let newItem = FDMenuItem(
id: recipe.componentId,
name: realName,
exactName: recipe.componentName,
category: recipe.category,
allergens: allergens,
calories: calories,
nutritionalEntries: nutritionalEntries,
dietaryMarkers: dietaryMarkers,
ingredients: recipe.ingredientStatement,
price: recipe.sellingPrice,
servingSize: recipe.productMeasuringSize,
servingSizeUnit: recipe.productMeasuringSizeUnit
)
menuItems.append(newItem)
}
}
return menuItems
}

View File

@@ -0,0 +1,97 @@
//
// FoodTruckParsers.swift
// TigerDine
//
// Created by Campbell on 11/3/25.
//
import Foundation
import SwiftSoup
// This code is actually miserable and might break sometimes. Sorry. Parse the HTML of the RIT food trucks web page and build
// a list of food trucks that are going to be there the next time they're there. This is not a good way to get this data but it's
// unfortunately the best way that I think I could make it happen. Sorry again for both my later self and anyone else who tries to
// work on this code.
func parseWeekendFoodTrucks(htmlString: String) -> [FoodTruckEvent] {
do {
let doc = try SwiftSoup.parse(htmlString)
var events: [FoodTruckEvent] = []
let now = Date()
let calendar = Calendar.current
let paragraphs = try doc.select("p:has(strong)")
for p in paragraphs {
let text = try p.text()
let parts = text.components(separatedBy: .whitespaces).joined(separator: " ")
let dateRegex = /(?:(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+[A-Za-z]+\s+\d+)/
let date = parts.firstMatch(of: dateRegex).map { String($0.0) } ?? ""
if date.isEmpty { continue }
let timeRegex = /(\d{1,2}(:\d{2})?\s*[-]\s*\d{1,2}(:\d{2})?\s*p\.m\.)/
let time = parts.firstMatch(of: timeRegex).map { String($0.0) } ?? ""
let locationRegex = /A-Z Lot/
let location = parts.firstMatch(of: locationRegex).map { String($0.0) } ?? ""
let year = Calendar.current.component(.year, from: Date())
let fullDateString = "\(date) \(year)"
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMMM d yyyy"
formatter.locale = Locale(identifier: "en_US_POSIX")
let dateParsed = formatter.date(from: fullDateString) ?? now
let timeStrings = time.split(separator: "-", maxSplits: 1)
print("raw open range: \(timeStrings)")
var openTime = Date()
var closeTime = Date()
if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) {
// If the time is NOT in the morning, add 12 hours.
let openHour = if openString.contains("a.m") {
Int(openString.filter("0123456789".contains))!
} else {
Int(openString)! + 12
}
let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0)
openTime = calendar.date(
bySettingHour: openTimeComponents.hour!,
minute: openTimeComponents.minute!,
second: openTimeComponents.second!,
of: now)!
}
if let closeString = timeStrings.last?.filter(":0123456789".contains) {
// I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have
// yet to see any evidence of a visiting chef leaving before noon so far.
let closeStringComponents = closeString.split(separator: ":", maxSplits: 1)
let closeTimeComponents = DateComponents(
hour: Int(closeStringComponents[0])! + 12,
minute: closeStringComponents.count > 1 ? Int(closeStringComponents[1]) : 0,
second: 0)
closeTime = calendar.date(
bySettingHour: closeTimeComponents.hour!,
minute: closeTimeComponents.minute!,
second: closeTimeComponents.second!,
of: now)!
}
if let ul = try p.nextElementSibling(), ul.tagName() == "ul" {
let trucks = try ul.select("li").array().map { try $0.text() }
events.append(FoodTruckEvent(
date: dateParsed,
openTime: openTime,
closeTime: closeTime,
location: location,
trucks: trucks
))
print(events)
}
}
return events
} catch {
print(error)
return []
}
}

View File

@@ -0,0 +1,60 @@
//
// PushScheduler.swift
// TigerDine
//
// Created by Campbell on 10/3/25.
//
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))"
content.sound = .default
// 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)")
}

View File

@@ -0,0 +1,187 @@
//
// Requests.swift
// TigerDine
//
// Created by Campbell on 8/31/25.
//
import Foundation
enum InvalidHTTPError: Error {
case invalid
}
// This code has now been mostly rewritten to be pretty and async instead of being horrifying callback based code in a context where
// callback based code made no sense. I love async!
// Get information for all dining locations.
func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Error> {
// The endpoint requires that you specify a date, so get today's.
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(dateString)"
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(DiningLocationsParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
// Get information for just one dining location based on its location ID.
func getSingleDiningInfo(date: String?, locId: Int) async -> Result<DiningLocationParser, Error> {
// The current date and the location ID are required to get information for just one location.
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(dateString)&locId=\(locId)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(DiningLocationParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
// Get the occupancy information for a location using its MDO ID, whatever that stands for. This ID is provided alongside the other
// main ID in the data returned by the TigerCenter API.
func getOccupancyPercentage(mdoId: Int) async -> Result<Double, Error> {
let urlString = "https://maps.rit.edu/proxySearch/densityMapDetail.php?mdo=\(mdoId)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let occupancy = try JSONDecoder().decode([DiningOccupancyParser].self, from: data)
if !occupancy.isEmpty {
print("current occupancy: \(occupancy[0].count)")
print("maximum occupancy: \(occupancy[0].max_occ)")
let occupancyPercentage = Double(occupancy[0].count) / Double(occupancy[0].max_occ) * 100
print("occupancy percentage: \(occupancyPercentage)%")
return .success(occupancyPercentage)
} else {
return .failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON")))
}
} catch {
return .failure(error)
}
}
// Get the RIT Events food truck page and strip it down to just the part of the HTML that we'll need to parse with SwiftSoup.
func getFoodTruckPage() async -> Result<String, Error> {
let urlString = "https://www.rit.edu/events/weekend-food-trucks"
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let contents = try String(contentsOf: url)
let scheduleRegex = /<div class=\".*?field--name-field-event-description.*?\">([\s\S]*?)<\/div>/
if let match = contents.firstMatch(of: scheduleRegex) {
return .success(String(match.0))
}
return .success(contents)
} catch {
return .failure(error)
}
}
func searchFDMealPlannerLocations() async -> Result<FDSearchResponseParser, Error> {
let urlString = "https://locations.fdmealplanner.com/api/v1/location-data-webapi/search-locationByAccount?AccountShortName=RIT&pageIndex=1&pageSize=0"
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDSearchResponseParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
func getFDMealPlannerOpenings(locationId: Int) async -> Result<FDMealPeriodsParser, Error> {
let urlString = "https://apiservicelocatorstenantrit.fdmealplanner.com/api/v1/data-locator-webapi/20/mealPeriods?LocationId=\(locationId)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDMealPeriodsParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
func getFDMealPlannerMenu(locationId: Int, accountId: Int, mealPeriodId: Int) async -> Result<FDMealsParser, Error> {
let dateString = getFDMPAPIFriendlyDateString(date: Date())
let urlString = "https://apiservicelocatorstenantrit.fdmealplanner.com/api/v1/data-locator-webapi/20/meals?menuId=0&accountId=\(accountId)&locationId=\(locationId)&mealPeriodId=\(mealPeriodId)&tenantId=20&monthId=\(Calendar.current.component(.month, from: Date()))&startDate=\(dateString)&endDate=\(dateString)"
print("making request to \(urlString)")
guard let url = URL(string: urlString) else {
return .failure(URLError(.badURL))
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return .failure(InvalidHTTPError.invalid)
}
let decoded = try JSONDecoder().decode(FDMealsParser.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}

View File

@@ -0,0 +1,69 @@
//
// SharedComponents.swift
// TigerDine
//
// Created by Campbell on 9/8/25.
//
import Foundation
import SafariServices
import SwiftUI
// Gross disgusting UIKit code :(
// There isn't a direct way to use integrated Safari from SwiftUI, except maybe in iOS 26? I'm not targeting that though so I must fall
// back on UIKit stuff.
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
func getTCAPIFriendlyDateString(date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
func getFDMPAPIFriendlyDateString(date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy/MM/dd"
return formatter.string(from: date)
}
// The common date formatter that I'm using everywhere that open periods are shown within the app.
let dateDisplay: DateFormatter = {
let display = DateFormatter()
display.timeZone = TimeZone(identifier: "America/New_York")
display.dateStyle = .none
display.timeStyle = .short
return display
}()
let visitingChefDateDisplay: DateFormatter = {
let display = DateFormatter()
display.dateFormat = "EEEE, MMM d"
display.locale = Locale(identifier: "en_US_POSIX")
return display
}()
let weekdayFromDate: DateFormatter = {
let weekdayFormatter = DateFormatter()
weekdayFormatter.dateFormat = "EEEE"
return weekdayFormatter
}()
// Custom view extension that just applies modifiers in a block to the object it's applied to. Mostly useful for splitting up conditional
// modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!)
extension View {
func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
}

View File

@@ -0,0 +1,316 @@
//
// TigerCenterParsers.swift
// TigerDine
//
// Created by Campbell on 9/19/25.
//
import Foundation
func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus {
// This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
let calendar = Calendar.current
let now = Date()
var openStatus: OpenStatus = .closed
if now >= openTime && now <= closeTime {
// This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is
// only true for 24-hour locations.
if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! {
openStatus = .open
} else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! {
openStatus = .closingSoon
} else {
openStatus = .open
}
} else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now {
openStatus = .openingSoon
} else {
openStatus = .closed
}
return openStatus
}
func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation {
print("beginning parse for \(location.name)")
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
let desc = location.description.replacingOccurrences(of: "<br />", with: "")
// Check if this location's ID is in the TigerCenter -> FD MealPlanner ID map and save those IDs if it is.
let fdmpIds: FDMPIds? = if tCtoFDMPMap.keys.contains(location.id) {
FDMPIds(
locationId: tCtoFDMPMap[location.id]!.0,
accountId: tCtoFDMPMap[location.id]!.1
)
} else {
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/?mdo_id=\(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(
id: location.id,
mdoId: location.mdoId,
fdmpIds: fdmpIds,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: mapsUrl,
date: forDate ?? Date(),
diningTimes: nil,
open: .closed,
visitingChefs: nil,
dailySpecials: nil)
}
var openStrings: [String] = []
var closeStrings: [String] = []
// Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there
// are exceptions, use those times for the day, otherwise we can just use the default times. Also check for repeats! The response data
// can include those somtimes, for reasons:tm:
for event in location.events {
if let exceptions = event.exceptions, !exceptions.isEmpty {
// Only save the exception times if the location is actually open during those times, and if these times aren't a repeat.
// I've seen repeats for Brick City Cafe specifically, where both the breakfast and lunch standard open periods had
// exceptions listing the same singluar brunch period. That feels like a stupid choice but oh well.
if exceptions[0].open, !openStrings.contains(exceptions[0].startTime), !closeStrings.contains(exceptions[0].endTime) {
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
}
} else {
if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) {
// Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the
// week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no
// hours for this location.
if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) {
openStrings.append(event.startTime)
closeStrings.append(event.endTime)
}
}
}
}
// Early return if there are no valid opening times, most likely because the day's exceptions dictate that the location is closed.
// Mostly comes into play on holidays.
if openStrings.isEmpty || closeStrings.isEmpty {
return DiningLocation(
id: location.id,
mdoId: location.mdoId,
fdmpIds: fdmpIds,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: mapsUrl,
date: forDate ?? Date(),
diningTimes: nil,
open: .closed,
visitingChefs: nil,
dailySpecials: nil)
}
// I hate all of this date component nonsense.
var openDates: [Date] = []
var closeDates: [Date] = []
let calendar = Calendar.current
let now = Date()
for i in 0..<openStrings.count {
let openParts = openStrings[i].split(separator: ":").map { Int($0) ?? 0 }
let openTimeComponents = DateComponents(hour: openParts[0], minute: openParts[1], second: openParts[2])
let closeParts = closeStrings[i].split(separator: ":").map { Int($0) ?? 0 }
let closeTimeComponents = DateComponents(hour: closeParts[0], minute: closeParts[1], second: closeParts[2])
openDates.append(calendar.date(
bySettingHour: openTimeComponents.hour!,
minute: openTimeComponents.minute!,
second: openTimeComponents.second!,
of: now)!)
closeDates.append(calendar.date(
bySettingHour: closeTimeComponents.hour!,
minute: closeTimeComponents.minute!,
second: closeTimeComponents.second!,
of: now)!)
}
var diningTimes: [DiningTimes] = []
for i in 0..<openDates.count {
diningTimes.append(DiningTimes(openTime: openDates[i], closeTime: closeDates[i]))
}
// If the closing time is less than or equal to the opening time, it's probably midnight and means either open until midnight
// or open 24/7, in the case of Bytes.
for i in diningTimes.indices {
if diningTimes[i].closeTime <= diningTimes[i].openTime {
diningTimes[i].closeTime = calendar.date(byAdding: .day, value: 1, to: diningTimes[i].closeTime)!
}
}
// Sometimes the openings are not in order, for some reason. I'm observing this with Brick City, where for some reason the early opening
// is event 1, and the later opening is event 0. This is silly so let's reverse it.
diningTimes.sort { $0.openTime < $1.openTime }
// This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
var openStatus: OpenStatus = .closed
for i in diningTimes.indices {
openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime)
// If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
// accurately catch Gracie's multiple open periods each day.
if openStatus != .closed {
break
}
}
// Parse the "menus" array and keep track of visiting chefs at this location, if there are any. If not then we can just save nil.
// The time formats used for visiting chefs are inconsistent and suck so that part of this code might be kind of rough. I can
// probably make it a little better but I think most of the blame goes to TigerCenter here.
// Also save the daily specials. This is more of a footnote because that's just taking a string and saving it as two strings.
let visitingChefs: [VisitingChef]?
let dailySpecials: [DailySpecial]?
if !location.menus.isEmpty {
var chefs: [VisitingChef] = []
var specials: [DailySpecial] = []
for menu in location.menus {
if menu.category == "Visiting Chef" {
print("found visiting chef: \(menu.name)")
var name: String = menu.name
let splitString = name.split(separator: "(", maxSplits: 1)
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.
let timeStrings = String(splitString[1]).replacingOccurrences(of: ")", with: "").split(separator: "-", maxSplits: 1)
print("raw open range: \(timeStrings)")
let openTime: Date
let closeTime: Date
if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) {
// If the time is NOT in the morning, add 12 hours.
let openHour = if openString.contains("a.m") {
Int(openString.filter("0123456789".contains))!
} else {
Int(openString)! + 12
}
let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0)
openTime = calendar.date(
bySettingHour: openTimeComponents.hour!,
minute: openTimeComponents.minute!,
second: openTimeComponents.second!,
of: forDate ?? now)!
} else {
break
}
if let closeString = timeStrings.last?.filter("0123456789".contains) {
// I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have
// yet to see any evidence of a visiting chef leaving before noon so far.
let closeHour = Int(closeString)! + 12
let closeTimeComponents = DateComponents(hour: closeHour, minute: 0, second: 0)
closeTime = calendar.date(
bySettingHour: closeTimeComponents.hour!,
minute: closeTimeComponents.minute!,
second: closeTimeComponents.second!,
of: forDate ?? now)!
} else {
break
}
// Parse the chef's status, mapping the OpenStatus to a VisitingChefStatus.
let visitngChefStatus: VisitingChefStatus = switch parseOpenStatus(openTime: openTime, closeTime: closeTime) {
case .open:
.hereNow
case .closed:
if now < openTime {
.arrivingLater
} else {
.gone
}
case .openingSoon:
.arrivingSoon
case .closingSoon:
.leavingSoon
}
chefs.append(VisitingChef(
name: name,
description: menu.description ?? "No description available", // Some don't have descriptions, apparently.
openTime: openTime,
closeTime: closeTime,
status: visitngChefStatus))
} else if menu.category == "Daily Specials" {
print("found daily special: \(menu.name)")
let splitString = menu.name.split(separator: "(", maxSplits: 1)
specials.append(DailySpecial(
name: String(splitString[0]),
type: String(splitString.count > 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: "")))
}
}
visitingChefs = chefs
dailySpecials = specials
} else {
visitingChefs = nil
dailySpecials = nil
}
return DiningLocation(
id: location.id,
mdoId: location.mdoId,
fdmpIds: fdmpIds,
name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: mapsUrl,
date: forDate ?? Date(),
diningTimes: diningTimes,
open: openStatus,
visitingChefs: visitingChefs,
dailySpecials: dailySpecials)
}
extension DiningLocation {
// Updates the open status of a location and of its visiting chefs, so that the labels in the UI update automatically as
// time progresses and locations open/close/etc.
mutating func updateOpenStatus() {
var openStatus: OpenStatus = .closed
if let diningTimes = diningTimes, !diningTimes.isEmpty {
for i in diningTimes.indices {
openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime)
// If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
// accurately catch Gracie's multiple open periods each day.
if openStatus != .closed {
break
}
}
self.open = openStatus
} else {
self.open = .closed
}
if let visitingChefs = visitingChefs, !visitingChefs.isEmpty {
let now = Date()
for i in visitingChefs.indices {
self.visitingChefs![i].status = switch parseOpenStatus(
openTime: visitingChefs[i].openTime,
closeTime: visitingChefs[i].closeTime) {
case .open:
.hereNow
case .closed:
if now < visitingChefs[i].openTime {
.arrivingLater
} else {
.gone
}
case .openingSoon:
.arrivingSoon
case .closingSoon:
.leavingSoon
}
}
}
}
}

194
TigerDine/ContentView.swift Normal file
View File

@@ -0,0 +1,194 @@
//
// ContentView.swift
// TigerDine
//
// Created by Campbell on 8/31/25.
//
import SwiftUI
struct ContentView: View {
// Save sort/filter options in AppStorage so that they actually get saved.
@AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false
@AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false
@State private var favorites = Favorites()
@State private var notifyingChefs = NotifyingChefs()
@State private var model = DiningModel()
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var showingDonationSheet: Bool = false
@State private var rotationDegrees: Double = 0
@State private var diningLocations: [DiningLocation] = []
@State private var searchText: String = ""
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
// Small wrapper around the method on the model so that errors can be handled by showing the uh error screen.
private func getDiningData() async {
do {
try await model.getHoursByDay()
await model.scheduleAllPushes()
isLoading = false
} catch {
isLoading = true
loadFailed = true
}
}
// Start a perpetually running timer to refresh the open statuses, so that they automatically switch as appropriate without
// needing to refresh the data. You don't need to yell at the API again to know that the location opening at 11:00 AM should now
// display "Open" instead of "Opening Soon" now that it's 11:01.
private func updateOpenStatuses() async {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
model.updateOpenStatuses()
// If the last refreshed date isn't today, that means we probably passed midnight and need to refresh the data.
// So do that.
if !Calendar.current.isDateInToday(model.lastRefreshed ?? Date()) {
Task {
await getDiningData()
}
}
}
}
var body: some View {
NavigationStack() {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching dining data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button(action: {
loadFailed = false
Task {
await getDiningData()
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.padding(.top, 10)
} else {
Image(systemName: "fork.knife.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("Loading...")
.foregroundStyle(.secondary)
}
}
.padding()
} else {
VStack() {
List {
Section(content: {
NavigationLink(destination: VisitingChefs()) {
Text("Upcoming Visiting Chefs")
}
NavigationLink(destination: FoodTruckView()) {
Text("Weekend Food Trucks")
}
})
Section(content: {
LocationList(
diningLocations: $model.locationsByDay[0],
openLocationsFirst: $openLocationsFirst,
openLocationsOnly: $openLocationsOnly,
searchText: $searchText
)
}, footer: {
if let lastRefreshed = model.lastRefreshed {
VStack(alignment: .center) {
Text("Last refreshed: \(lastRefreshed.formatted())")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
})
}
}
.navigationTitle("TigerDine")
.searchable(text: $searchText, prompt: "Search")
.refreshable {
await getDiningData()
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
NavigationLink(destination: VisitingChefPush()) {
Image(systemName: "bell.badge")
}
Menu {
Button(action: {
Task {
await getDiningData()
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
Divider()
NavigationLink(destination: AboutView()) {
Image(systemName: "info.circle")
Text("About")
}
Button(action: {
showingDonationSheet = true
}) {
Label("Donate", systemImage: "heart")
}
} label: {
Image(systemName: "slider.horizontal.3")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Menu {
Toggle(isOn: $openLocationsOnly) {
Label("Hide Closed Locations", systemImage: "eye.slash")
}
Toggle(isOn: $openLocationsFirst) {
Label("Open Locations First", systemImage: "arrow.up.arrow.down")
}
} label: {
Image(systemName: "line.3.horizontal.decrease")
}
if #unavailable(iOS 26.0) {
Spacer()
}
}
if #available(iOS 26.0, *) {
ToolbarSpacer(.flexible, placement: .bottomBar)
DefaultToolbarItem(kind: .search, placement: .bottomBar)
}
}
}
}
.environment(favorites)
.environment(notifyingChefs)
.environment(model)
.task {
await getDiningData()
await updateOpenStatuses()
}
.sheet(isPresented: $showingDonationSheet) {
DonationView()
}
}
}
#Preview {
ContentView()
}

View File

@@ -0,0 +1,51 @@
//
// DietaryRestrictions.swift
// TigerDine
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
enum Allergen: String, Codable, CaseIterable {
case coconut
case egg
case gluten
case milk
case peanut
case sesame
case shellfish
case soy
case treenut
case wheat
}
@Observable
class DietaryRestrictions {
private var dietaryRestrictions: Set<String>
private let key = "DietaryRestrictions"
init() {
let favorites = UserDefaults.standard.array(forKey: key) as? [String] ?? [String]()
dietaryRestrictions = Set(favorites)
}
func contains(_ restriction: Allergen) -> Bool {
dietaryRestrictions.contains(restriction.rawValue)
}
func add(_ restriction: Allergen) {
dietaryRestrictions.insert(restriction.rawValue)
save()
}
func remove(_ restriction: Allergen) {
dietaryRestrictions.remove(restriction.rawValue)
save()
}
func save() {
let favorites = Array(dietaryRestrictions)
UserDefaults.standard.set(favorites, forKey: key)
}
}

View File

@@ -0,0 +1,117 @@
//
// DiningModel.swift
// TigerDine
//
// 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
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
//
// Favorites.swift
// TigerDine
//
// Created by Campbell on 9/22/25.
//
import SwiftUI
@Observable
class Favorites {
private var favoriteLocations: Set<Int>
private let key = "Favorites"
init() {
let favorites = UserDefaults.standard.array(forKey: key) as? [Int] ?? [Int]()
favoriteLocations = Set(favorites)
}
func contains(_ location: DiningLocation) -> Bool {
favoriteLocations.contains(location.id)
}
func add(_ location: DiningLocation) {
favoriteLocations.insert(location.id)
save()
}
func remove(_ location: DiningLocation) {
favoriteLocations.remove(location.id)
save()
}
func save() {
let favorites = Array(favoriteLocations)
UserDefaults.standard.set(favorites, forKey: key)
}
}

View File

@@ -0,0 +1,37 @@
//
// MenuDietaryRestrictionsModel.swift
// TigerDine
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
class MenuDietaryRestrictionsModel: ObservableObject {
var dietaryRestrictions = DietaryRestrictions()
// I thought these could be @AppStorage keys but apparently not, because SwiftUI would subscribe to updates from those if
// they aren't being used directly inside the view.
@Published var isVegetarian: Bool {
didSet { UserDefaults.standard.set(isVegetarian, forKey: "isVegetarian") }
}
@Published var isVegan: Bool {
didSet { UserDefaults.standard.set(isVegan, forKey: "isVegan") }
}
@Published var noBeef: Bool {
didSet { UserDefaults.standard.set(noBeef, forKey: "noBeef") }
}
@Published var noPork: Bool {
didSet { UserDefaults.standard.set(noPork, forKey: "noPork") }
}
init() {
self.isVegetarian = UserDefaults.standard.bool(forKey: "isVegetarian")
self.isVegan = UserDefaults.standard.bool(forKey: "isVegan")
self.noBeef = UserDefaults.standard.bool(forKey: "noBeef")
self.noPork = UserDefaults.standard.bool(forKey: "noPork")
}
}

View File

@@ -0,0 +1,38 @@
//
// NotifyingChefs.swift
// TigerDine
//
// Created by Campbell on 10/1/25.
//
import SwiftUI
@Observable
class NotifyingChefs {
private var notifyingChefs: Set<String>
private let key = "NotifyingChefs"
init() {
let chefs = UserDefaults.standard.array(forKey: key) as? [String] ?? [String]()
notifyingChefs = Set(chefs)
}
func contains(_ chef: String) -> Bool {
notifyingChefs.contains(chef.lowercased())
}
func add(_ chef: String) {
notifyingChefs.insert(chef.lowercased())
save()
}
func remove(_ chef: String) {
notifyingChefs.remove(chef.lowercased())
save()
}
func save() {
let chefs = Array(notifyingChefs)
UserDefaults.standard.set(chefs, forKey: key)
}
}

View File

@@ -0,0 +1,86 @@
//
// PushesModel.swift
// TigerDine
//
// 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
}
}
}

View File

@@ -0,0 +1,16 @@
//
// FDMPMealPeriods.swift
// TigerDine
//
// Created by Campbell on 11/8/25.
//
import Foundation
let fdmpMealPeriodsMap: [Int: String] = [
1: "Breakfast",
2: "Lunch",
3: "Dinner",
6: "Late Night",
8: "All Day",
]

View File

@@ -0,0 +1,25 @@
//
// TCtoFDMPMap.swift
// TigerDine
//
// Created by Campbell on 11/8/25.
//
import Foundation
/// Maps the IDs used by TigerCenter to the locationId and accountId values used by FD MealPlanner. This is used to get menus for locations from their detail views.
let tCtoFDMPMap: [Int: (Int, Int)] = [
// These are ordered based on the way that they're ordered in the FD MealPlanner search API response.
30: (1, 1), // Artesano
31: (2, 2), // Beanz
23: (7, 7), // Crossroads
25: (8, 8), // Cantina
34: (6, 6), // Ctr-Alt-DELi
21: (10, 10), // Gracie's
22: (4, 4), // Brick City Cafe
441: (11, 11), // Loaded Latke
38: (12, 12), // Midnight Oil
26: (14, 4), // RITZ
35: (18, 17), // The College Grind
24: (15, 14), // The Commons
]

View File

@@ -0,0 +1,124 @@
//
// FDMealPlannerTypes.swift
// TigerDine
//
// Created by Campbell on 11/3/25.
//
import Foundation
/// Struct to parse the response from the FDMP search API. This API returns all of the dining locations that are have menus available and the required IDs needed to get those menus.
struct FDSearchResponseParser: Decodable {
/// The main response body containing the result count and the results themselves.
struct Data: Decodable {
/// The key information returned for each location in the search results. These values are required to pass along to the menu API.
struct Result: Decodable {
let locationId: Int
let accountId: Int
let tenantId: Int
let locationName: String
let locationCode: String
let locationDisplayName: String
let accountName: String
}
let result: [Result]
let totalCount: Int
}
let success: Bool
let errorMessage: String?
let data: Data
}
/// Struct to parse the response from the FDMP meal periods API. This API returns all potentail meal periods for a location based on its ID. This meal period ID is required to get the menu for that meal period from the meals API.
struct FDMealPeriodsParser: Decodable {
/// The response body, which is a list of responses that include a meal period and the ID that maps to it.
struct Data: Decodable {
let id: Int
let mealPeriodName: String
}
let success: Bool
let errorMessage: String?
let data: [Data]
}
/// Struct to parse the response from the FDMP meals API. This API contains the actual menu information for the specified location during the specified meal period. It doesn't contain every menu item, but it's the best source of menu information that I can access.
struct FDMealsParser: Decodable, Hashable {
/// The actual response body.
struct Result: Decodable, Hashable {
/// An individual item on the menu at this location and its information.
struct MenuRecipe: Decodable, Hashable {
let componentName: String
let componentId: Int
let componentTypeId: Int
let englishAlternateName: String
let category: String
let allergenName: String
let calories: String
let carbohydrates: String
let carbohydratesUOM: String
let dietaryFiber: String
let dietaryFiberUOM: String
let fat: String
let fatUOM: String
let protein: String
let proteinUOM: String
let saturatedFat: String
let saturatedFatUOM: String
let transFattyAcid: String
let transFattyAcidUOM: String
let calcium: String
let calciumUOM: String
let cholesterol: String
let cholesterolUOM: String
let iron: String
let ironUOM: String
let sodium: String
let sodiumUOM: String
let vitaminA: String
let vitaminAUOM: String
let vitaminC: String
let vitaminCUOM: String
let totalSugars: String
let totalSugarsUOM: String
let recipeProductDietaryName: String
let ingredientStatement: String
let sellingPrice: Double
let productMeasuringSize: Int
let productMeasuringSizeUnit: String
let itemsToOrder: Int
}
let menuId: Int
let menuForDate: String
let menuToDate: String
let accountId: Int
let accountName: String
let menuTypeName: String
let mealPeriodId: Int
let allMenuRecipes: [MenuRecipe]?
}
let responseStatus: String?
let result: [Result]
}
/// A single nutritional entry, including the amount and the unit. Used over a tuple for hashable purposes.
struct FDNutritionalEntry: Hashable {
let type: String
let amount: Double
let unit: String
}
/// A single menu item, stripped down and reorganized to a format that actually makes sense for me to use in the rest of the app.
struct FDMenuItem: Hashable, Identifiable {
let id: Int
let name: String
let exactName: String
let category: String
let allergens: [String]
let calories: Int
let nutritionalEntries: [FDNutritionalEntry]
let dietaryMarkers: [String]
let ingredients: String
let price: Double
let servingSize: Int
let servingSizeUnit: String
}

View File

@@ -0,0 +1,18 @@
//
// FoodTruckTypes.swift
// TigerDine
//
// Created by Campbell on 11/3/25.
//
import Foundation
/// A weekend food trucks even representing when it's happening and what food trucks will be there.
struct FoodTruckEvent: Hashable {
let date: Date
let openTime: Date
let closeTime: Date
let location: String
let trucks: [String]
}

View File

@@ -0,0 +1,17 @@
//
// PushTypes.swift
// TigerDine
//
// 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
}

View File

@@ -0,0 +1,136 @@
//
// TigerCenterTypes.swift
// TigerDine
//
// Created by Campbell on 9/2/25.
//
import Foundation
/// Struct to parse the response data from the TigerCenter API when getting the information for a dining location.
struct DiningLocationParser: Decodable {
/// An individual "event", which is just an open period for the location.
struct Event: Decodable {
/// Hour exceptions for the given event.
struct HoursException: Decodable {
let id: Int
let name: String
let startTime: String
let endTime: String
let startDate: String
let endDate: String
let open: Bool
}
let startTime: String
let endTime: String
let daysOfWeek: [String]
let exceptions: [HoursException]?
}
/// An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because visiting chefs have descriptions but specials do not.
struct Menu: Decodable {
let name: String
let description: String?
let category: String
}
/// Other basic information to read from a location's JSON that we'll need later.
let id: Int
let mdoId: Int
let name: String
let summary: String
let description: String
let mapsUrl: String
let events: [Event]
let menus: [Menu]
}
/// Struct that probably doesn't need to exist but this made parsing the list of location responses easy.
struct DiningLocationsParser: Decodable {
let locations: [DiningLocationParser]
}
/// Enum to represent the four possible states a given location can be in.
enum OpenStatus {
case open
case closed
case openingSoon
case closingSoon
}
/// An individual open period for a location.
struct DiningTimes: Equatable, Hashable {
var openTime: Date
var closeTime: Date
}
/// Enum to represent the five possible states a visiting chef can be in.
enum VisitingChefStatus {
case hereNow
case gone
case arrivingLater
case arrivingSoon
case leavingSoon
}
/// A visiting chef present at a location.
struct VisitingChef: Equatable, Hashable {
let name: String
let description: String
var openTime: Date
var closeTime: Date
var status: VisitingChefStatus
}
/// A daily special at a location.
struct DailySpecial: Equatable, Hashable {
let name: String
let type: String
}
/// The IDs required to get the menu for a location from FD MealPlanner. Only present if the location appears in the map.
struct FDMPIds: Hashable {
let locationId: Int
let accountId: Int
}
/// The basic information about a dining location needed to display it in the app after parsing is finished.
struct DiningLocation: Identifiable, Hashable {
let id: Int
let mdoId: Int
let fdmpIds: FDMPIds?
let name: String
let summary: String
let desc: String
let mapsUrl: String
let date: Date
let diningTimes: [DiningTimes]?
var open: OpenStatus
var visitingChefs: [VisitingChef]?
let dailySpecials: [DailySpecial]?
}
/// Parser to read the occupancy data for a location.
struct DiningOccupancyParser: Decodable {
/// Represents a per-hour occupancy rating.
struct HourlyOccupancy: Decodable {
let hour: Int
let today: Int
let today_max: Int
let one_week_ago: Int
let one_week_ago_max: Int
let average: Int
}
let count: Int
let location: String
let building: String
let mdo_id: Int
let max_occ: Int
let open_status: String
let intra_loc_hours: [HourlyOccupancy]
}
/// Struct used to represent a day and its hours as strings. Type used for the hours of today and the next 6 days used in DetailView.
struct WeeklyHours: Hashable {
let day: String
let date: Date
let timeStrings: [String]
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,17 @@
//
// TigerDineApp.swift
// TigerDine
//
// Created by Campbell on 8/31/25.
//
import SwiftUI
@main
struct TigerDineApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,70 @@
//
// AboutView.swift
// TigerDine
//
// Created by Campbell on 9/12/25.
//
import SwiftUI
struct AboutView: View {
@Environment(\.openURL) private var openURL
let appVersionString: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
let buildNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String
var body: some View {
VStack(alignment: .leading) {
Image("Icon")
.resizable()
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 20))
Text("TigerDine")
.font(.title)
.fontWeight(.bold)
Text("An unofficial RIT Dining app")
.font(.subheadline)
Text("Version \(appVersionString) (\(buildNumber))")
.foregroundStyle(.secondary)
.padding(.bottom, 2)
VStack(alignment: .leading, spacing: 10) {
Text("Dining locations, their descriptions, and their opening hours are sourced from the RIT student-run TigerCenter API. Building occupancy information is sourced from the official RIT maps API. Menu and nutritional information is sourced from the data provided to FD MealPlanner by RIT Dining through the FD MealPlanner API.")
Text("This app is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Rochester Institute of Technology. This app is student created and maintained.")
VStack(alignment: .center, spacing: 8) {
HStack(spacing: 8) {
Button(action: {
openURL(URL(string: "https://github.com/NinjaCheetah/TigerDine")!)
}) {
Label("Source Code", systemImage: "network")
}
Button(action: {
openURL(URL(string: "https://tigercenter.rit.edu/")!)
}) {
Label("TigerCenter", systemImage: "fork.knife.circle")
}
}
HStack(spacing: 8) {
Button(action: {
openURL(URL(string: "https://maps.rit.edu/")!)
}) {
Label("Official RIT Map", systemImage: "map")
}
Button(action: {
openURL(URL(string: "https://fdmealplanner.com/")!)
}) {
Label("FD MealPlanner", systemImage: "menucard")
}
}
}
.frame(maxWidth: .infinity)
}
Spacer()
}
.padding()
.navigationTitle("About")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
AboutView()
}

View File

@@ -0,0 +1,285 @@
//
// DetailView.swift
// TigerDine
//
// Created by Campbell on 9/1/25.
//
import SwiftUI
import SafariServices
struct DetailView: View {
@State var locationId: Int
@Environment(Favorites.self) var favorites
@Environment(DiningModel.self) var model
@Environment(\.openURL) private var openURL
@State private var showingSafari: Bool = false
@State private var occupancyLoading: Bool = true
@State private var occupancyPercentage: Double = 0.0
// This gets the location that we're meant to be displaying details about using the provided ID.
private var location: DiningLocation {
return model.locationsByDay[0].first { $0.id == locationId }!
}
// This creates a list of the time strings for the current day and following 6 days to display in the "Upcoming Hours" section.
// I realized that it makes a lot more sense to do today + 6 rather than just the current calendar week's hours, because who
// cares what Tuesday's hours were on Saturday, you want to know what Sunday's hours will be.
private var weeklyHours: [WeeklyHours] {
var newWeeklyHours: [WeeklyHours] = []
for day in model.locationsByDay {
for location in day {
if location.id == locationId {
let weekdayFormatter = DateFormatter()
weekdayFormatter.dateFormat = "EEEE"
if let times = location.diningTimes, !times.isEmpty {
var timeStrings: [String] = []
for time in times {
timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
}
newWeeklyHours.append(
WeeklyHours(
day: weekdayFormatter.string(from: location.date),
date: location.date,
timeStrings: timeStrings
))
} else {
newWeeklyHours.append(
WeeklyHours(
day: weekdayFormatter.string(from: location.date),
date: location.date,
timeStrings: ["Closed"]
))
}
}
}
}
return newWeeklyHours
}
// Still a little broken, does not work for refresh. Need to fix.
private func getOccupancy() async {
// Only fetch occupancy data if the location is actually open right now. Otherwise, just exit early and hide the spinner.
if location.open == .open || location.open == .closingSoon {
occupancyLoading = true
switch await getOccupancyPercentage(mdoId: location.mdoId) {
case .success(let occupancy):
occupancyPercentage = occupancy
occupancyLoading = false
case .failure(let error):
print(error)
occupancyLoading = false
}
} else {
occupancyLoading = false
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack(alignment: .center) {
VStack(alignment: .leading) {
Text(location.name)
.font(.title)
.fontWeight(.bold)
Text(location.summary)
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
VStack(alignment: .leading) {
switch location.open {
case .open:
Text("Open")
.font(.title3)
.foregroundStyle(.green)
case .closed:
Text("Closed")
.font(.title3)
.foregroundStyle(.red)
case .openingSoon:
Text("Opening Soon")
.font(.title3)
.foregroundStyle(.orange)
case .closingSoon:
Text("Closing Soon")
.font(.title3)
.foregroundStyle(.orange)
}
if let times = location.diningTimes, !times.isEmpty {
ForEach(times, id: \.self) { time in
Text("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
.foregroundStyle(.secondary)
}
} else {
Text("Not Open Today")
.foregroundStyle(.secondary)
}
}
#if DEBUG
HStack(spacing: 0) {
ForEach(Range(1...5), id: \.self) { index in
if occupancyPercentage > (20 * Double(index)) {
Image(systemName: "person.fill")
} else {
Image(systemName: "person")
}
}
ProgressView()
.progressViewStyle(.circular)
.frame(width: 18, height: 18)
.opacity(occupancyLoading ? 1 : 0)
.task {
await getOccupancy()
}
}
.foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0))
.font(.title3)
#endif
}
Spacer()
VStack(alignment: .trailing) {
HStack(alignment: .center) {
// Favorites toggle button.
Button(action: {
if favorites.contains(location) {
favorites.remove(location)
} else {
favorites.add(location)
}
}) {
if favorites.contains(location) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.font(.title3)
} else {
Image(systemName: "star")
.foregroundStyle(.yellow)
.font(.title3)
}
}
// 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
}) {
Image(systemName: "map")
.font(.title3)
}
}
if let fdmpIds = location.fdmpIds {
NavigationLink(destination: MenuView(accountId: fdmpIds.accountId, locationId: fdmpIds.locationId)) {
Text("View Menu")
}
.padding(.top, 5)
}
Spacer()
}
}
.padding(.bottom, 12)
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack(alignment: .leading) {
Text("Today's Visiting Chefs")
.font(.title3)
.fontWeight(.semibold)
ForEach(visitingChefs, id: \.self) { chef in
HStack(alignment: .top) {
Text(chef.name)
Spacer()
VStack(alignment: .trailing) {
switch chef.status {
case .hereNow:
Text("Here Now")
.foregroundStyle(.green)
case .gone:
Text("Left For Today")
.foregroundStyle(.red)
case .arrivingLater:
Text("Arriving Later")
.foregroundStyle(.red)
case .arrivingSoon:
Text("Arriving Soon")
.foregroundStyle(.orange)
case .leavingSoon:
Text("Leaving Soon")
.foregroundStyle(.orange)
}
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
}
Divider()
}
}
.padding(.bottom, 12)
}
if let dailySpecials = location.dailySpecials, !dailySpecials.isEmpty {
VStack(alignment: .leading) {
Text("Today's Daily Specials")
.font(.title3)
.fontWeight(.semibold)
ForEach(dailySpecials, id: \.self) { special in
HStack(alignment: .top) {
Text(special.name)
Spacer()
Text(special.type)
.foregroundStyle(.secondary)
}
Divider()
}
}
.padding(.bottom, 12)
}
VStack(alignment: .leading) {
Text("Upcoming Hours")
.font(.title3)
.fontWeight(.semibold)
ForEach(weeklyHours, id: \.self) { day in
HStack(alignment: .top) {
Text(day.day)
Spacer()
VStack {
ForEach(day.timeStrings, id: \.self) { timeString in
Text(timeString)
.foregroundStyle(.secondary)
}
}
}
Divider()
}
}
.padding(.bottom, 12)
// Ideally I'd like this text to be justified to more effectively use the screen space.
Text(location.desc)
.font(.body)
.padding(.bottom, 10)
Text("IMPORTANT: Some locations' descriptions may refer to them as being cashless during certain hours. This is outdated information, as all RIT Dining locations are now cashless 24/7.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingSafari) {
SafariView(url: URL(string: location.mapsUrl)!)
}
.refreshable {
do {
try await model.getHoursByDay()
} catch {
print(error)
}
await getOccupancy()
}
}
}

View File

@@ -0,0 +1,109 @@
//
// DonationView.swift
// TigerDine
//
// Created by Campbell on 9/17/25.
//
import SwiftUI
struct DonationView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) private var openURL
@State private var symbolDrawn: Bool = true
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 12) {
HStack {
if #available(iOS 26.0, *) {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.symbolEffect(.drawOn, isActive: symbolDrawn)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
symbolDrawn = false
}
}
} else {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
}
Text("Donate")
.fontWeight(.bold)
}
.font(.title)
Text("The TigerDine app is free and open source software!")
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text("However, the Apple Developer Program is expensive, and I paid $106.19 pretty much just to distribute this app and nothing else. If you can, I'd appreciate it if you wouldn't mind tossing a coin or two my way to help and make that expense a little less painful.")
.multilineTextAlignment(.center)
Text("No pressure though.")
.foregroundStyle(.secondary)
Button(action: {
openURL(URL(string: "https://ko-fi.com/ninjacheetah")!)
}) {
HStack(alignment: .center) {
Image("kofiLogo")
.resizable()
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading) {
Text("Tip Me on Ko-fi")
.fontWeight(.bold)
Text("Chip in as much or as little as you'd like!")
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "chevron.forward")
}
.padding(.all, 6)
.background (
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.1))
)
}
.buttonStyle(.plain)
Button(action: {
openURL(URL(string: "https://paypal.me/NinjaCheetahX")!)
}) {
HStack(alignment: .center) {
Image("paypalLogo")
.resizable()
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading) {
Text("Send Me Money Directly")
.fontWeight(.bold)
Text("I have nothing specific to say here!")
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "chevron.forward")
}
.padding(.all, 6)
.background (
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.1))
)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar {
Button(action: {
dismiss()
}) {
Image(systemName: "xmark")
}
}
}
.padding(.horizontal, 10)
}
}
#Preview {
DonationView()
}

View File

@@ -0,0 +1,105 @@
//
// FoodTruckView.swift
// TigerDine
//
// Created by Campbell on 10/5/25.
//
import SwiftUI
import SafariServices
struct FoodTruckView: View {
@State private var foodTruckEvents: [FoodTruckEvent] = []
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private func doFoodTruckStuff() async {
switch await getFoodTruckPage() {
case .success(let schedule):
foodTruckEvents = parseWeekendFoodTrucks(htmlString: schedule)
isLoading = false
case .failure(let error):
print(error)
loadFailed = true
}
}
var body: some View {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching food truck data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: "truck.box")
.resizable()
.scaledToFit()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("One moment...")
.foregroundStyle(.secondary)
}
}
.task {
await doFoodTruckStuff()
}
.padding()
} else {
ScrollView {
VStack(alignment: .leading) {
HStack {
Text("Weekend Food Trucks")
.font(.title)
.fontWeight(.semibold)
Spacer()
Button(action: {
showingSafari = true
}) {
Image(systemName: "network")
.foregroundStyle(.accent)
.font(.title3)
}
}
ForEach(foodTruckEvents, id: \.self) { event in
Divider()
Text(visitingChefDateDisplay.string(from: event.date))
.font(.title2)
.fontWeight(.semibold)
Text("\(dateDisplay.string(from: event.openTime)) - \(dateDisplay.string(from: event.closeTime))")
.font(.title3)
ForEach(event.trucks, id: \.self) { truck in
Text(truck)
}
Spacer()
}
Spacer()
Text("Food truck data is sourced directly from the RIT Events website, and may not be presented correctly. Use the button in the top right to access the RIT Events website directly to see the original source of the information.")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
.sheet(isPresented: $showingSafari) {
SafariView(url: URL(string: "https://www.rit.edu/events/weekend-food-trucks")!)
}
}
}
}

View File

@@ -0,0 +1,115 @@
//
// LocationList.swift
// TigerDine
//
// Created by Campbell on 10/1/25.
//
import SwiftUI
// This view handles the actual location list, because having it inside ContentView was too complex (both visually and for the
// type checker too, apparently).
struct LocationList: View {
@Binding var diningLocations: [DiningLocation]
@Binding var openLocationsFirst: Bool
@Binding var openLocationsOnly: Bool
@Binding var searchText: String
@Environment(Favorites.self) var favorites
// The dining locations need to be sorted before being displayed. Favorites should always be shown first, followed by non-favorites.
// Afterwards, filters the sorted list based on any current search text and the "open locations only" filtering option.
private var filteredLocations: [DiningLocation] {
var newLocations = diningLocations
// Because "The Commons" should be C for "Commons" and not T for "The".
func removeThe(_ name: String) -> String {
let lowercased = name.lowercased()
if lowercased.hasPrefix("the ") {
return String(name.dropFirst(4))
}
return name
}
newLocations.sort { firstLoc, secondLoc in
let firstLocIsFavorite = favorites.contains(firstLoc)
let secondLocIsFavorite = favorites.contains(secondLoc)
// Favorites get priority!
if firstLocIsFavorite != secondLocIsFavorite {
return firstLocIsFavorite && !secondLocIsFavorite
}
// Additional sorting rule that sorts open locations ahead of closed locations, if enabled.
if openLocationsFirst {
let firstIsOpen = (firstLoc.open == .open || firstLoc.open == .closingSoon)
let secondIsOpen = (secondLoc.open == .open || secondLoc.open == .closingSoon)
if firstIsOpen != secondIsOpen {
return firstIsOpen && !secondIsOpen
}
}
return removeThe(firstLoc.name)
.localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending
}
// Search/open only filtering step.
newLocations = newLocations.filter { location in
let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText)
let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon
return searchedLocations && openLocations
}
return newLocations
}
var body: some View {
ForEach(filteredLocations, id: \.self) { location in
NavigationLink(destination: DetailView(locationId: location.id)) {
VStack(alignment: .leading) {
HStack {
Text(location.name)
if favorites.contains(location) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
switch location.open {
case .open:
Text("Open")
.foregroundStyle(.green)
case .closed:
Text("Closed")
.foregroundStyle(.red)
case .openingSoon:
Text("Opening Soon")
.foregroundStyle(.orange)
case .closingSoon:
Text("Closing Soon")
.foregroundStyle(.orange)
}
if let times = location.diningTimes, !times.isEmpty {
ForEach(times, id: \.self) { time in
Text("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
.foregroundStyle(.secondary)
}
} else {
Text("Not Open Today")
.foregroundStyle(.secondary)
}
}
}
.swipeActions {
Button(action: {
withAnimation {
if favorites.contains(location) {
favorites.remove(location)
} else {
favorites.add(location)
}
}
}) {
if favorites.contains(location) {
Label("Unfavorite", systemImage: "star")
} else {
Label("Favorite", systemImage: "star")
}
}
.tint(favorites.contains(location) ? .yellow : nil)
}
}
}
}

View File

@@ -0,0 +1,61 @@
//
// MenuDietaryRestrictionsSheet.swift
// TigerDine
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
struct MenuDietaryRestrictionsSheet: View {
@Environment(\.dismiss) var dismiss
@ObservedObject var dietaryRestrictionsModel: MenuDietaryRestrictionsModel
var body: some View {
NavigationView {
Form {
Section(header: Text("Diet")) {
Toggle(isOn: $dietaryRestrictionsModel.noBeef) {
Text("No Beef")
}
Toggle(isOn: $dietaryRestrictionsModel.noPork) {
Text("No Pork")
}
Toggle(isOn: $dietaryRestrictionsModel.isVegetarian) {
Text("Vegetarian")
}
Toggle(isOn: $dietaryRestrictionsModel.isVegan) {
Text("Vegan")
}
}
Section(header: Text("Allergens")) {
ForEach(Allergen.allCases, id: \.self) { allergen in
Toggle(isOn: Binding(
get: {
dietaryRestrictionsModel.dietaryRestrictions.contains(allergen)
},
set: { isOn in
if isOn {
dietaryRestrictionsModel.dietaryRestrictions.add(allergen)
} else {
dietaryRestrictionsModel.dietaryRestrictions.remove(allergen)
}
}
)) {
Text(allergen.rawValue.capitalized)
}
}
}
}
.navigationTitle("Menu Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
Button(action: {
dismiss()
}) {
Image(systemName: "xmark")
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
//
// MenuItemView.swift
// TigerDine
//
// Created by Campbell on 11/6/25.
//
import SwiftUI
struct MenuItemView: View {
@State var menuItem: FDMenuItem
private var infoString: String {
// Calories SHOULD always be available, so start there.
var str = "\(menuItem.calories) Cal • "
// Price might be $0.00, so don't display it if that's the case because that's obviously wrong. RIT Dining would never give
// us free food!
if menuItem.price == 0.0 {
str += "Price Unavailable"
} else {
str += "$\(String(format: "%.2f", menuItem.price))"
}
// Same with the price, the serving size might be 0 which is also wrong so don't display that.
if menuItem.servingSize != 0 {
str += "\(menuItem.servingSize) \(menuItem.servingSizeUnit)"
}
return str
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(menuItem.name)
.font(.title)
.fontWeight(.bold)
Text(menuItem.category)
.font(.title2)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Text(infoString)
.font(.title3)
.foregroundStyle(.secondary)
HStack {
ForEach(menuItem.dietaryMarkers, id: \.self) { dietaryMarker in
Text(dietaryMarker)
.foregroundStyle(Color.white)
.font(.caption)
.padding(5)
.background(
RoundedRectangle(cornerRadius: 16)
.fill({
switch dietaryMarker {
case "Vegan", "Vegetarian":
return Color.green
default:
return Color.orange
}
}())
)
}
}
.padding(.bottom, 12)
if !menuItem.allergens.isEmpty {
Text("Allergens")
.font(.title3)
.fontWeight(.semibold)
Text(menuItem.allergens.joined(separator: ", "))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.bottom, 12)
}
VStack(alignment: .leading) {
Text("Nutrition Facts")
.font(.title3)
.fontWeight(.semibold)
ForEach(menuItem.nutritionalEntries, id: \.self) { entry in
HStack(alignment: .top) {
Text(entry.type)
Spacer()
Text("\(String(format: "%.1f", entry.amount))\(entry.unit)")
.foregroundStyle(.secondary)
}
Divider()
}
}
.padding(.bottom, 12)
Text("Ingredients")
.font(.title3)
.fontWeight(.semibold)
Text(menuItem.ingredients)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.horizontal, 8)
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
MenuItemView(
menuItem: FDMenuItem(
id: 0,
name: "Chocolate Chip Muffin",
exactName: "Muffin Chocolate Chip Thaw and Serve A; Case; 72 Ounce; 12",
category: "Baked Goods",
allergens: ["Wheat", "Gluten", "Egg", "Milk", "Soy"],
calories: 470,
nutritionalEntries: [FDNutritionalEntry(type: "Example", amount: 0.0, unit: "g")],
dietaryMarkers: ["Vegetarian"],
ingredients: "Some ingredients that you'd expect to find inside of a chocolate chip muffin",
price: 2.79,
servingSize: 1,
servingSizeUnit: "Each")
)
}

View File

@@ -0,0 +1,237 @@
//
// MenuView.swift
// TigerDine
//
// Created by Campbell on 11/3/25.
//
import SwiftUI
struct MenuView: View {
@State var accountId: Int
@State var locationId: Int
@State private var menuItems: [FDMenuItem] = []
@State private var searchText: String = ""
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var selectedMealPeriod: Int = 0
@State private var openPeriods: [Int] = []
@StateObject private var dietaryRestrictionsModel = MenuDietaryRestrictionsModel()
@State private var showingDietaryRestrictionsSheet: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
func getOpenPeriods() async {
// Only run this if we haven't already gotten the open periods. This is somewhat of a bandaid solution to the issue of
// fetching this information more than once, but hey it works!
if openPeriods.isEmpty {
switch await getFDMealPlannerOpenings(locationId: locationId) {
case .success(let openingResults):
openPeriods = openingResults.data.map { Int($0.id) }
selectedMealPeriod = openPeriods[0]
// Since this only runs once when the view first loads, we can safely use this to call the method to get the data
// the first time. This also ensures that it doesn't happen until we have the opening periods collected.
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
case .failure(let error):
print(error)
loadFailed = true
}
}
}
func getMenuForPeriod(mealPeriodId: Int) async {
switch await getFDMealPlannerMenu(locationId: locationId, accountId: accountId, mealPeriodId: mealPeriodId) {
case .success(let menus):
menuItems = parseFDMealPlannerMenu(menu: menus)
isLoading = false
case .failure(let error):
print(error)
loadFailed = true
}
}
func getPriceString(price: Double) -> String {
if price == 0.0 {
return "Price Unavailable"
} else {
return "$\(String(format: "%.2f", price))"
}
}
private var filteredMenuItems: [FDMenuItem] {
var newItems = menuItems
// Filter out allergens.
newItems = newItems.filter { item in
if !item.allergens.isEmpty {
for allergen in item.allergens {
if let checkingAllergen = Allergen(rawValue: allergen.lowercased()) {
if dietaryRestrictionsModel.dietaryRestrictions.contains(checkingAllergen) {
return false
}
}
}
}
return true
}
// Filter down to vegetarian/vegan only if enabled.
if dietaryRestrictionsModel.isVegetarian || dietaryRestrictionsModel.isVegan {
newItems = newItems.filter { item in
if dietaryRestrictionsModel.isVegetarian && (item.dietaryMarkers.contains("Vegetarian") || item.dietaryMarkers.contains("Vegan")) {
return true
} else if dietaryRestrictionsModel.isVegan && (item.dietaryMarkers.contains("Vegan")) {
return true
}
return false
}
}
// Filter out pork/beef.
if dietaryRestrictionsModel.noBeef {
newItems = newItems.filter { item in
item.dietaryMarkers.contains("Beef") == false
}
}
if dietaryRestrictionsModel.noPork {
newItems = newItems.filter { item in
item.dietaryMarkers.contains("Pork") == false
}
}
// Filter down to search contents.
newItems = newItems.filter { item in
let searchedLocations = searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
return searchedLocations
}
newItems.sort { firstItem, secondItem in
return firstItem.name.localizedCaseInsensitiveCompare(secondItem.name) == .orderedAscending
}
return newItems
}
var body: some View {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching the menu. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: "fork.knife.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("One moment...")
.foregroundStyle(.secondary)
}
}
.task {
await getOpenPeriods()
}
.padding()
} else {
VStack {
if !menuItems.isEmpty {
List {
Section {
ForEach(filteredMenuItems) { item in
NavigationLink(destination: MenuItemView(menuItem: item)) {
VStack(alignment: .leading) {
HStack {
Text(item.name)
ForEach(item.dietaryMarkers, id: \.self) { dietaryMarker in
Text(dietaryMarker)
.foregroundStyle(Color.white)
.font(.caption)
.padding(5)
.background(
RoundedRectangle(cornerRadius: 16)
.fill({
switch dietaryMarker {
case "Vegan", "Vegetarian":
return Color.green
default:
return Color.orange
}
}())
)
}
}
Text("\(item.calories) Cal • \(getPriceString(price: item.price))")
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle("Menu")
.navigationBarTitleDisplayMode(.large)
.searchable(text: $searchText, prompt: "Search")
} else {
Image(systemName: "clock.badge.exclamationmark")
.resizable()
.scaledToFit()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("No menu is available for the selected meal period today. Try selecting a different meal period.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Picker("Meal Period", selection: $selectedMealPeriod) {
ForEach(openPeriods, id: \.self) { period in
Text(fdmpMealPeriodsMap[period]!).tag(period)
}
}
} label: {
Image(systemName: "clock")
Text("Meal Periods")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Button(action: {
showingDietaryRestrictionsSheet = true
}) {
Image(systemName: "line.3.horizontal.decrease")
}
if #unavailable(iOS 26.0) {
Spacer()
}
}
if #available(iOS 26.0, *) {
ToolbarSpacer(.flexible, placement: .bottomBar)
DefaultToolbarItem(kind: .search, placement: .bottomBar)
}
}
.onChange(of: selectedMealPeriod) {
rotationDegrees = 0
isLoading = true
Task {
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
}
}
.sheet(isPresented: $showingDietaryRestrictionsSheet) {
MenuDietaryRestrictionsSheet(dietaryRestrictionsModel: dietaryRestrictionsModel)
}
}
}
}
#Preview {
MenuView(accountId: 1, locationId: 1)
}

View File

@@ -0,0 +1,143 @@
//
// VisitingChefs.swift
// TigerDine
//
// Created by Campbell on 9/8/25.
//
import SwiftUI
struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
struct VisitingChefs: View {
@Environment(DiningModel.self) var model
@State private var locationsWithChefs: [DiningLocation] = []
@State private var safariUrl: IdentifiableURL?
@State private var chefDays: [String] = []
@State private var focusedIndex: Int = 0
// Builds a list of days that each contain a list of dining locations that have visiting chefs to make displaying them
// as easy as possible.
private var locationsWithChefsByDay: [[DiningLocation]] {
var locationsWithChefsByDay = [[DiningLocation]]()
for day in model.locationsByDay {
var locationsWithChefs = [DiningLocation]()
for location in day {
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
locationsWithChefs.append(location)
}
}
locationsWithChefsByDay.append(locationsWithChefs)
}
return locationsWithChefsByDay
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Button(action: {
focusedIndex -= 1
}) {
Image(systemName: "chevron.left.circle")
.font(.title)
}
.disabled(focusedIndex == 0)
Spacer()
Text("Visiting Chefs for \(visitingChefDateDisplay.string(from: model.daysRepresented[focusedIndex]))")
.font(.title)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
Spacer()
Button(action: {
focusedIndex += 1
}) {
Image(systemName: "chevron.right.circle")
.font(.title)
}
.disabled(focusedIndex == 6)
}
if locationsWithChefsByDay[focusedIndex].isEmpty {
VStack {
Divider()
Text("No visiting chefs today")
.font(.title2)
.foregroundStyle(.secondary)
}
}
ForEach(locationsWithChefsByDay[focusedIndex], id: \.self) { location in
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack(alignment: .leading) {
Divider()
HStack(alignment: .center) {
Text(location.name)
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(action: {
safariUrl = IdentifiableURL(url: URL(string: location.mapsUrl)!)
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
}
}
ForEach(visitingChefs, id: \.self) { chef in
Spacer()
Text(chef.name)
.fontWeight(.semibold)
HStack(spacing: 3) {
if focusedIndex == 0 {
switch chef.status {
case .hereNow:
Text("Here Now")
.foregroundStyle(.green)
case .gone:
Text("Left For Today")
.foregroundStyle(.red)
case .arrivingLater:
Text("Arriving Later")
.foregroundStyle(.red)
case .arrivingSoon:
Text("Arriving Soon")
.foregroundStyle(.orange)
case .leavingSoon:
Text("Leaving Soon")
.foregroundStyle(.orange)
}
} else {
Text("Arriving on \(weekdayFromDate.string(from: model.daysRepresented[focusedIndex]))")
.foregroundStyle(.red)
}
Text("")
.foregroundStyle(.secondary)
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
Text(chef.description)
}
}
.padding(.bottom, 20)
}
}
}
.padding(.horizontal, 8)
}
.sheet(item: $safariUrl) { url in
SafariView(url: url.url)
}
.refreshable {
do {
try await model.getHoursByDay()
} catch {
print(error)
}
}
}
}
#Preview {
VisitingChefs()
}

View File

@@ -0,0 +1,148 @@
//
// VisitingChefsPush.swift
// TigerDine
//
// Created by Campbell on 10/1/25.
//
import SwiftUI
struct VisitingChefPush: View {
@AppStorage("visitingChefPushEnabled") var pushEnabled: Bool = false
@AppStorage("notificationOffset") var notificationOffset: Int = 2
@Environment(DiningModel.self) var model
@State private var pushAllowed: Bool = false
private let visitingChefs = [
"California Rollin' Sushi",
"D'Mangu",
"Esan's Kitchen",
"Halal n Out",
"just chik'n",
"KO-BQ",
"Macarollin'",
"P.H. Express",
"Tandoor of India"
]
var body: some View {
VStack {
Form {
Section(header: Text("Visiting Chef Notifications"),
footer: Text(!pushAllowed ? "You must allow notifications from TigerDine to use this feature." : "")) {
Toggle(isOn: $pushEnabled) {
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)
.onChange(of: notificationOffset) {
Task {
// If we changed the offset, we need to reschedule everything.
await model.cancelAllPushes()
await model.scheduleAllPushes()
}
}
}
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: {
model.notifyingChefs.contains(chef)
},
set: { isOn in
if isOn {
model.notifyingChefs.add(chef)
Task {
await model.schedulePushesForChef(chef)
}
} else {
model.notifyingChefs.remove(chef)
model.visitingChefPushes.cancelPushesForChef(name: chef)
}
}
)) {
Text(chef)
}
}
}
.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
}
}
.onAppear {
Task {
let center = UNUserNotificationCenter.current()
do {
try await center.requestAuthorization(options: [.alert, .sound])
} catch {
print(error)
}
let settings = await center.notificationSettings()
guard (settings.authorizationStatus == .authorized) else { pushEnabled = false; return }
pushAllowed = true
}
}
.navigationTitle("Notifications")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
VisitingChefPush()
}