diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index cbc06e7..324b703 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -291,7 +291,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index 38177b0..f5083c6 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -79,7 +79,6 @@ struct ContentView: View { case .success(let locations): for i in 0.. OpenStatus { - // This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next - // 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes. - let calendar = Calendar.current - let now = Date() - var openStatus: OpenStatus = .closed - if now >= openTime && now <= closeTime { - // This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is - // only true for 24-hour locations. - if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! { - openStatus = .open - } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! { - openStatus = .closingSoon - } else { - openStatus = .open +// Get the occupancy information for a location using its TigerCenter API location ID. +// This function is very messy but as the comment at the top of the file says, all of this async API access code is rough. +func getOccupancyPercentage(locationId: Int, completionHandler: @escaping (Result) -> Void) { + // We need to use the TigerCenter location ID to get the maps API ID. + var url_string = "https://maps.rit.edu/api/api-dining.php?id=\(locationId)" + print("making request to \(url_string)") + + guard let url = URL(string: url_string) else { + print("Invalid URL") + return + } + let request = URLRequest(url: url) + + var mapsId: Int = 0 + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completionHandler(.failure(error)) + return } - } 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)") - - // The descriptions sometimes have HTML
tags despite also having \n. Those need to be removed. - let desc = location.description.replacingOccurrences(of: "
", with: "") - - // Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week. - if location.events.isEmpty { - return DiningLocation( - id: location.id, - name: location.name, - summary: location.summary, - desc: desc, - mapsUrl: location.mapsUrl, - diningTimes: nil, - open: .closed, - visitingChefs: nil, - dailySpecials: nil) - } - - var openStrings: [String] = [] - var closeStrings: [String] = [] - - // Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there - // are exceptions, use those times for the day, otherwise we can just use the default times. - for event in location.events { - if let exceptions = event.exceptions, !exceptions.isEmpty { - // Only save the exception times if the location is actually open during those times. - if exceptions[0].open { - openStrings.append(exceptions[0].startTime) - closeStrings.append(exceptions[0].endTime) + + guard let data = data else { + completionHandler(.failure(URLError(.badServerResponse))) + return + } + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + completionHandler(.failure(InvalidHTTPError.invalid)) + return + } + + do { + let decoded = try JSONDecoder().decode(MapsMiddlemanParser.self, from: data) + mapsId = Int(decoded.properties.mdoid)! + + // Use the newly-acquired maps ID to request the occupancy information for the location. + url_string = "https://maps.rit.edu/proxySearch/densityMapDetail.php?mdo=\(mapsId)" + print("making request to \(url_string)") + + guard let url = URL(string: url_string) else { + print("Invalid URL") + return } - } 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] = [] - - let calendar = Calendar.current - let now = Date() - - for i in 0.. 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: ""))) - } + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + completionHandler(.failure(InvalidHTTPError.invalid)) + return + } + + do { + let occupancy = try JSONDecoder().decode([DiningOccupancyParser].self, from: data) + if !occupancy.isEmpty { + print("current occupancy: \(occupancy[0].count)") + print("maximum occupancy: \(occupancy[0].max_occ)") + let occupancyPercentage = Double(occupancy[0].count) / Double(occupancy[0].max_occ) * 100 + print("occupancy percentage: \(occupancyPercentage)%") + completionHandler(.success(occupancyPercentage)) + } else { + completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON")))) + } + } catch { + completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON")))) + } + }.resume() + } catch { + completionHandler(.failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON")))) } - visitingChefs = chefs - dailySpecials = specials - } else { - visitingChefs = nil - dailySpecials = nil - } - - return DiningLocation( - id: location.id, - name: location.name, - summary: location.summary, - desc: desc, - mapsUrl: location.mapsUrl, - diningTimes: diningTimes, - open: openStatus, - visitingChefs: visitingChefs, - dailySpecials: dailySpecials) + }.resume() } diff --git a/RIT Dining/Data/Parsers.swift b/RIT Dining/Data/Parsers.swift new file mode 100644 index 0000000..7ebd0ba --- /dev/null +++ b/RIT Dining/Data/Parsers.swift @@ -0,0 +1,246 @@ +// +// Parsers.swift +// RIT Dining +// +// Created by Campbell on 9/19/25. +// + +import Foundation + +func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus { + // This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next + // 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes. + let calendar = Calendar.current + let now = Date() + var openStatus: OpenStatus = .closed + if now >= openTime && now <= closeTime { + // This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is + // only true for 24-hour locations. + if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! { + openStatus = .open + } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! { + openStatus = .closingSoon + } else { + openStatus = .open + } + } else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now { + openStatus = .openingSoon + } else { + openStatus = .closed + } + return openStatus +} + +func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { + print("beginning parse for \(location.name)") + + // The descriptions sometimes have HTML
tags despite also having \n. Those need to be removed. + let desc = location.description.replacingOccurrences(of: "
", with: "") + + // Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week. + if location.events.isEmpty { + return DiningLocation( + id: location.id, + name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, + diningTimes: nil, + open: .closed, + visitingChefs: nil, + dailySpecials: nil) + } + + var openStrings: [String] = [] + var closeStrings: [String] = [] + + // Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there + // are exceptions, use those times for the day, otherwise we can just use the default times. Also check for repeats! The response data + // can include those somtimes, for reasons:tm: + for event in location.events { + if let exceptions = event.exceptions, !exceptions.isEmpty { + // Only save the exception times if the location is actually open during those times, and if these times aren't a repeat. + // I've seen repeats for Brick City Cafe specifically, where both the breakfast and lunch standard open periods had + // exceptions listing the same singluar brunch period. That feels like a stupid choice but oh well. + if exceptions[0].open, !openStrings.contains(exceptions[0].startTime), !closeStrings.contains(exceptions[0].endTime) { + openStrings.append(exceptions[0].startTime) + closeStrings.append(exceptions[0].endTime) + } + } else { + if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) { + 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] = [] + + let calendar = Calendar.current + let now = Date() + + for i in 0.. 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: ""))) + } + } + visitingChefs = chefs + dailySpecials = specials + } else { + visitingChefs = nil + dailySpecials = nil + } + + return DiningLocation( + id: location.id, + name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, + diningTimes: diningTimes, + open: openStatus, + visitingChefs: visitingChefs, + dailySpecials: dailySpecials) +} diff --git a/RIT Dining/Data/Types.swift b/RIT Dining/Data/Types.swift index bbfec52..da2f470 100644 --- a/RIT Dining/Data/Types.swift +++ b/RIT Dining/Data/Types.swift @@ -71,7 +71,7 @@ enum VisitingChefStatus { case leavingSoon } -// A visitng chef present at a location. +// A visiting chef present at a location. struct VisitingChef: Equatable, Hashable { let name: String let description: String @@ -80,6 +80,7 @@ struct VisitingChef: Equatable, Hashable { let status: VisitingChefStatus } +// A daily special at a location. struct DailySpecial: Equatable, Hashable { let name: String let type: String @@ -97,3 +98,36 @@ struct DiningLocation: Identifiable, Hashable { let visitingChefs: [VisitingChef]? let dailySpecials: [DailySpecial]? } + +// Parser used to parse the data from the maps.rit.edu/api/api-dining.php used as a middleman to translate the IDs from TigerCenter +// to the IDs used for the maps API. +struct MapsMiddlemanParser: Decodable { + // Properties of the location, which are all I need. + struct Properties: Decodable { + let name: String + let url: String + let id: String + let mdoid: String + } + let properties: Properties +} + +// Parser to read the occupancy data for a location. +struct DiningOccupancyParser: Decodable { + // Represents a per-hour occupancy rating. + struct HourlyOccupancy: Decodable { + let hour: Int + let today: Int + let today_max: Int + let one_week_ago: Int + let one_week_ago_max: Int + let average: Int + } + let count: Int + let location: String + let building: String + let mdo_id: Int + let max_occ: Int + let open_status: String + let intra_loc_hours: [HourlyOccupancy] +} diff --git a/RIT Dining/Views/DetailView.swift b/RIT Dining/Views/DetailView.swift index 1f7d9a0..9292761 100644 --- a/RIT Dining/Views/DetailView.swift +++ b/RIT Dining/Views/DetailView.swift @@ -16,6 +16,8 @@ struct DetailView: View { @State private var openString: String = "" @State private var week: [Date] = [] @State private var weeklyHours: [[String]] = [] + @State private var occupancyLoading: Bool = true + @State private var occupancyPercentage: Double = 0.0 private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] private var animation: Animation { @@ -75,6 +77,30 @@ struct DetailView: View { } } + private func getOccupancy() { + // Only fetch occupancy data if the location is actually open right now. Otherwise, just exit early and hide the spinner. + if location.open == .open || location.open == .closingSoon { + DispatchQueue.main.async { + getOccupancyPercentage(locationId: location.id) { result in + switch result { + case .success(let occupancy): + DispatchQueue.main.sync { + occupancyPercentage = occupancy + occupancyLoading = false + } + case .failure(let error): + print(error) + DispatchQueue.main.sync { + occupancyLoading = false + } + } + } + } + } else { + occupancyLoading = false + } + } + var body: some View { if isLoading { VStack { @@ -148,6 +174,24 @@ struct DetailView: View { } } } + 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) + .onAppear { + getOccupancy() + } + } + .foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0)) + .font(.title3) .padding(.bottom, 12) if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty { VStack(alignment: .leading) {