Improved visiting chef and daily specials information

Reworked the detail view for locations to use the screen space more effectively and show the day's visiting chefs and daily specials, as well as the hours for the entire week.
The visiting chef screen has also been massively reworked to show all visiting chefs under what location they're in, with their times for the day and an indicator marking their status (one of: "Here Now", "Arriving Later", "Ariving Soon", "Leaving Soon", and "Left For Today"). These markers are also used in location's detail view. There's also an arrow in the top right that switches the visiting chef screen from today's visiting chefs to tomorrow's, so you can scout out what you might want to get tomorrow.
Locations are also now sorted alphabetically on the main menu, to make finding the location you're looking for easier.
This commit is contained in:
Campbell 2025-09-11 16:27:42 -04:00
parent ea2538ce18
commit 24b71b7b3f
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
34 changed files with 433 additions and 260 deletions

View File

@ -253,8 +253,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = 5GF7GKNTK4; DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -271,6 +272,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -282,8 +284,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = 5GF7GKNTK4; DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -300,6 +303,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

View File

@ -1,172 +1,10 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "notification40.png", "filename" : "RIT Dining Temp Logo.png",
"idiom" : "iphone", "idiom" : "universal",
"scale" : "2x", "platform" : "ios",
"size" : "20x20"
},
{
"filename" : "notification60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "settings58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "settings87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "spotlight80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "spotlight120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "iphone120.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "iphone180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "ipadNotification20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "ipadNotification40.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "ipadSettings29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "ipadSettings58.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "ipadSpotlight40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "ipadSpotlight80.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "ipad76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "ipad152.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "ipadPro167.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "appstore1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "mac16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "mac32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "mac32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "mac64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "mac128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "mac256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "mac256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "mac512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "mac512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "mac1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
} }
], ],
"info" : { "info" : {

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -82,7 +82,7 @@ struct ContentView: View {
} }
} }
DispatchQueue.global().sync { DispatchQueue.global().sync {
diningLocations = newDiningLocations diningLocations = newDiningLocations.sorted { $0.name < $1.name }
lastRefreshed = Date() lastRefreshed = Date()
isLoading = false isLoading = false
} }
@ -125,7 +125,7 @@ struct ContentView: View {
List { List {
if searchText.isEmpty { if searchText.isEmpty {
Section(content: { Section(content: {
NavigationLink(destination: VisitingChefs(diningLocations: diningLocations)) { NavigationLink(destination: VisitingChefs()) {
Text("Today's Visiting Chefs") Text("Today's Visiting Chefs")
} }
}) })

View File

@ -8,19 +8,6 @@
import SwiftUI import SwiftUI
import SafariServices import SafariServices
// 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) {}
}
struct DetailView: View { struct DetailView: View {
@State var location: DiningLocation @State var location: DiningLocation
@State private var isLoading: Bool = true @State private var isLoading: Bool = true
@ -111,9 +98,19 @@ struct DetailView: View {
} else { } else {
ScrollView { ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack(alignment: .center) {
Text(location.name) Text(location.name)
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
Spacer()
Button(action: {
showingSafari = true
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
.font(.title3)
}
}
Text(location.summary) Text(location.summary)
.font(.title2) .font(.title2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -151,13 +148,60 @@ struct DetailView: View {
} }
} }
} }
.padding(.bottom, 10) .padding(.bottom, 12)
Button(action: { if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
showingSafari = true VStack(alignment: .leading) {
}) { Text("Today's Visiting Chefs")
Text("View on Map") .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("\(display.string(from: chef.openTime)) - \(display.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)
} }
.padding(.bottom, 10)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("This Week's Hours") Text("This Week's Hours")
.font(.title3) .font(.title3)
@ -176,7 +220,8 @@ struct DetailView: View {
Divider() Divider()
} }
} }
.padding(.bottom, 10) .padding(.bottom, 12)
// Ideally I'd like this text to be justified to more effectively use the screen space.
Text(location.desc) Text(location.desc)
.font(.body) .font(.body)
.padding(.bottom, 10) .padding(.bottom, 10)
@ -204,5 +249,6 @@ struct DetailView: View {
mapsUrl: "https://example.com", mapsUrl: "https://example.com",
diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())], diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
open: .open, open: .open,
visitingChefs: nil)) visitingChefs: nil,
dailySpecials: nil))
} }

View File

@ -17,11 +17,7 @@ enum InvalidHTTPError: Error {
// Get information for all dining locations. // Get information for all dining locations.
func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<DiningLocationsParser, Error>) -> Void) { func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<DiningLocationsParser, Error>) -> Void) {
// 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 date_string: String = if let date { date } else { let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
Date().formatted(.iso8601
.year().month().day()
.dateSeparator(.dash))
}
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)" let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)"
guard let url = URL(string: url_string) else { guard let url = URL(string: url_string) else {
@ -51,11 +47,7 @@ func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<Dining
// 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?, locationId: Int, completionHandler: @escaping (Result<DiningLocationParser, Error>) -> Void) { func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @escaping (Result<DiningLocationParser, Error>) -> Void) {
// 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 date_string: String = if let date { date } else { let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
Date().formatted(.iso8601
.year().month().day()
.dateSeparator(.dash))
}
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)" let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)"
print("making request to \(url_string)") print("making request to \(url_string)")
@ -83,6 +75,26 @@ func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @esc
}.resume() }.resume()
} }
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 {
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) -> DiningLocation { func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
print("beginning parse for \(location.name)") print("beginning parse for \(location.name)")
@ -99,7 +111,8 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
mapsUrl: location.mapsUrl, mapsUrl: location.mapsUrl,
diningTimes: nil, diningTimes: nil,
open: .closed, open: .closed,
visitingChefs: nil) visitingChefs: nil,
dailySpecials: nil)
} }
var openStrings: [String] = [] var openStrings: [String] = []
@ -109,8 +122,20 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
// are exceptions, use those times for the day, otherwise we can just use the default times. // are exceptions, use those times for the day, otherwise we can just use the default times.
for event in location.events { for event in location.events {
if let exceptions = event.exceptions, !exceptions.isEmpty { if let exceptions = event.exceptions, !exceptions.isEmpty {
// Early return if the exception for the day specifies that the location is closed. Used for things like holidays. // Only save the exception times if the location is actually open during those times.
if !exceptions[0].open { if exceptions[0].open {
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
}
} else {
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( return DiningLocation(
id: location.id, id: location.id,
name: location.name, name: location.name,
@ -119,14 +144,8 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
mapsUrl: location.mapsUrl, mapsUrl: location.mapsUrl,
diningTimes: nil, diningTimes: nil,
open: .closed, open: .closed,
visitingChefs: nil) visitingChefs: nil,
} dailySpecials: nil)
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
} else {
openStrings.append(event.startTime)
closeStrings.append(event.endTime)
}
} }
// I hate all of this date component nonsense. // I hate all of this date component nonsense.
@ -176,17 +195,7 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
// 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. // 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 var openStatus: OpenStatus = .closed
for i in diningTimes.indices { for i in diningTimes.indices {
if now >= diningTimes[i].openTime && now <= diningTimes[i].closeTime { openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime)
if diningTimes[i].closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! {
openStatus = .closingSoon
} else {
openStatus = .open
}
} else if diningTimes[i].openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && diningTimes[i].closeTime > now {
openStatus = .openingSoon
} else {
openStatus = .closed
}
// If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to // 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. // accurately catch Gracie's multiple open periods each day.
if openStatus != .closed { if openStatus != .closed {
@ -195,20 +204,92 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
} }
// 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. // 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.
// Eventually this will parse out the times, but that's complicated because that data is formatted poorly and inconsistently and // The time formats used for visiting chefs are inconsistent and suck so that part of this code might be kind of rough. I can
// I'm not interested in messing with that quite yet. // probably make it a little better but I think most of the blame goes to TigerCenter here.
let visitingChefs: [VisitngChef]? // 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 { if !location.menus.isEmpty {
var chefs: [VisitngChef] = [] var chefs: [VisitingChef] = []
var specials: [DailySpecial] = []
for menu in location.menus { for menu in location.menus {
if menu.category == "Visiting Chef" { if menu.category == "Visiting Chef" {
print("found visiting chef: \(menu.name)") print("found visiting chef: \(menu.name)")
chefs.append(VisitngChef(name: menu.name, description: menu.description!)) var name: String = menu.name
let splitString = name.split(separator: "(", maxSplits: 1)
name = String(splitString[0])
// 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: 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: 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 visitingChefs = chefs
dailySpecials = specials
} else { } else {
visitingChefs = nil visitingChefs = nil
dailySpecials = nil
} }
return DiningLocation( return DiningLocation(
@ -219,5 +300,6 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
mapsUrl: location.mapsUrl, mapsUrl: location.mapsUrl,
diningTimes: diningTimes, diningTimes: diningTimes,
open: openStatus, open: openStatus,
visitingChefs: visitingChefs) visitingChefs: visitingChefs,
dailySpecials: dailySpecials)
} }

View File

@ -0,0 +1,32 @@
//
// SharedComponents.swift
// RIT Dining
//
// 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 getAPIFriendlyDateString(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)
}

View File

@ -62,10 +62,27 @@ struct DiningTimes: Equatable, Hashable {
var closeTime: 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 visitng chef present at a location. // A visitng chef present at a location.
struct VisitngChef: Equatable, Hashable { struct VisitingChef: Equatable, Hashable {
let name: String let name: String
let description: String let description: String
var openTime: Date
var closeTime: Date
let status: VisitingChefStatus
}
struct DailySpecial: Equatable, Hashable {
let name: String
let type: String
} }
// 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.
@ -77,5 +94,6 @@ struct DiningLocation: Identifiable, Hashable {
let mapsUrl: String let mapsUrl: String
let diningTimes: [DiningTimes]? let diningTimes: [DiningTimes]?
let open: OpenStatus let open: OpenStatus
let visitingChefs: [VisitngChef]? let visitingChefs: [VisitingChef]?
let dailySpecials: [DailySpecial]?
} }

View File

@ -7,20 +7,170 @@
import SwiftUI import SwiftUI
struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
struct VisitingChefs: View { struct VisitingChefs: View {
@State var diningLocations: [DiningLocation] @State private var diningLocations: [DiningLocation] = []
@State private var isLoading: Bool = true
@State private var rotationDegrees: Double = 0
@State private var daySwitcherRotation: Double = 0
@State private var safariUrl: IdentifiableURL?
@State private var isTomorrow: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private let display: DateFormatter = {
let display = DateFormatter()
display.timeZone = TimeZone(identifier: "America/New_York")
display.dateStyle = .none
display.timeStyle = .short
return display
}()
// Asynchronously fetch the data for all of the locations on the given date (only ever today or tomorrow) to get the visiting chef
// information.
private func getDiningDataForDate(date: String) {
var newDiningLocations: [DiningLocation] = []
getAllDiningInfo(date: date) { result in
DispatchQueue.global().async {
switch result {
case .success(let locations):
for i in 0..<locations.locations.count {
let diningInfo = parseLocationInfo(location: locations.locations[i])
print(diningInfo.name)
DispatchQueue.global().sync {
newDiningLocations.append(diningInfo)
}
}
DispatchQueue.global().sync {
diningLocations = newDiningLocations
isLoading = false
}
case .failure(let error): print(error)
}
}
}
}
private func getDiningData() {
isLoading = true
let dateString: String
if !isTomorrow {
dateString = getAPIFriendlyDateString(date: Date())
print("default really really really long string to make this line more obvious for debugging: \(dateString)")
} else {
let calendar = Calendar.current
let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date())!
dateString = getAPIFriendlyDateString(date: tomorrow)
print("really really really long string to make this line more obvious for debugging: \(dateString)")
}
getDiningDataForDate(date: dateString)
}
var body: some View { var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack(alignment: .center) {
if !isTomorrow {
Text("Today's Visiting Chefs")
.font(.title)
.fontWeight(.bold)
} else {
Text("Tomorrow's Visiting Chefs")
.font(.title)
.fontWeight(.bold)
}
Spacer()
Button(action: {
withAnimation(Animation.linear.speed(1.5)) {
if isTomorrow {
daySwitcherRotation = 0.0
} else {
daySwitcherRotation = 180.0
}
}
isTomorrow.toggle()
getDiningData()
}) {
Image(systemName: "chevron.right.circle")
.rotationEffect(.degrees(daySwitcherRotation))
.font(.title)
}
}
if isLoading {
VStack { VStack {
Image(systemName: "fork.knife.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
rotationDegrees = 0.0
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("Loading...")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 25)
} else {
ForEach(diningLocations, id: \.self) { location in ForEach(diningLocations, id: \.self) { location in
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty { if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack { VStack(alignment: .leading) {
Divider()
HStack(alignment: .center) {
Text(location.name) Text(location.name)
.font(.title2) .font(.title2)
.fontWeight(.semibold) .fontWeight(.semibold)
Spacer()
Button(action: {
safariUrl = IdentifiableURL(url: URL(string: location.mapsUrl)!)
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
}
}
ForEach(visitingChefs, id: \.self) { chef in ForEach(visitingChefs, id: \.self) { chef in
Spacer()
Text(chef.name) Text(chef.name)
.fontWeight(.semibold) .fontWeight(.semibold)
HStack(spacing: 3) {
if !isTomorrow {
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 Tomorrow")
.foregroundStyle(.red)
}
Text("")
.foregroundStyle(.secondary)
Text("\(display.string(from: chef.openTime)) - \(display.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
Text(chef.description) Text(chef.description)
} }
} }
@ -28,20 +178,23 @@ struct VisitingChefs: View {
} }
} }
} }
}
.padding(.horizontal, 8)
}
.navigationTitle("Visiting Chefs") .navigationTitle("Visiting Chefs")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(item: $safariUrl) { url in
SafariView(url: url.url)
}
.onAppear {
getDiningData()
}
.refreshable {
getDiningData()
}
} }
} }
#Preview { #Preview {
VisitingChefs( VisitingChefs()
diningLocations: [DiningLocation(
id: 0,
name: "Example",
summary: "A Place",
desc: "A long description of the place",
mapsUrl: "https://example.com",
diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
open: .open,
visitingChefs: [VisitngChef(name: "Example Chef (1-2 p.m.)", description: "Serves example food")])])
} }