diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 4bc9996..2a0d267 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -255,9 +255,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -289,9 +290,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/RIT Dining/Data/Parsers.swift b/RIT Dining/Components/Parsers.swift similarity index 96% rename from RIT Dining/Data/Parsers.swift rename to RIT Dining/Components/Parsers.swift index ea5ef3b..cfcff57 100644 --- a/RIT Dining/Data/Parsers.swift +++ b/RIT Dining/Components/Parsers.swift @@ -46,6 +46,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, + date: forDate ?? Date(), diningTimes: nil, open: .closed, visitingChefs: nil, @@ -69,12 +70,10 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining } } else { if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) { - // Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the week - // it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no hours - // for this location. - let weekdayFormatter = DateFormatter() - weekdayFormatter.dateFormat = "EEEE" - if event.daysOfWeek.contains(weekdayFormatter.string(from: forDate ?? Date()).uppercased()) { + // Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the + // week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no + // hours for this location. + if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) { openStrings.append(event.startTime) closeStrings.append(event.endTime) } @@ -92,6 +91,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, + date: forDate ?? Date(), diningTimes: nil, open: .closed, visitingChefs: nil, @@ -249,6 +249,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, + date: forDate ?? Date(), diningTimes: diningTimes, open: openStatus, visitingChefs: visitingChefs, diff --git a/RIT Dining/Data/FetchData.swift b/RIT Dining/Components/Requests.swift similarity index 99% rename from RIT Dining/Data/FetchData.swift rename to RIT Dining/Components/Requests.swift index 566f535..a6d3ec4 100644 --- a/RIT Dining/Data/FetchData.swift +++ b/RIT Dining/Components/Requests.swift @@ -1,5 +1,5 @@ // -// FetchData.swift +// Requests.swift // RIT Dining // // Created by Campbell on 8/31/25. diff --git a/RIT Dining/SharedComponents.swift b/RIT Dining/Components/SharedComponents.swift similarity index 81% rename from RIT Dining/SharedComponents.swift rename to RIT Dining/Components/SharedComponents.swift index 011228f..18ca11f 100644 --- a/RIT Dining/SharedComponents.swift +++ b/RIT Dining/Components/SharedComponents.swift @@ -40,6 +40,19 @@ let dateDisplay: DateFormatter = { return display }() +let visitingChefDateDisplay: DateFormatter = { + let display = DateFormatter() + display.dateFormat = "EEEE, MMM d" + display.locale = Locale(identifier: "en_US_POSIX") + return display +}() + +let weekdayFromDate: DateFormatter = { + let weekdayFormatter = DateFormatter() + weekdayFormatter.dateFormat = "EEEE" + return weekdayFormatter +}() + // Custom view extension that just applies modifiers in a block to the object it's applied to. Mostly useful for splitting up conditional // modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!) extension View { diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index 2142952..3c09f10 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -7,82 +7,18 @@ 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 @AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false @State private var favorites = Favorites() + @State private var notifyingChefs = NotifyingChefs() + @State private var model = DiningModel() @State private var isLoading: Bool = true @State private var loadFailed: Bool = false @State private var showingDonationSheet: Bool = false @State private var rotationDegrees: Double = 0 @State private var diningLocations: [DiningLocation] = [] - @State private var lastRefreshed: Date? @State private var searchText: String = "" private var animation: Animation { @@ -91,20 +27,13 @@ struct ContentView: View { .repeatForever(autoreverses: false) } - // Asynchronously fetch the data for all of the locations and parse their data to display it. + // Small wrapper around the method on the model so that errors can be handled by showing the uh error screen. private func getDiningData() async { - var newDiningLocations: [DiningLocation] = [] - switch await getAllDiningInfo(date: nil) { - case .success(let locations): - for i in 0.. String { - let lowercased = name.lowercased() - if lowercased.hasPrefix("the ") { - return String(name.dropFirst(4)) - } - return name - } - newLocations.sort { firstLoc, secondLoc in - let firstLocIsFavorite = favorites.contains(firstLoc) - let secondLocIsFavorite = favorites.contains(secondLoc) - // Favorites get priority! - if firstLocIsFavorite != secondLocIsFavorite { - return firstLocIsFavorite && !secondLocIsFavorite - } - // Additional sorting rule that sorts open locations ahead of closed locations, if enabled. - if openLocationsFirst { - let firstIsOpen = (firstLoc.open == .open || firstLoc.open == .closingSoon) - let secondIsOpen = (secondLoc.open == .open || secondLoc.open == .closingSoon) - if firstIsOpen != secondIsOpen { - return firstIsOpen && !secondIsOpen - } - } - return removeThe(firstLoc.name) - .localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending - } - // Search/open only filtering step. - newLocations = newLocations.filter { location in - let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText) - let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon - return searchedLocations && openLocations - } - return newLocations - } - var body: some View { NavigationStack() { if isLoading { @@ -208,13 +96,18 @@ struct ContentView: View { List { Section(content: { NavigationLink(destination: VisitingChefs()) { - Text("Today's Visiting Chefs") + Text("Upcoming Visiting Chefs") } }) Section(content: { - LocationList(filteredLocations: filteredLocations) + LocationList( + diningLocations: $model.locationsByDay[0], + openLocationsFirst: $openLocationsFirst, + openLocationsOnly: $openLocationsOnly, + searchText: $searchText + ) }, footer: { - if let lastRefreshed { + if let lastRefreshed = model.lastRefreshed { VStack(alignment: .center) { Text("Last refreshed: \(lastRefreshed.formatted())") .foregroundStyle(.secondary) @@ -239,6 +132,11 @@ struct ContentView: View { }) { Label("Refresh", systemImage: "arrow.clockwise") } +// NavigationLink(destination: VisitingChefPush()) { +// Image(systemName: "bell.badge") +// .foregroundColor(.accentColor) +// Text("Notifications") +// } Divider() NavigationLink(destination: AboutView()) { Image(systemName: "info.circle") @@ -277,6 +175,8 @@ struct ContentView: View { } } .environment(favorites) + .environment(notifyingChefs) + .environment(model) .task { await getDiningData() await updateOpenStatuses() diff --git a/RIT Dining/Data/Model.swift b/RIT Dining/Data/Model.swift new file mode 100644 index 0000000..cb61d3a --- /dev/null +++ b/RIT Dining/Data/Model.swift @@ -0,0 +1,59 @@ +// +// Model.swift +// RIT Dining +// +// Created by Campbell on 10/1/25. +// + +import SwiftUI + +@Observable +class DiningModel { + var locationsByDay = [[DiningLocation]]() + var daysRepresented = [Date]() + var lastRefreshed: Date? + + // This is the actual method responsible for making requests to the API for the current day and next 6 days to collect all + // of the information that the app needs for the various view. Making it part of the model allows it to be updated from + // any view at any time, and prevents excess API requests (if you never refresh, the app will never need to make more than 7 + // calls per launch). + func getHoursByDay() async throws { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let week: [Date] = (0..<7).compactMap { offset in + calendar.date(byAdding: .day, value: offset, to: today) + } + daysRepresented = week + var newLocationsByDay = [[DiningLocation]]() + for day in week { + let dateString = day.formatted(.iso8601 + .year().month().day() + .dateSeparator(.dash)) + switch await getAllDiningInfo(date: dateString) { + case .success(let locations): + var newDiningLocations = [DiningLocation]() + for i in 0.. + private let key = "NotifyingChefs" + + init() { + let chefs = UserDefaults.standard.array(forKey: key) as? [String] ?? [String]() + notifyingChefs = Set(chefs) + } + + func contains(_ chef: String) -> Bool { + notifyingChefs.contains(chef.lowercased()) + } + + func add(_ chef: String) { + notifyingChefs.insert(chef.lowercased()) + save() + } + + func remove(_ chef: String) { + notifyingChefs.remove(chef.lowercased()) + save() + } + + func save() { + let chefs = Array(notifyingChefs) + UserDefaults.standard.set(chefs, forKey: key) + } +} diff --git a/RIT Dining/Data/Types.swift b/RIT Dining/Data/Types.swift index e2e5d2d..785826f 100644 --- a/RIT Dining/Data/Types.swift +++ b/RIT Dining/Data/Types.swift @@ -96,25 +96,13 @@ struct DiningLocation: Identifiable, Hashable { let summary: String let desc: String let mapsUrl: String + let date: Date let diningTimes: [DiningTimes]? var open: OpenStatus 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. @@ -134,3 +122,10 @@ struct DiningOccupancyParser: Decodable { let open_status: String let intra_loc_hours: [HourlyOccupancy] } + +// Struct used to represent a day and its hours as strings. Type used for the hours of today and the next 6 days used in DetailView. +struct WeeklyHours: Hashable { + let day: String + let date: Date + let timeStrings: [String] +} diff --git a/RIT Dining/RIT Dining.entitlements b/RIT Dining/RIT Dining.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/RIT Dining/RIT Dining.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/RIT Dining/Views/DetailView.swift b/RIT Dining/Views/DetailView.swift index 4dbf10d..e9d2610 100644 --- a/RIT Dining/Views/DetailView.swift +++ b/RIT Dining/Views/DetailView.swift @@ -9,56 +9,55 @@ import SwiftUI import SafariServices struct DetailView: View { - @Binding var location: DiningLocation + @State var locationId: Int @Environment(Favorites.self) var favorites - @State private var isLoading: Bool = true - @State private var rotationDegrees: Double = 0 + @Environment(DiningModel.self) var model @State private var showingSafari: Bool = false @State private var openString: String = "" - @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 { - .linear - .speed(0.1) - .repeatForever(autoreverses: false) + + // This gets the location that we're meant to be displaying details about using the provided ID. + private var location: DiningLocation { + return model.locationsByDay[0].first { $0.id == locationId }! } - // 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) - let week = calendar.range(of: .weekday, in: .weekOfYear, for: today)! - .compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) } - var newWeeklyHours: [[String]] = [] - for day in week { - let date_string = day.formatted(.iso8601 - .year().month().day() - .dateSeparator(.dash)) - 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))") + // This creates a list of the time strings for the current day and following 6 days to display in the "Upcoming Hours" section. + // I realized that it makes a lot more sense to do today + 6 rather than just the current calendar week's hours, because who + // cares what Tuesday's hours were on Saturday, you want to know what Sunday's hours will be. + private var weeklyHours: [WeeklyHours] { + var newWeeklyHours: [WeeklyHours] = [] + for day in model.locationsByDay { + for location in day { + if location.id == locationId { + let weekdayFormatter = DateFormatter() + weekdayFormatter.dateFormat = "EEEE" + if let times = location.diningTimes, !times.isEmpty { + var timeStrings: [String] = [] + for time in times { + timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))") + } + newWeeklyHours.append( + WeeklyHours( + day: weekdayFormatter.string(from: location.date), + date: location.date, + timeStrings: timeStrings + )) + } else { + newWeeklyHours.append( + WeeklyHours( + day: weekdayFormatter.string(from: location.date), + date: location.date, + timeStrings: ["Closed"] + )) } - newWeeklyHours.append(timeStrings) - } else { - newWeeklyHours.append(["Closed"]) } - case .failure(let error): - print(error) } } - weeklyHours = newWeeklyHours - isLoading = false - print(weeklyHours) + return newWeeklyHours } + // Still a little broken, does not work for refresh. Need to fix. 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 { @@ -76,227 +75,189 @@ struct DetailView: View { } } - // 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 { - Image(systemName: "fork.knife.circle") - .resizable() - .frame(width: 75, height: 75) - .foregroundStyle(.accent) - .rotationEffect(.degrees(rotationDegrees)) - .onAppear { - withAnimation(animation) { - rotationDegrees = 360.0 + ScrollView { + VStack(alignment: .leading) { + HStack(alignment: .center) { + Text(location.name) + .font(.title) + .fontWeight(.bold) + Spacer() + Button(action: { + if favorites.contains(location) { + favorites.remove(location) + } else { + favorites.add(location) + } + }) { + if favorites.contains(location) { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .font(.title3) + } else { + Image(systemName: "star") + .foregroundStyle(.yellow) + .font(.title3) } } - Text("Loading...") + Button(action: { + showingSafari = true + }) { + Image(systemName: "map") + .foregroundStyle(.accent) + .font(.title3) + } + } + Text(location.summary) + .font(.title2) .foregroundStyle(.secondary) - } - .task { - await getWeeklyHours() - } - .padding() - } else { - ScrollView { - VStack(alignment: .leading) { - HStack(alignment: .center) { - Text(location.name) - .font(.title) - .fontWeight(.bold) - Spacer() - Button(action: { - if favorites.contains(location) { - favorites.remove(location) - } else { - favorites.add(location) - } - }) { - if favorites.contains(location) { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) - .font(.title3) - } else { - Image(systemName: "star") - .foregroundStyle(.yellow) - .font(.title3) - } - } - Button(action: { - showingSafari = true - }) { - Image(systemName: "map") - .foregroundStyle(.accent) - .font(.title3) - } + 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(location.summary) - .font(.title2) + Text("•") .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 += "\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime)), " - } - openString = String(openString.prefix(openString.count - 2)) - } - } else { - Text("Not Open Today") - .foregroundStyle(.secondary) - } - } - } - 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) - .task { - await 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) { - 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("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))") - .foregroundStyle(.secondary) + VStack { + if let times = location.diningTimes, !times.isEmpty { + Text(openString) + .foregroundStyle(.secondary) + .onAppear { + openString = "" + for time in times { + openString += "\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime)), " } + openString = String(openString.prefix(openString.count - 2)) } - Divider() - } + } else { + Text("Not Open Today") + .foregroundStyle(.secondary) } - .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() - } + } + HStack(spacing: 0) { + ForEach(Range(1...5), id: \.self) { index in + if occupancyPercentage > (20 * Double(index)) { + Image(systemName: "person.fill") + } else { + Image(systemName: "person") } - .padding(.bottom, 12) } + ProgressView() + .progressViewStyle(.circular) + .frame(width: 18, height: 18) + .opacity(occupancyLoading ? 1 : 0) + .task { + await 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) { - Text("This Week's Hours") + Text("Today's Visiting Chefs") .font(.title3) .fontWeight(.semibold) - ForEach(weeklyHours.indices, id: \.self) { index in + ForEach(visitingChefs, id: \.self) { chef in HStack(alignment: .top) { - Text("\(daysOfWeek[index])") + Text(chef.name) Spacer() - VStack { - ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in - Text(weeklyHours[index][innerIndex]) - .foregroundStyle(.secondary) + 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("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))") + .foregroundStyle(.secondary) } } Divider() } } .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) - 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) + 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) + } + VStack(alignment: .leading) { + Text("Upcoming Hours") + .font(.title3) + .fontWeight(.semibold) + ForEach(weeklyHours, id: \.self) { day in + HStack(alignment: .top) { + Text(day.day) + Spacer() + VStack { + ForEach(day.timeStrings, id: \.self) { timeString in + Text(timeString) + .foregroundStyle(.secondary) + } + } + } + Divider() + } + } + .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) + 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) } - .navigationTitle("Details") - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingSafari) { - SafariView(url: URL(string: location.mapsUrl)!) - } - .refreshable { - await getWeeklyHours() - await getOccupancy() + .padding(.horizontal, 8) + } + .navigationTitle("Details") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingSafari) { + SafariView(url: URL(string: location.mapsUrl)!) + } + .refreshable { + do { + try await model.getHoursByDay() + } catch { + print(error) } + 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)) -//} diff --git a/RIT Dining/Views/LocationList.swift b/RIT Dining/Views/LocationList.swift new file mode 100644 index 0000000..33c6acb --- /dev/null +++ b/RIT Dining/Views/LocationList.swift @@ -0,0 +1,115 @@ +// +// LocationList.swift +// RIT Dining +// +// Created by Campbell on 10/1/25. +// + +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 { + @Binding var diningLocations: [DiningLocation] + @Binding var openLocationsFirst: Bool + @Binding var openLocationsOnly: Bool + @Binding var searchText: String + @Environment(Favorites.self) var favorites + + // The dining locations need to be sorted before being displayed. Favorites should always be shown first, followed by non-favorites. + // Afterwards, filters the sorted list based on any current search text and the "open locations only" filtering option. + private var filteredLocations: [DiningLocation] { + var newLocations = diningLocations + // Because "The Commons" should be C for "Commons" and not T for "The". + func removeThe(_ name: String) -> String { + let lowercased = name.lowercased() + if lowercased.hasPrefix("the ") { + return String(name.dropFirst(4)) + } + return name + } + newLocations.sort { firstLoc, secondLoc in + let firstLocIsFavorite = favorites.contains(firstLoc) + let secondLocIsFavorite = favorites.contains(secondLoc) + // Favorites get priority! + if firstLocIsFavorite != secondLocIsFavorite { + return firstLocIsFavorite && !secondLocIsFavorite + } + // Additional sorting rule that sorts open locations ahead of closed locations, if enabled. + if openLocationsFirst { + let firstIsOpen = (firstLoc.open == .open || firstLoc.open == .closingSoon) + let secondIsOpen = (secondLoc.open == .open || secondLoc.open == .closingSoon) + if firstIsOpen != secondIsOpen { + return firstIsOpen && !secondIsOpen + } + } + return removeThe(firstLoc.name) + .localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending + } + // Search/open only filtering step. + newLocations = newLocations.filter { location in + let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText) + let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon + return searchedLocations && openLocations + } + return newLocations + } + + var body: some View { + ForEach(filteredLocations, id: \.self) { location in + NavigationLink(destination: DetailView(locationId: location.id)) { + 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) + } + } + } +} diff --git a/RIT Dining/Views/VisitingChefs.swift b/RIT Dining/Views/VisitingChefs.swift index 8f4337a..a533097 100644 --- a/RIT Dining/Views/VisitingChefs.swift +++ b/RIT Dining/Views/VisitingChefs.swift @@ -13,164 +13,113 @@ struct IdentifiableURL: Identifiable { } struct VisitingChefs: View { + @Environment(DiningModel.self) var model @State private var locationsWithChefs: [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 + @State private var chefDays: [String] = [] + @State private var focusedIndex: Int = 0 - private var animation: Animation { - .linear - .speed(0.1) - .repeatForever(autoreverses: false) - } - - // 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) async { - var newDiningLocations: [DiningLocation] = [] - switch await getAllDiningInfo(date: date) { - case .success(let locations): - for i in 0..