mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
Added filtering options and basic detail view
This commit is contained in:
parent
3f812495b0
commit
9f1d5c2078
@ -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 {
|
||||||
|
90
RIT Dining/DetailView.swift
Normal file
90
RIT Dining/DetailView.swift
Normal 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))
|
||||||
|
}
|
@ -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 {
|
||||||
|
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.
|
// 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 {
|
if !exceptions[0].open {
|
||||||
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)
|
||||||
}
|
}
|
||||||
openStrings.append(location.events[0].exceptions![0].startTime)
|
openStrings.append(exceptions[0].startTime)
|
||||||
closeStrings.append(location.events[0].exceptions![0].endTime)
|
closeStrings.append(exceptions[0].endTime)
|
||||||
} else {
|
} else {
|
||||||
for event in location.events {
|
|
||||||
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 {
|
} else if openDates[i] <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeDates[i] > now {
|
||||||
if openDates[i] < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
|
||||||
openStatus = .openingSoon
|
openStatus = .openingSoon
|
||||||
break
|
|
||||||
} else {
|
} 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(
|
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)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user