diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index a1ac79f..3a2c362 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -254,7 +254,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -283,7 +283,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index ad66957..bc38eb4 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -70,12 +70,12 @@ struct ContentView: View { // Asynchronously fetch the data for all of the locations and parse their data to display it. private func getDiningData() { var newDiningLocations: [DiningLocation] = [] - getAllDiningInfo { result in + getAllDiningInfo(date: nil) { result in DispatchQueue.global().async { switch result { case .success(let locations): for i in 0..) -> Void { + switch result { + case .success(let location): + let diningInfo = parseLocationInfo(location: location) + if let times = diningInfo.diningTimes, !times.isEmpty { + var timeStrings: [String] = [] + for time in times { + timeStrings.append("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))") + } + weeklyHours.append(timeStrings) + } else { + weeklyHours.append(["Closed"]) + } + case .failure(let error): + print(error) + } + if week.count > 0 { + DispatchQueue.global().async { + let date_string = week.removeFirst().formatted(.iso8601 + .year().month().day() + .dateSeparator(.dash)) + getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone) + } + } else { + isLoading = false + print(weeklyHours) + } + } + + private func getWeeklyHours() { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let dayOfWeek = calendar.component(.weekday, from: today) + week = calendar.range(of: .weekday, in: .weekOfYear, for: today)! + .compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) } + DispatchQueue.global().async { + let date_string = week.removeFirst().formatted(.iso8601 + .year().month().day() + .dateSeparator(.dash)) + getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone) + } + } + var body: some View { - ScrollView { - VStack(alignment: .leading) { - Text(location.name) - .font(.title) - Text(location.summary) - .font(.title2) - .foregroundStyle(.secondary) - HStack(alignment: .top) { - 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) - } - VStack { - if let times = location.diningTimes, !times.isEmpty { - ForEach(times, id: \.self) { time in - Text("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))") - .foregroundStyle(.secondary) - } - } else { - Text("Not Open Today") - .foregroundStyle(.secondary) + if isLoading { + VStack { + Image(systemName: "fork.knife.circle") + .resizable() + .frame(width: 75, height: 75) + .foregroundStyle(.accent) + .rotationEffect(.degrees(rotationDegrees)) + .onAppear { + withAnimation(animation) { + rotationDegrees = 360.0 } } - } - .padding(.bottom, 10) - Button(action: { - showingSafari = true - }) { - Text("View on Map") - } - .padding(.bottom, 10) - Text(location.desc) - .font(.body) - .padding(.bottom, 10) - Text("IMPORTANT: Some locations' descriptions may refer to them as being cashless during certain hours. This is outdated information, as all RIT Dining locations are now cashless 24/7.") - .font(.callout) + Text("Loading...") .foregroundStyle(.secondary) } - .padding(.horizontal, 8) - } - .navigationTitle("Details") - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingSafari) { - SafariView(url: URL(string: location.mapsUrl)!) + .onAppear { + getWeeklyHours() + } + .padding() + } else { + ScrollView { + VStack(alignment: .leading) { + Text(location.name) + .font(.title) + .fontWeight(.bold) + 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 += "\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime)), " + } + openString = String(openString.prefix(openString.count - 2)) + } + } else { + Text("Not Open Today") + .foregroundStyle(.secondary) + } + } + } + .padding(.bottom, 10) + Button(action: { + showingSafari = true + }) { + Text("View on Map") + } + .padding(.bottom, 10) + VStack(alignment: .leading) { + Text("This Week's Hours") + .font(.title3) + .fontWeight(.semibold) + ForEach(weeklyHours.indices, id: \.self) { index in + HStack(alignment: .top) { + Text("\(daysOfWeek[index])") + Spacer() + VStack { + ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in + Text(weeklyHours[index][innerIndex]) + .foregroundStyle(.secondary) + } + } + } + Divider() + } + } + .padding(.bottom, 10) + Text(location.desc) + .font(.body) + .padding(.bottom, 10) + Text("IMPORTANT: Some locations' descriptions may refer to them as being cashless during certain hours. This is outdated information, as all RIT Dining locations are now cashless 24/7.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + } + .navigationTitle("Details") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingSafari) { + SafariView(url: URL(string: location.mapsUrl)!) + } } } } @@ -100,5 +203,6 @@ struct DetailView: View { desc: "A long description of the place", mapsUrl: "https://example.com", diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())], - open: .open)) + open: .open, + visitingChefs: nil)) } diff --git a/RIT Dining/FetchData.swift b/RIT Dining/FetchData.swift index 73cf39b..174c9f8 100644 --- a/RIT Dining/FetchData.swift +++ b/RIT Dining/FetchData.swift @@ -11,13 +11,17 @@ enum InvalidHTTPError: Error { case invalid } -// This code came from another project of mine and was used to fetch the GitHub API for update checking. I just copied it here, but it can -// probably be made simpler for this use case. -func getAllDiningInfo(completionHandler: @escaping (Result) -> Void) { +// This API requesting code came from another project of mine and was used to fetch the GitHub API for update checking. I just copied it +// here, but it can probably be made simpler for this use case. + +// 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 = Date().formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) + let date_string: String = if let date { date } else { + Date().formatted(.iso8601 + .year().month().day() + .dateSeparator(.dash)) + } let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)" guard let url = URL(string: url_string) else { @@ -44,7 +48,42 @@ func getAllDiningInfo(completionHandler: @escaping (Result DiningLocation { +// Get information for just one dining location based on its location ID. +func getSingleDiningInfo(date: String?, locationId: Int, 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 url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)" + print("making request to \(url_string)") + + guard let url = URL(string: url_string) else { + print("Invalid URL") + return + } + let request = URLRequest(url: url) + + URLSession.shared.dataTask(with: request) { data, response, error in + guard case .none = error else { return } + + guard let data = data else { + print("Data error.") + return + } + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + completionHandler(.failure(InvalidHTTPError.invalid)) + return + } + + let decoded: Result = Result(catching: { try JSONDecoder().decode(DiningLocationParser.self, from: data) }) + completionHandler(decoded) + }.resume() +} + +func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { print("beginning parse for \(location.name)") // The descriptions sometimes have HTML
tags despite also having \n. Those need to be removed. @@ -59,7 +98,8 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation { desc: desc, mapsUrl: location.mapsUrl, diningTimes: nil, - open: .closed) + open: .closed, + visitingChefs: nil) } var openStrings: [String] = [] @@ -78,7 +118,8 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation { desc: desc, mapsUrl: location.mapsUrl, diningTimes: nil, - open: .closed) + open: .closed, + visitingChefs: nil) } openStrings.append(exceptions[0].startTime) closeStrings.append(exceptions[0].endTime) @@ -153,6 +194,23 @@ func getLocationInfo(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]? + if !location.menus.isEmpty { + var chefs: [VisitngChef] = [] + 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!)) + } + } + visitingChefs = chefs + } else { + visitingChefs = nil + } + return DiningLocation( id: location.id, name: location.name, @@ -160,5 +218,6 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation { desc: desc, mapsUrl: location.mapsUrl, diningTimes: diningTimes, - open: openStatus) + open: openStatus, + visitingChefs: visitingChefs) } diff --git a/RIT Dining/Types.swift b/RIT Dining/Types.swift index f0ce02f..665babe 100644 --- a/RIT Dining/Types.swift +++ b/RIT Dining/Types.swift @@ -11,7 +11,7 @@ import Foundation // be improved later when I feel like it. struct DiningLocationParser: Decodable { // An individual "event", which is just an open period for the location. - struct Events: Decodable { + struct Event: Decodable { // Hour exceptions for the given event. struct HoursException: Decodable { let id: Int @@ -26,13 +26,21 @@ struct DiningLocationParser: Decodable { let endTime: String let exceptions: [HoursException]? } + // An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because + // visiting chefs have descriptions but specials do not. + struct Menu: Decodable { + let name: String + let description: String? + let category: String + } // Other basic information to read from a location's JSON that we'll need later. let id: Int let name: String let summary: String let description: String let mapsUrl: String - let events: [Events] + let events: [Event] + let menus: [Menu] } // Struct that probably doesn't need to exist but this made parsing the list of location responses easy. @@ -54,6 +62,12 @@ struct DiningTimes: Equatable, Hashable { var closeTime: Date } +// A visitng chef present at a location. +struct VisitngChef: Equatable, Hashable { + let name: String + let description: String +} + // The basic information about a dining location needed to display it in the app after parsing is finished. struct DiningLocation: Identifiable, Hashable { let id: Int @@ -63,4 +77,5 @@ struct DiningLocation: Identifiable, Hashable { let mapsUrl: String let diningTimes: [DiningTimes]? let open: OpenStatus + let visitingChefs: [VisitngChef]? } diff --git a/RIT Dining/VisitingChefs.swift b/RIT Dining/VisitingChefs.swift new file mode 100644 index 0000000..a21456a --- /dev/null +++ b/RIT Dining/VisitingChefs.swift @@ -0,0 +1,47 @@ +// +// VisitingChefs.swift +// RIT Dining +// +// Created by Campbell on 9/8/25. +// + +import SwiftUI + +struct VisitingChefs: View { + @State var diningLocations: [DiningLocation] + + 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) + } + } + .padding(.bottom, 15) + } + } + } + .navigationTitle("Visiting Chefs") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + 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")])]) +}