mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-12-02 01:21:35 -05:00
Added dining location menus from FD MealPlanner API
Dining locations that also exist on FD MealPlanner now have a "View Menu" button under the favorite/OnDemand/map buttons that take you to a new view that pulls the location's menu from FD MealPlanner. You can view all of the menu items that have actually been added to that site, and tap them for more details (which will be expanded on later). Searching the item list is supported, with more filtering options coming in the next update. Meal periods can be browsed using the clock button in the top right for locations that are open more than once per day. Other changes: - App renamed from "RIT Dining" to "TigerDine" to not get me in trouble for an App Store release - Slightly changed the way that dining locations' short descriptions and current open times are displayed in the detail view - Fixed the box truck icon used in the food truck view being squished
This commit is contained in:
parent
2c512180d9
commit
c7639de06b
@ -265,11 +265,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "RIT Dining";
|
INFOPLIST_KEY_CFBundleDisplayName = TigerDine;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@ -300,11 +300,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "RIT Dining";
|
INFOPLIST_KEY_CFBundleDisplayName = TigerDine;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
|||||||
52
RIT Dining/Components/FDMealPlannerParsers.swift
Normal file
52
RIT Dining/Components/FDMealPlannerParsers.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// FDMealPlannerParsers.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
// 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
|
||||||
|
} else {
|
||||||
|
recipe.componentName
|
||||||
|
}
|
||||||
|
let allergens = 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())
|
||||||
|
|
||||||
|
let newItem = FDMenuItem(
|
||||||
|
id: recipe.componentId,
|
||||||
|
name: realName,
|
||||||
|
exactName: recipe.componentName,
|
||||||
|
category: recipe.category,
|
||||||
|
allergens: allergens,
|
||||||
|
calories: calories,
|
||||||
|
dietaryMarkers: dietaryMarkers,
|
||||||
|
ingredients: recipe.ingredientStatement,
|
||||||
|
price: recipe.sellingPrice,
|
||||||
|
servingSize: recipe.productMeasuringSize,
|
||||||
|
servingSizeUnit: recipe.productMeasuringSizeUnit
|
||||||
|
)
|
||||||
|
menuItems.append(newItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return menuItems
|
||||||
|
}
|
||||||
97
RIT Dining/Components/FoodTruckParsers.swift
Normal file
97
RIT Dining/Components/FoodTruckParsers.swift
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// FoodTruckParsers.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ enum InvalidHTTPError: Error {
|
|||||||
// Get information for all dining locations.
|
// Get information for all dining locations.
|
||||||
func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Error> {
|
func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Error> {
|
||||||
// The endpoint requires that you specify a date, so get today's.
|
// The endpoint requires that you specify a date, so get today's.
|
||||||
let dateString: String = date ?? getAPIFriendlyDateString(date: Date())
|
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
|
||||||
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(dateString)"
|
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(dateString)"
|
||||||
|
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
@ -41,7 +41,7 @@ func getAllDiningInfo(date: String?) async -> Result<DiningLocationsParser, Erro
|
|||||||
// Get information for just one dining location based on its location ID.
|
// Get information for just one dining location based on its location ID.
|
||||||
func getSingleDiningInfo(date: String?, locId: Int) async -> Result<DiningLocationParser, Error> {
|
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.
|
// The current date and the location ID are required to get information for just one location.
|
||||||
let dateString: String = date ?? getAPIFriendlyDateString(date: Date())
|
let dateString: String = date ?? getTCAPIFriendlyDateString(date: Date())
|
||||||
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(dateString)&locId=\(locId)"
|
let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(dateString)&locId=\(locId)"
|
||||||
print("making request to \(urlString)")
|
print("making request to \(urlString)")
|
||||||
|
|
||||||
@ -97,6 +97,7 @@ func getOccupancyPercentage(mdoId: Int) async -> Result<Double, 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> {
|
func getFoodTruckPage() async -> Result<String, Error> {
|
||||||
let urlString = "https://www.rit.edu/events/weekend-food-trucks"
|
let urlString = "https://www.rit.edu/events/weekend-food-trucks"
|
||||||
|
|
||||||
@ -115,3 +116,72 @@ func getFoodTruckPage() async -> Result<String, Error> {
|
|||||||
return .failure(error)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ struct SafariView: UIViewControllerRepresentable {
|
|||||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAPIFriendlyDateString(date: Date) -> String {
|
func getTCAPIFriendlyDateString(date: Date) -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.calendar = Calendar(identifier: .iso8601)
|
formatter.calendar = Calendar(identifier: .iso8601)
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
@ -31,6 +31,15 @@ func getAPIFriendlyDateString(date: Date) -> String {
|
|||||||
return formatter.string(from: date)
|
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.
|
// The common date formatter that I'm using everywhere that open periods are shown within the app.
|
||||||
let dateDisplay: DateFormatter = {
|
let dateDisplay: DateFormatter = {
|
||||||
let display = DateFormatter()
|
let display = DateFormatter()
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
//
|
//
|
||||||
// Parsers.swift
|
// TigerCenterParsers.swift
|
||||||
// RIT Dining
|
// RIT Dining
|
||||||
//
|
//
|
||||||
// Created by Campbell on 9/19/25.
|
// Created by Campbell on 9/19/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftSoup
|
|
||||||
|
|
||||||
func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus {
|
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
|
// 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
|
||||||
@ -38,11 +37,22 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
|
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
|
||||||
let desc = location.description.replacingOccurrences(of: "<br />", with: "")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week.
|
// Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week.
|
||||||
if location.events.isEmpty {
|
if location.events.isEmpty {
|
||||||
return DiningLocation(
|
return DiningLocation(
|
||||||
id: location.id,
|
id: location.id,
|
||||||
mdoId: location.mdoId,
|
mdoId: location.mdoId,
|
||||||
|
fdmpIds: fdmpIds,
|
||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
@ -88,6 +98,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
return DiningLocation(
|
return DiningLocation(
|
||||||
id: location.id,
|
id: location.id,
|
||||||
mdoId: location.mdoId,
|
mdoId: location.mdoId,
|
||||||
|
fdmpIds: fdmpIds,
|
||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
@ -246,6 +257,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
return DiningLocation(
|
return DiningLocation(
|
||||||
id: location.id,
|
id: location.id,
|
||||||
mdoId: location.mdoId,
|
mdoId: location.mdoId,
|
||||||
|
fdmpIds: fdmpIds,
|
||||||
name: location.name,
|
name: location.name,
|
||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
@ -298,91 +310,3 @@ extension DiningLocation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -120,7 +120,7 @@ struct ContentView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("RIT Dining")
|
.navigationTitle("TigerDine")
|
||||||
.searchable(text: $searchText, prompt: "Search")
|
.searchable(text: $searchText, prompt: "Search")
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await getDiningData()
|
await getDiningData()
|
||||||
@ -135,6 +135,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
|
// This is commented out because this feature is still not done. Sorry!
|
||||||
// NavigationLink(destination: VisitingChefPush()) {
|
// NavigationLink(destination: VisitingChefPush()) {
|
||||||
// Image(systemName: "bell.badge")
|
// Image(systemName: "bell.badge")
|
||||||
// .foregroundColor(.accentColor)
|
// .foregroundColor(.accentColor)
|
||||||
|
|||||||
16
RIT Dining/Data/Static/FDMPMealPeriods.swift
Normal file
16
RIT Dining/Data/Static/FDMPMealPeriods.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// FDMPMealPeriods.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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
RIT Dining/Data/Static/TCtoFDMPMap.swift
Normal file
25
RIT Dining/Data/Static/TCtoFDMPMap.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// TCtoFDMPMap.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
9041: (18, 17), // The College Grind
|
||||||
|
24: (15, 14), // The Commons
|
||||||
|
]
|
||||||
90
RIT Dining/Data/Types/FDMealPlannerTypes.swift
Normal file
90
RIT Dining/Data/Types/FDMealPlannerTypes.swift
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// FDMealPlannerTypes.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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 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 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 dietaryMarkers: [String]
|
||||||
|
let ingredients: String
|
||||||
|
let price: Double
|
||||||
|
let servingSize: Int
|
||||||
|
let servingSizeUnit: String
|
||||||
|
}
|
||||||
18
RIT Dining/Data/Types/FoodTruckTypes.swift
Normal file
18
RIT Dining/Data/Types/FoodTruckTypes.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// FoodTruckTypes.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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]
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// Types.swift
|
// TigerCenterTypes.swift
|
||||||
// RIT Dining
|
// RIT Dining
|
||||||
//
|
//
|
||||||
// Created by Campbell on 9/2/25.
|
// Created by Campbell on 9/2/25.
|
||||||
@ -88,10 +88,17 @@ struct DailySpecial: Equatable, Hashable {
|
|||||||
let type: 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.
|
// The basic information about a dining location needed to display it in the app after parsing is finished.
|
||||||
struct DiningLocation: Identifiable, Hashable {
|
struct DiningLocation: Identifiable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let mdoId: Int
|
let mdoId: Int
|
||||||
|
let fdmpIds: FDMPIds?
|
||||||
let name: String
|
let name: String
|
||||||
let summary: String
|
let summary: String
|
||||||
let desc: String
|
let desc: String
|
||||||
@ -129,12 +136,3 @@ struct WeeklyHours: Hashable {
|
|||||||
let date: Date
|
let date: Date
|
||||||
let timeStrings: [String]
|
let timeStrings: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
@ -18,8 +18,10 @@ struct AboutView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 128, height: 128)
|
.frame(width: 128, height: 128)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
Text("An RIT Dining App")
|
Text("TigerDine")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
|
Text("An unofficial RIT Dining app")
|
||||||
|
.font(.subheadline)
|
||||||
Text("Version \(appVersionString) (\(buildNumber))")
|
Text("Version \(appVersionString) (\(buildNumber))")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|||||||
@ -14,7 +14,6 @@ struct DetailView: View {
|
|||||||
@Environment(DiningModel.self) var model
|
@Environment(DiningModel.self) var model
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var showingSafari: Bool = false
|
@State private var showingSafari: Bool = false
|
||||||
@State private var openString: String = ""
|
|
||||||
@State private var occupancyLoading: Bool = true
|
@State private var occupancyLoading: Bool = true
|
||||||
@State private var occupancyPercentage: Double = 0.0
|
@State private var occupancyPercentage: Double = 0.0
|
||||||
|
|
||||||
@ -80,98 +79,108 @@ struct DetailView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Text(location.name)
|
VStack(alignment: .leading) {
|
||||||
.font(.title)
|
Text(location.name)
|
||||||
.fontWeight(.bold)
|
.font(.title)
|
||||||
Spacer()
|
.fontWeight(.bold)
|
||||||
Button(action: {
|
Text(location.summary)
|
||||||
if favorites.contains(location) {
|
.font(.title2)
|
||||||
favorites.remove(location)
|
.fontWeight(.semibold)
|
||||||
} else {
|
.foregroundStyle(.secondary)
|
||||||
favorites.add(location)
|
VStack(alignment: .leading) {
|
||||||
}
|
switch location.open {
|
||||||
}) {
|
case .open:
|
||||||
if favorites.contains(location) {
|
Text("Open")
|
||||||
Image(systemName: "star.fill")
|
.font(.title3)
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.green)
|
||||||
.font(.title3)
|
case .closed:
|
||||||
} else {
|
Text("Closed")
|
||||||
Image(systemName: "star")
|
.font(.title3)
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.red)
|
||||||
.font(.title3)
|
case .openingSoon:
|
||||||
}
|
Text("Opening Soon")
|
||||||
}
|
.font(.title3)
|
||||||
// Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page.
|
.foregroundStyle(.orange)
|
||||||
Button(action: {
|
case .closingSoon:
|
||||||
openURL(URL(string: "https://ondemand.rit.edu")!)
|
Text("Closing Soon")
|
||||||
}) {
|
.font(.title3)
|
||||||
Image(systemName: "cart")
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
.font(.title3)
|
if let times = location.diningTimes, !times.isEmpty {
|
||||||
}
|
ForEach(times, id: \.self) { time in
|
||||||
.disabled(location.open == .closed || location.open == .openingSoon)
|
Text("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
|
||||||
Button(action: {
|
.foregroundStyle(.secondary)
|
||||||
showingSafari = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "map")
|
|
||||||
.font(.title3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(location.summary)
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
HStack(alignment: .top, spacing: 3) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
Text("•")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
VStack {
|
|
||||||
if let times = location.diningTimes, !times.isEmpty {
|
|
||||||
Text(openString)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.onAppear {
|
|
||||||
openString = ""
|
|
||||||
for time in times {
|
|
||||||
openString += "\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime)), "
|
|
||||||
}
|
|
||||||
openString = String(openString.prefix(openString.count - 2))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Not Open Today")
|
Text("Not Open Today")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ struct FoodTruckView: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(systemName: "truck.box")
|
Image(systemName: "truck.box")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
.frame(width: 75, height: 75)
|
.frame(width: 75, height: 75)
|
||||||
.foregroundStyle(.accent)
|
.foregroundStyle(.accent)
|
||||||
.rotationEffect(.degrees(rotationDegrees))
|
.rotationEffect(.degrees(rotationDegrees))
|
||||||
|
|||||||
97
RIT Dining/Views/MenuItemView.swift
Normal file
97
RIT Dining/Views/MenuItemView.swift
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// MenuItemView.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Allergens")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.top, 8)
|
||||||
|
Text(menuItem.allergens.joined(separator: ", "))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
Text("Ingredients")
|
||||||
|
.font(.headline)
|
||||||
|
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,
|
||||||
|
dietaryMarkers: ["Vegetarian"],
|
||||||
|
ingredients: "Some ingredients that you'd expect to find inside of a chocolate chip muffin",
|
||||||
|
price: 2.79,
|
||||||
|
servingSize: 1,
|
||||||
|
servingSizeUnit: "Each")
|
||||||
|
)
|
||||||
|
}
|
||||||
178
RIT Dining/Views/MenuView.swift
Normal file
178
RIT Dining/Views/MenuView.swift
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
//
|
||||||
|
// MenuView.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// 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] = []
|
||||||
|
|
||||||
|
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
|
||||||
|
newItems = newItems.filter { item in
|
||||||
|
let searchedLocations = searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
|
||||||
|
return searchedLocations
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selectedMealPeriod) {
|
||||||
|
rotationDegrees = 0
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MenuView(accountId: 1, locationId: 1)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user