From ea2538ce1873b8e32eaa6faea31d33978e486e15 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Mon, 8 Sep 2025 01:26:31 -0400 Subject: [PATCH] Improved DetailView, preliminary visiting chef support The DetailView now presents information in a more appealing way, and also fetches the opening hours for the entire week, so you can see more than just the current day's hours for a location. Also added preliminary support for parsing visiting chef information. Times are not being parsed yet because the formatting for them is super bad and inconsistent, but the names and descriptions are parsed. A "Today's Visiting Chefs" button has been added to the top of ContentView that brings you to a basic screen listing all of the locations with visiting chefs and telling you what they are. Currently times are presented as part of the name of the location like they are in the TigerCenter response data. --- RIT Dining.xcodeproj/project.pbxproj | 4 +- RIT Dining/ContentView.swift | 11 +- RIT Dining/DetailView.swift | 206 ++++++++++++++++++++------- RIT Dining/FetchData.swift | 79 ++++++++-- RIT Dining/Types.swift | 19 ++- RIT Dining/VisitingChefs.swift | 47 ++++++ 6 files changed, 299 insertions(+), 67 deletions(-) create mode 100644 RIT Dining/VisitingChefs.swift 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")])]) +}