mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-04 21:25:27 -05:00
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:
BIN
TigerDine/AppIcon.icon/Assets/TigerDine Temp Logo Emblem.png
Normal file
BIN
TigerDine/AppIcon.icon/Assets/TigerDine Temp Logo Emblem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
37
TigerDine/AppIcon.icon/icon.json
Normal file
37
TigerDine/AppIcon.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
TigerDine/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
TigerDine/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 |
6
TigerDine/Assets.xcassets/Contents.json
Normal file
6
TigerDine/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
22
TigerDine/Assets.xcassets/Icon.imageset/Contents.json
vendored
Normal file
22
TigerDine/Assets.xcassets/Icon.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
TigerDine/Assets.xcassets/Icon.imageset/TigerDine Temp Logo-iOS-Dark-1024x1024@1x.png
vendored
Normal file
BIN
TigerDine/Assets.xcassets/Icon.imageset/TigerDine Temp Logo-iOS-Dark-1024x1024@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 784 KiB |
BIN
TigerDine/Assets.xcassets/Icon.imageset/TigerDine Temp Logo-iOS-Default-1024x1024@1x.png
vendored
Normal file
BIN
TigerDine/Assets.xcassets/Icon.imageset/TigerDine Temp Logo-iOS-Default-1024x1024@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
12
TigerDine/Assets.xcassets/kofiLogo.imageset/Contents.json
vendored
Normal file
12
TigerDine/Assets.xcassets/kofiLogo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "kofiLogo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
TigerDine/Assets.xcassets/kofiLogo.imageset/kofiLogo.png
vendored
Normal file
BIN
TigerDine/Assets.xcassets/kofiLogo.imageset/kofiLogo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
12
TigerDine/Assets.xcassets/paypalLogo.imageset/Contents.json
vendored
Normal file
12
TigerDine/Assets.xcassets/paypalLogo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "paypalLogo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
TigerDine/Assets.xcassets/paypalLogo.imageset/paypalLogo.png
vendored
Normal file
BIN
TigerDine/Assets.xcassets/paypalLogo.imageset/paypalLogo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
75
TigerDine/Components/FDMealPlannerParsers.swift
Normal file
75
TigerDine/Components/FDMealPlannerParsers.swift
Normal 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
|
||||
}
|
||||
97
TigerDine/Components/FoodTruckParsers.swift
Normal file
97
TigerDine/Components/FoodTruckParsers.swift
Normal 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 []
|
||||
}
|
||||
}
|
||||
60
TigerDine/Components/PushScheduler.swift
Normal file
60
TigerDine/Components/PushScheduler.swift
Normal 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)")
|
||||
}
|
||||
187
TigerDine/Components/Requests.swift
Normal file
187
TigerDine/Components/Requests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
69
TigerDine/Components/SharedComponents.swift
Normal file
69
TigerDine/Components/SharedComponents.swift
Normal 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) }
|
||||
}
|
||||
316
TigerDine/Components/TigerCenterParsers.swift
Normal file
316
TigerDine/Components/TigerCenterParsers.swift
Normal 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
194
TigerDine/ContentView.swift
Normal 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()
|
||||
}
|
||||
51
TigerDine/Data/DietaryRestrictions.swift
Normal file
51
TigerDine/Data/DietaryRestrictions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
117
TigerDine/Data/DiningModel.swift
Normal file
117
TigerDine/Data/DiningModel.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
TigerDine/Data/Favorites.swift
Normal file
38
TigerDine/Data/Favorites.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
37
TigerDine/Data/MenuDietaryRestrictionsModel.swift
Normal file
37
TigerDine/Data/MenuDietaryRestrictionsModel.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
38
TigerDine/Data/NotifyingChefs.swift
Normal file
38
TigerDine/Data/NotifyingChefs.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
86
TigerDine/Data/PushesModel.swift
Normal file
86
TigerDine/Data/PushesModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
16
TigerDine/Data/Static/FDMPMealPeriods.swift
Normal file
16
TigerDine/Data/Static/FDMPMealPeriods.swift
Normal 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",
|
||||
]
|
||||
25
TigerDine/Data/Static/TCtoFDMPMap.swift
Normal file
25
TigerDine/Data/Static/TCtoFDMPMap.swift
Normal 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
|
||||
]
|
||||
124
TigerDine/Data/Types/FDMealPlannerTypes.swift
Normal file
124
TigerDine/Data/Types/FDMealPlannerTypes.swift
Normal 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
|
||||
}
|
||||
18
TigerDine/Data/Types/FoodTruckTypes.swift
Normal file
18
TigerDine/Data/Types/FoodTruckTypes.swift
Normal 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]
|
||||
}
|
||||
|
||||
17
TigerDine/Data/Types/PushTypes.swift
Normal file
17
TigerDine/Data/Types/PushTypes.swift
Normal 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
|
||||
}
|
||||
136
TigerDine/Data/Types/TigerCenterTypes.swift
Normal file
136
TigerDine/Data/Types/TigerCenterTypes.swift
Normal 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]
|
||||
}
|
||||
5
TigerDine/TigerDine.entitlements
Normal file
5
TigerDine/TigerDine.entitlements
Normal 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>
|
||||
17
TigerDine/TigerDineApp.swift
Normal file
17
TigerDine/TigerDineApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
70
TigerDine/Views/AboutView.swift
Normal file
70
TigerDine/Views/AboutView.swift
Normal 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()
|
||||
}
|
||||
285
TigerDine/Views/DetailView.swift
Normal file
285
TigerDine/Views/DetailView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
109
TigerDine/Views/DonationView.swift
Normal file
109
TigerDine/Views/DonationView.swift
Normal 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()
|
||||
}
|
||||
105
TigerDine/Views/FoodTruckView.swift
Normal file
105
TigerDine/Views/FoodTruckView.swift
Normal 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")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
TigerDine/Views/LocationList.swift
Normal file
115
TigerDine/Views/LocationList.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
TigerDine/Views/Menus/MenuDietaryRestrictionsSheet.swift
Normal file
61
TigerDine/Views/Menus/MenuDietaryRestrictionsSheet.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
TigerDine/Views/Menus/MenuItemView.swift
Normal file
117
TigerDine/Views/Menus/MenuItemView.swift
Normal 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")
|
||||
)
|
||||
}
|
||||
237
TigerDine/Views/Menus/MenuView.swift
Normal file
237
TigerDine/Views/Menus/MenuView.swift
Normal 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)
|
||||
}
|
||||
143
TigerDine/Views/Visiting Chefs/VisitingChefs.swift
Normal file
143
TigerDine/Views/Visiting Chefs/VisitingChefs.swift
Normal 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()
|
||||
}
|
||||
148
TigerDine/Views/Visiting Chefs/VisitingChefsPush.swift
Normal file
148
TigerDine/Views/Visiting Chefs/VisitingChefsPush.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user