diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 331f097..4bc9996 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 = 12; + CURRENT_PROJECT_VERSION = 13; 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 = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index e3f6e3d..2142952 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -7,6 +7,71 @@ import SwiftUI +// This view handles the actual location list, because having it inside ContentView was too complex (both visually and for the +// type checker too, apparently). +struct LocationList: View { + @State var filteredLocations: [DiningLocation] + @Environment(Favorites.self) var favorites + + var body: some View { + ForEach($filteredLocations) { $location in + NavigationLink(destination: DetailView(location: $location)) { + VStack(alignment: .leading) { + HStack { + Text(location.name) + if favorites.contains(location) { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + 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) + } + if let times = location.diningTimes, !times.isEmpty { + ForEach(times, id: \.self) { time in + Text("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))") + .foregroundStyle(.secondary) + } + } else { + Text("Not Open Today") + .foregroundStyle(.secondary) + } + } + } + .swipeActions { + Button(action: { + withAnimation { + if favorites.contains(location) { + favorites.remove(location) + } else { + favorites.add(location) + } + } + + }) { + if favorites.contains(location) { + Label("Unfavorite", systemImage: "star") + } else { + Label("Favorite", systemImage: "star") + } + } + .tint(favorites.contains(location) ? .yellow : nil) + } + } + } +} + struct ContentView: View { // Save sort/filter options in AppStorage so that they actually get saved. @AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false @@ -29,20 +94,18 @@ struct ContentView: View { // Asynchronously fetch the data for all of the locations and parse their data to display it. private func getDiningData() async { var newDiningLocations: [DiningLocation] = [] - getAllDiningInfo(date: nil) { result in - switch result { - case .success(let locations): - for i in 0..) -> Void) { +func getAllDiningInfo(date: String?) async -> Result { // The endpoint requires that you specify a date, so get today's. - let date_string: String = date ?? getAPIFriendlyDateString(date: Date()) - let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)" + let dateString: String = date ?? getAPIFriendlyDateString(date: Date()) + let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(dateString)" - guard let url = URL(string: url_string) else { - print("Invalid URL") - return + guard let url = URL(string: urlString) else { + return .failure(URLError(.badURL)) } - let request = URLRequest(url: url) - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completionHandler(.failure(error)) - return + + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + return .failure(InvalidHTTPError.invalid) } - 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 - } - - let decoded: Result = Result(catching: { try JSONDecoder().decode(DiningLocationsParser.self, from: data) }) - completionHandler(decoded) - }.resume() + let decoded = try JSONDecoder().decode(DiningLocationsParser.self, from: data) + return .success(decoded) + } catch { + return .failure(error) + } } // Get information for just one dining location based on its location ID. -func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @escaping (Result) -> Void) { +func getSingleDiningInfo(date: String?, locId: Int) async -> Result { // The current date and the location ID are required to get information for just one location. - 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)") - - guard let url = URL(string: url_string) else { - print("Invalid URL") - return + let dateString: String = date ?? getAPIFriendlyDateString(date: Date()) + let urlString = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(dateString)&locId=\(locId)" + print("making request to \(urlString)") + + guard let url = URL(string: urlString) else { + return .failure(URLError(.badURL)) } - let request = URLRequest(url: url) - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completionHandler(.failure(error)) - return + + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + return .failure(InvalidHTTPError.invalid) } - 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 - } - - let decoded: Result = Result(catching: { try JSONDecoder().decode(DiningLocationParser.self, from: data) }) - completionHandler(decoded) - }.resume() + let decoded = try JSONDecoder().decode(DiningLocationParser.self, from: data) + return .success(decoded) + } catch { + return .failure(error) + } } -// Get the occupancy information for a location using its MDO ID, whatever that stands for. This ID is provided alongside the other main -// ID in the data returned by the TigerCenter API. -func getOccupancyPercentage(mdoId: Int, completionHandler: @escaping (Result) -> Void) { +// Get the occupancy information for a location using its MDO ID, whatever that stands for. This ID is provided alongside the other +// main ID in the data returned by the TigerCenter API. +func getOccupancyPercentage(mdoId: Int) async -> Result { let urlString = "https://maps.rit.edu/proxySearch/densityMapDetail.php?mdo=\(mdoId)" print("making request to \(urlString)") guard let url = URL(string: urlString) else { - print("Invalid URL") - return + return .failure(URLError(.badURL)) } - let occRequest = URLRequest(url: url) - URLSession.shared.dataTask(with: occRequest) { data, response, error in - if let error = error { - completionHandler(.failure(error)) - return + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + return .failure(InvalidHTTPError.invalid) } - guard let data = data else { - completionHandler(.failure(URLError(.badServerResponse))) - return + 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)%") + return .success(occupancyPercentage) + } else { + return .failure(DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Failed to decode JSON"))) } - - 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 { + return .failure(error) + } } diff --git a/RIT Dining/Views/DetailView.swift b/RIT Dining/Views/DetailView.swift index 2ad9e93..4dbf10d 100644 --- a/RIT Dining/Views/DetailView.swift +++ b/RIT Dining/Views/DetailView.swift @@ -9,17 +9,15 @@ import SwiftUI import SafariServices struct DetailView: View { - @State var location: DiningLocation + @Binding var location: DiningLocation @Environment(Favorites.self) var favorites @State private var isLoading: Bool = true @State private var rotationDegrees: Double = 0 @State private var showingSafari: Bool = false @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 - @State private var focusedDate: Date = Date() private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] private var animation: Animation { @@ -28,76 +26,63 @@ struct DetailView: View { .repeatForever(autoreverses: false) } - private func requestDone(result: Result) -> Void { - switch result { - case .success(let location): - let diningInfo = parseLocationInfo(location: location, forDate: focusedDate) - if let times = diningInfo.diningTimes, !times.isEmpty { - var timeStrings: [String] = [] - for time in times { - timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))") - } - weeklyHours.append(timeStrings) - } else { - weeklyHours.append(["Closed"]) - } - case .failure(let error): - print(error) - } - if week.count > 0 { - // Saving this to a state variable SUCKS, but I needed a quick fix and all of this request code is still pending a - // rewrite anyway to be properly async like the code in ContentView and VisitingChefs. - focusedDate = week.removeFirst() - DispatchQueue.global().async { - let dateString = focusedDate.formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) - getSingleDiningInfo(date: dateString, locationId: location.id, completionHandler: requestDone) - } - } else { - isLoading = false - print(weeklyHours) - } - } - - private func getWeeklyHours() { + // This function is now actaully async and iterative! Wow! It doesn't suck ass anymore! + private func getWeeklyHours() async { 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)! + let 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 + var newWeeklyHours: [[String]] = [] + for day in week { + let date_string = day.formatted(.iso8601 .year().month().day() .dateSeparator(.dash)) - getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone) + switch await getSingleDiningInfo(date: date_string, locId: location.id) { + case .success(let location): + let diningInfo = parseLocationInfo(location: location, forDate: day) + if let times = diningInfo.diningTimes, !times.isEmpty { + var timeStrings: [String] = [] + for time in times { + timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))") + } + newWeeklyHours.append(timeStrings) + } else { + newWeeklyHours.append(["Closed"]) + } + case .failure(let error): + print(error) + } } + weeklyHours = newWeeklyHours + isLoading = false + print(weeklyHours) } - private func getOccupancy() { + private func getOccupancy() async { // 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(mdoId: location.mdoId) { 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 - } - } - } + occupancyLoading = true + switch await getOccupancyPercentage(mdoId: location.mdoId) { + case .success(let occupancy): + occupancyPercentage = occupancy + occupancyLoading = false + case .failure(let error): + print(error) + occupancyLoading = false } } else { occupancyLoading = false } } + // Same label update timer from ContentView. + private func updateOpenStatuses() async { + Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in + location.updateOpenStatus() + } + } + var body: some View { if isLoading { VStack { @@ -114,8 +99,8 @@ struct DetailView: View { Text("Loading...") .foregroundStyle(.secondary) } - .onAppear { - getWeeklyHours() + .task { + await getWeeklyHours() } .padding() } else { @@ -200,8 +185,8 @@ struct DetailView: View { .progressViewStyle(.circular) .frame(width: 18, height: 18) .opacity(occupancyLoading ? 1 : 0) - .onAppear { - getOccupancy() + .task { + await getOccupancy() } } .foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0)) @@ -294,20 +279,24 @@ struct DetailView: View { .sheet(isPresented: $showingSafari) { SafariView(url: URL(string: location.mapsUrl)!) } + .refreshable { + await getWeeklyHours() + await getOccupancy() + } } } } -#Preview { - DetailView(location: DiningLocation( - id: 0, - mdoId: 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: nil, - dailySpecials: nil)) -} +//#Preview { +// DetailView(location: DiningLocation( +// id: 0, +// mdoId: 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: nil, +// dailySpecials: nil)) +//} diff --git a/RIT Dining/Views/VisitingChefs.swift b/RIT Dining/Views/VisitingChefs.swift index 6ded0a1..8f4337a 100644 --- a/RIT Dining/Views/VisitingChefs.swift +++ b/RIT Dining/Views/VisitingChefs.swift @@ -30,21 +30,20 @@ struct VisitingChefs: View { // information. private func getDiningDataForDate(date: String) async { var newDiningLocations: [DiningLocation] = [] - getAllDiningInfo(date: date) { result in - switch result { - case .success(let locations): - for i in 0..