diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index 4548ef7..f9c9675 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -9,15 +9,52 @@ import SwiftUI struct Location: Hashable { let name: String + let summary: String + let desc: String + let mapsUrl: String let todaysHours: [String] let isOpen: openStatus } +struct LocationList: View { + let diningLocations: [Location] + + var body: some View { + ForEach(diningLocations, id: \.self) { location in + NavigationLink(destination: DetailView(location: location)) { + VStack(alignment: .leading) { + Text(location.name) + switch location.isOpen { + 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) + } + ForEach(location.todaysHours, id: \.self) { hours in + Text(hours) + .foregroundStyle(.secondary) + } + } + } + } + } +} + struct ContentView: View { @State private var isLoading = true @State private var rotationDegrees: Double = 0 @State private var diningLocations: [Location] = [] @State private var lastRefreshed: Date? + @State private var searchText: String = "" + @State private var openLocationsOnly: Bool = false private var animation: Animation { .linear @@ -58,6 +95,9 @@ struct ContentView: View { newDiningLocations.append( Location( name: diningInfo.name, + summary: diningInfo.summary, + desc: diningInfo.desc, + mapsUrl: diningInfo.mapsUrl, todaysHours: todaysHours, isOpen: diningInfo.open ) @@ -75,6 +115,16 @@ struct ContentView: View { } } + // Allow for searching the list and hiding closed locations. Gets a list of locations that match the search and a list that match + // the open only filter (.open and .closingSoon) and then returns the ones that match both lists. + private var filteredLocations: [Location] { + diningLocations.filter { location in + let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText) + let openLocations = !openLocationsOnly || location.isOpen == .open || location.isOpen == .closingSoon + return searchedLocations && openLocations + } + } + var body: some View { NavigationStack() { if isLoading { @@ -97,28 +147,7 @@ struct ContentView: View { VStack() { List { Section(content: { - ForEach(diningLocations, id: \.self) { location in - VStack(alignment: .leading) { - Text(location.name) - ForEach(location.todaysHours, id: \.self) { hours in - Text(hours) - } - switch location.isOpen { - 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) - } - } - } + LocationList(diningLocations: filteredLocations) }, footer: { if let lastRefreshed { VStack(alignment: .center) { @@ -131,9 +160,26 @@ struct ContentView: View { } } .navigationTitle("RIT Dining") + .searchable(text: $searchText, prompt: "Search...") .refreshable { getDiningData() } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button(action: { + getDiningData() + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + Toggle(isOn: $openLocationsOnly) { + Label("Hide Closed Locations", systemImage: "eye.slash") + } + } label: { + Image(systemName: "slider.horizontal.3") + } + } + } } } .onAppear { diff --git a/RIT Dining/DetailView.swift b/RIT Dining/DetailView.swift new file mode 100644 index 0000000..e8ec872 --- /dev/null +++ b/RIT Dining/DetailView.swift @@ -0,0 +1,90 @@ +// +// DetailView.swift +// RIT Dining +// +// Created by Campbell on 9/1/25. +// + +import SwiftUI +import SafariServices + +// Gross disgusting UIKit code :( +// There isn't a direct way to use integrated Safari from SwiftUI, except maybe in iOS 26? I'm not targeting that though so I must fall +// back on UIKit stuff. +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} + +struct DetailView: View { + @State var location: Location + @State private var showingSafari: Bool = false + + 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.isOpen { + 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 { + ForEach(location.todaysHours, id: \.self) { hours in + Text(hours) + .foregroundStyle(.secondary) + } + } + } + .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) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + } + .navigationTitle("Details") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingSafari) { + SafariView(url: URL(string: location.mapsUrl)!) + } + } +} + +#Preview { + DetailView(location: Location( + name: "Example", + summary: "A Place", + desc: "A long description of the place", + mapsUrl: "https://example.com", + todaysHours: ["Now - Later"], + isOpen: .open)) +} diff --git a/RIT Dining/FetchData.swift b/RIT Dining/FetchData.swift index bc9a5df..679dbc0 100644 --- a/RIT Dining/FetchData.swift +++ b/RIT Dining/FetchData.swift @@ -28,6 +28,9 @@ struct DiningLocation: Decodable { let id: Int let name: String + let summary: String + let description: String + let mapsUrl: String let events: [Events] } @@ -87,6 +90,9 @@ struct DiningTimes: Equatable { struct DiningInfo { let id: Int let name: String + let summary: String + let desc: String + let mapsUrl: String let diningTimes: [DiningTimes]? let open: openStatus } @@ -94,11 +100,17 @@ struct DiningInfo { func getLocationInfo(location: DiningLocation) -> DiningInfo { 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 DiningInfo( id: location.id, name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, diningTimes: .none, open: .closed) } @@ -108,19 +120,22 @@ func getLocationInfo(location: DiningLocation) -> DiningInfo { // 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. - if let exceptions = location.events[0].exceptions, !exceptions.isEmpty { - // Early return if the exception for the day specifies that the location is closed. Used for things like holidays. - if !location.events[0].exceptions![0].open { - return DiningInfo( - id: location.id, - name: location.name, - diningTimes: .none, - open: .closed) - } - openStrings.append(location.events[0].exceptions![0].startTime) - closeStrings.append(location.events[0].exceptions![0].endTime) - } else { - for event in location.events { + for event in location.events { + if let exceptions = event.exceptions, !exceptions.isEmpty { + // Early return if the exception for the day specifies that the location is closed. Used for things like holidays. + if !exceptions[0].open { + return DiningInfo( + id: location.id, + name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, + diningTimes: .none, + open: .closed) + } + openStrings.append(exceptions[0].startTime) + closeStrings.append(exceptions[0].endTime) + } else { openStrings.append(event.startTime) closeStrings.append(event.endTime) } @@ -161,26 +176,25 @@ func getLocationInfo(location: DiningLocation) -> DiningInfo { } } - // This can probably be done in a cleaner way but it's okay for now. If the location is open but the close date is within the next + // 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. var openStatus: openStatus = .closed for i in 0..= openDates[i] && now <= closeDates[i]) - if isOpen { + if now >= openDates[i] && now <= closeDates[i] { if closeDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! { openStatus = .closingSoon - break } else { openStatus = .open - break } + } else if openDates[i] <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeDates[i] > now { + openStatus = .openingSoon } else { - if openDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! { - openStatus = .openingSoon - break - } else { - openStatus = .closed - } + openStatus = .closed + } + // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to + // accurately catch Gracie's multiple open periods each day. + if openStatus != .closed { + break } } @@ -192,6 +206,9 @@ func getLocationInfo(location: DiningLocation) -> DiningInfo { return DiningInfo( id: location.id, name: location.name, + summary: location.summary, + desc: desc, + mapsUrl: location.mapsUrl, diningTimes: diningTimes, open: openStatus) }