Added filtering options and basic detail view

This commit is contained in:
Campbell 2025-09-01 15:46:03 -04:00
parent 3f812495b0
commit 9f1d5c2078
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
3 changed files with 199 additions and 46 deletions

View File

@ -9,15 +9,52 @@ import SwiftUI
struct Location: Hashable { struct Location: Hashable {
let name: String let name: String
let summary: String
let desc: String
let mapsUrl: String
let todaysHours: [String] let todaysHours: [String]
let isOpen: openStatus 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 { struct ContentView: View {
@State private var isLoading = true @State private var isLoading = true
@State private var rotationDegrees: Double = 0 @State private var rotationDegrees: Double = 0
@State private var diningLocations: [Location] = [] @State private var diningLocations: [Location] = []
@State private var lastRefreshed: Date? @State private var lastRefreshed: Date?
@State private var searchText: String = ""
@State private var openLocationsOnly: Bool = false
private var animation: Animation { private var animation: Animation {
.linear .linear
@ -58,6 +95,9 @@ struct ContentView: View {
newDiningLocations.append( newDiningLocations.append(
Location( Location(
name: diningInfo.name, name: diningInfo.name,
summary: diningInfo.summary,
desc: diningInfo.desc,
mapsUrl: diningInfo.mapsUrl,
todaysHours: todaysHours, todaysHours: todaysHours,
isOpen: diningInfo.open 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 { var body: some View {
NavigationStack() { NavigationStack() {
if isLoading { if isLoading {
@ -97,28 +147,7 @@ struct ContentView: View {
VStack() { VStack() {
List { List {
Section(content: { Section(content: {
ForEach(diningLocations, id: \.self) { location in LocationList(diningLocations: filteredLocations)
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)
}
}
}
}, footer: { }, footer: {
if let lastRefreshed { if let lastRefreshed {
VStack(alignment: .center) { VStack(alignment: .center) {
@ -131,9 +160,26 @@ struct ContentView: View {
} }
} }
.navigationTitle("RIT Dining") .navigationTitle("RIT Dining")
.searchable(text: $searchText, prompt: "Search...")
.refreshable { .refreshable {
getDiningData() 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 { .onAppear {

View File

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

View File

@ -28,6 +28,9 @@ struct DiningLocation: Decodable {
let id: Int let id: Int
let name: String let name: String
let summary: String
let description: String
let mapsUrl: String
let events: [Events] let events: [Events]
} }
@ -87,6 +90,9 @@ struct DiningTimes: Equatable {
struct DiningInfo { struct DiningInfo {
let id: Int let id: Int
let name: String let name: String
let summary: String
let desc: String
let mapsUrl: String
let diningTimes: [DiningTimes]? let diningTimes: [DiningTimes]?
let open: openStatus let open: openStatus
} }
@ -94,11 +100,17 @@ struct DiningInfo {
func getLocationInfo(location: DiningLocation) -> DiningInfo { func getLocationInfo(location: DiningLocation) -> DiningInfo {
print("beginning parse for \(location.name)") print("beginning parse for \(location.name)")
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
let desc = location.description.replacingOccurrences(of: "<br />", 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. // 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 { if location.events.isEmpty {
return DiningInfo( return DiningInfo(
id: location.id, id: location.id,
name: location.name, name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: .none, diningTimes: .none,
open: .closed) 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 // 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. // 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 { for event in location.events {
// Early return if the exception for the day specifies that the location is closed. Used for things like holidays. if let exceptions = event.exceptions, !exceptions.isEmpty {
if !location.events[0].exceptions![0].open { // Early return if the exception for the day specifies that the location is closed. Used for things like holidays.
return DiningInfo( if !exceptions[0].open {
id: location.id, return DiningInfo(
name: location.name, id: location.id,
diningTimes: .none, name: location.name,
open: .closed) summary: location.summary,
} desc: desc,
openStrings.append(location.events[0].exceptions![0].startTime) mapsUrl: location.mapsUrl,
closeStrings.append(location.events[0].exceptions![0].endTime) diningTimes: .none,
} else { open: .closed)
for event in location.events { }
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
} else {
openStrings.append(event.startTime) openStrings.append(event.startTime)
closeStrings.append(event.endTime) 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. // 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 var openStatus: openStatus = .closed
for i in 0..<openDates.count { for i in 0..<openDates.count {
let isOpen = (now >= openDates[i] && now <= closeDates[i]) if now >= openDates[i] && now <= closeDates[i] {
if isOpen {
if closeDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! { if closeDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! {
openStatus = .closingSoon openStatus = .closingSoon
break
} else { } else {
openStatus = .open openStatus = .open
break
} }
} else if openDates[i] <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeDates[i] > now {
openStatus = .openingSoon
} else { } else {
if openDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! { openStatus = .closed
openStatus = .openingSoon }
break // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
} else { // accurately catch Gracie's multiple open periods each day.
openStatus = .closed if openStatus != .closed {
} break
} }
} }
@ -192,6 +206,9 @@ func getLocationInfo(location: DiningLocation) -> DiningInfo {
return DiningInfo( return DiningInfo(
id: location.id, id: location.id,
name: location.name, name: location.name,
summary: location.summary,
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: diningTimes, diningTimes: diningTimes,
open: openStatus) open: openStatus)
} }