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)
}