diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 3a2c362..f866f12 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -253,8 +253,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -271,6 +272,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -282,8 +284,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -300,6 +303,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/Contents.json b/RIT Dining/Assets.xcassets/AppIcon.appiconset/Contents.json index dfcf466..ee0fc91 100644 --- a/RIT Dining/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/RIT Dining/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,172 +1,10 @@ { "images" : [ { - "filename" : "notification40.png", - "idiom" : "iphone", - "scale" : "2x", - "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", + "filename" : "RIT Dining Temp Logo.png", + "idiom" : "universal", + "platform" : "ios", "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" : { diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/RIT Dining Temp Logo.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/RIT Dining Temp Logo.png new file mode 100644 index 0000000..b5eb116 Binary files /dev/null and b/RIT Dining/Assets.xcassets/AppIcon.appiconset/RIT Dining Temp Logo.png differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/appstore1024.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/appstore1024.png deleted file mode 100644 index ef6c552..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/appstore1024.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad152.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad152.png deleted file mode 100644 index 696a92d..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad152.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad76.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad76.png deleted file mode 100644 index e1fb056..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad76.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification20.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification20.png deleted file mode 100644 index 667c9a7..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification20.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification40.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification40.png deleted file mode 100644 index 42a2a8d..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadNotification40.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadPro167.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadPro167.png deleted file mode 100644 index c0c586a..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadPro167.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png deleted file mode 100644 index 0efc679..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png deleted file mode 100644 index fcae781..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight40.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight40.png deleted file mode 100644 index 42a2a8d..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight40.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight80.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight80.png deleted file mode 100644 index 156d674..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSpotlight80.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone120.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone120.png deleted file mode 100644 index 4b7f247..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone120.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone180.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone180.png deleted file mode 100644 index 06eaf6b..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone180.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac1024.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac1024.png deleted file mode 100644 index 2361dc3..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac1024.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac128.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac128.png deleted file mode 100644 index efbab44..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac128.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac16.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac16.png deleted file mode 100644 index 2c75532..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac16.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac256.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac256.png deleted file mode 100644 index 8bc73e6..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac256.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac32.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac32.png deleted file mode 100644 index 61bf53f..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac32.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac512.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac512.png deleted file mode 100644 index ae91c24..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac512.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac64.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac64.png deleted file mode 100644 index c6c8aed..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/mac64.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification40.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification40.png deleted file mode 100644 index 42a2a8d..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification40.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification60.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification60.png deleted file mode 100644 index 7e2c1be..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/notification60.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings58.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings58.png deleted file mode 100644 index fcae781..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings58.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings87.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings87.png deleted file mode 100644 index f4bcf7a..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/settings87.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight120.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight120.png deleted file mode 100644 index 4b7f247..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight120.png and /dev/null differ diff --git a/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight80.png b/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight80.png deleted file mode 100644 index 156d674..0000000 Binary files a/RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight80.png and /dev/null differ diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index bc38eb4..842c94d 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -82,7 +82,7 @@ struct ContentView: View { } } DispatchQueue.global().sync { - diningLocations = newDiningLocations + diningLocations = newDiningLocations.sorted { $0.name < $1.name } lastRefreshed = Date() isLoading = false } @@ -125,7 +125,7 @@ struct ContentView: View { List { if searchText.isEmpty { Section(content: { - NavigationLink(destination: VisitingChefs(diningLocations: diningLocations)) { + NavigationLink(destination: VisitingChefs()) { Text("Today's Visiting Chefs") } }) diff --git a/RIT Dining/DetailView.swift b/RIT Dining/DetailView.swift index 26a6345..1f7d9a0 100644 --- a/RIT Dining/DetailView.swift +++ b/RIT Dining/DetailView.swift @@ -8,19 +8,6 @@ import SwiftUI 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 { @State var location: DiningLocation @State private var isLoading: Bool = true @@ -111,9 +98,19 @@ struct DetailView: View { } else { ScrollView { VStack(alignment: .leading) { - Text(location.name) - .font(.title) - .fontWeight(.bold) + HStack(alignment: .center) { + Text(location.name) + .font(.title) + .fontWeight(.bold) + Spacer() + Button(action: { + showingSafari = true + }) { + Image(systemName: "map") + .foregroundStyle(.accent) + .font(.title3) + } + } Text(location.summary) .font(.title2) .foregroundStyle(.secondary) @@ -151,13 +148,60 @@ struct DetailView: View { } } } - .padding(.bottom, 10) - Button(action: { - showingSafari = true - }) { - Text("View on Map") + .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("\(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) { Text("This Week's Hours") .font(.title3) @@ -176,7 +220,8 @@ struct DetailView: View { 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) .font(.body) .padding(.bottom, 10) @@ -204,5 +249,6 @@ struct DetailView: View { mapsUrl: "https://example.com", diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())], open: .open, - visitingChefs: nil)) + visitingChefs: nil, + dailySpecials: nil)) } diff --git a/RIT Dining/FetchData.swift b/RIT Dining/FetchData.swift index 174c9f8..30bb2c1 100644 --- a/RIT Dining/FetchData.swift +++ b/RIT Dining/FetchData.swift @@ -17,11 +17,7 @@ enum InvalidHTTPError: Error { // Get information for all dining locations. func getAllDiningInfo(date: String?, completionHandler: @escaping (Result) -> Void) { // The endpoint requires that you specify a date, so get today's. - let date_string: String = if let date { date } else { - Date().formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) - } + let date_string: String = date ?? getAPIFriendlyDateString(date: Date()) let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)" guard let url = URL(string: url_string) else { @@ -51,11 +47,7 @@ func getAllDiningInfo(date: String?, completionHandler: @escaping (Result) -> Void) { // 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 { - Date().formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) - } + let date_string: String = date ?? getAPIFriendlyDateString(date: Date()) let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)" print("making request to \(url_string)") @@ -83,6 +75,26 @@ func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @esc }.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 { print("beginning parse for \(location.name)") @@ -99,7 +111,8 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { mapsUrl: location.mapsUrl, diningTimes: nil, open: .closed, - visitingChefs: nil) + visitingChefs: nil, + dailySpecials: nil) } var openStrings: [String] = [] @@ -109,26 +122,32 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { // are exceptions, use those times for the day, otherwise we can just use the default times. for event in location.events { 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. - if !exceptions[0].open { - return DiningLocation( - id: location.id, - name: location.name, - summary: location.summary, - desc: desc, - mapsUrl: location.mapsUrl, - diningTimes: nil, - open: .closed, - visitingChefs: nil) + // Only save the exception times if the location is actually open during those times. + if exceptions[0].open { + openStrings.append(exceptions[0].startTime) + closeStrings.append(exceptions[0].endTime) } - 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( + id: location.id, + name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, + diningTimes: nil, + open: .closed, + visitingChefs: nil, + dailySpecials: nil) + } + // I hate all of this date component nonsense. var openDates: [Date] = [] var closeDates: [Date] = [] @@ -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. var openStatus: OpenStatus = .closed for i in diningTimes.indices { - if now >= diningTimes[i].openTime && now <= 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 - } + 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 { @@ -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. - // Eventually this will parse out the times, but that's complicated because that data is formatted poorly and inconsistently and - // I'm not interested in messing with that quite yet. - let visitingChefs: [VisitngChef]? + // 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: [VisitngChef] = [] + var chefs: [VisitingChef] = [] + var specials: [DailySpecial] = [] for menu in location.menus { if menu.category == "Visiting Chef" { 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 + dailySpecials = specials } else { visitingChefs = nil + dailySpecials = nil } return DiningLocation( @@ -219,5 +300,6 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { mapsUrl: location.mapsUrl, diningTimes: diningTimes, open: openStatus, - visitingChefs: visitingChefs) + visitingChefs: visitingChefs, + dailySpecials: dailySpecials) } diff --git a/RIT Dining/SharedComponents.swift b/RIT Dining/SharedComponents.swift new file mode 100644 index 0000000..fa36213 --- /dev/null +++ b/RIT Dining/SharedComponents.swift @@ -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) +} diff --git a/RIT Dining/Types.swift b/RIT Dining/Types.swift index 665babe..bbfec52 100644 --- a/RIT Dining/Types.swift +++ b/RIT Dining/Types.swift @@ -62,10 +62,27 @@ struct DiningTimes: Equatable, Hashable { 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. -struct VisitngChef: Equatable, Hashable { +struct VisitingChef: Equatable, Hashable { let name: 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. @@ -77,5 +94,6 @@ struct DiningLocation: Identifiable, Hashable { let mapsUrl: String let diningTimes: [DiningTimes]? let open: OpenStatus - let visitingChefs: [VisitngChef]? + let visitingChefs: [VisitingChef]? + let dailySpecials: [DailySpecial]? } diff --git a/RIT Dining/VisitingChefs.swift b/RIT Dining/VisitingChefs.swift index a21456a..70a0bfe 100644 --- a/RIT Dining/VisitingChefs.swift +++ b/RIT Dining/VisitingChefs.swift @@ -7,41 +7,194 @@ import SwiftUI +struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} + 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 - var body: some View { - VStack { - ForEach(diningLocations, id: \.self) { location in - if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty { - VStack { - Text(location.name) - .font(.title2) - .fontWeight(.semibold) - ForEach(visitingChefs, id: \.self) { chef in - Text(chef.name) - .fontWeight(.semibold) - Text(chef.description) + 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..