mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
The details page for a location now shows an indicator of how busy a location is based on data from the RIT maps API, which kindly offers the current and maximum occupancies for locations around campus. This is displayed the same way that the RIT website displays it, with 0-5 person icons that are filled in based on the percentage occupied the location is. This API can be *really* slow sometimes, so this data is fetched indepdently of the main load, because it could end up massively slowing down the app to not display the main info until after the occupancy data loads. A small spinner is used to indicate that occupancy data is loading, and the indicators are partially transparent until the data is loaded. Also fixed a bug where locations with multiple opening periods could have exceptions for the same time period, resulting in duplicated time slots. Thanks Brick City Cafe! This is fixed by making sure that times are not already present in the openTimes/closeTimes arrays before adding them.
233 lines
9.9 KiB
Swift
233 lines
9.9 KiB
Swift
//
|
|
// ContentView.swift
|
|
// RIT Dining
|
|
//
|
|
// Created by Campbell on 8/31/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct LocationList: View {
|
|
let diningLocations: [DiningLocation]
|
|
|
|
// I forgot this before and was really confused why all of the times were in UTC.
|
|
private let display: DateFormatter = {
|
|
let display = DateFormatter()
|
|
display.timeZone = TimeZone(identifier: "America/New_York")
|
|
display.dateStyle = .none
|
|
display.timeStyle = .short
|
|
return display
|
|
}()
|
|
|
|
var body: some View {
|
|
ForEach(diningLocations, id: \.self) { location in
|
|
NavigationLink(destination: DetailView(location: location)) {
|
|
VStack(alignment: .leading) {
|
|
Text(location.name)
|
|
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("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Text("Not Open Today")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
// Stored in AppStorage because making this setting persistent makes sense. Some people only ever want to see open locations.
|
|
@AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false
|
|
@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 {
|
|
.linear
|
|
.speed(0.1)
|
|
.repeatForever(autoreverses: false)
|
|
}
|
|
|
|
// Asynchronously fetch the data for all of the locations and parse their data to display it.
|
|
private func getDiningData() {
|
|
var newDiningLocations: [DiningLocation] = []
|
|
getAllDiningInfo(date: nil) { result in
|
|
DispatchQueue.global().async {
|
|
switch result {
|
|
case .success(let locations):
|
|
for i in 0..<locations.locations.count {
|
|
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
|
DispatchQueue.global().sync {
|
|
newDiningLocations.append(diningInfo)
|
|
}
|
|
}
|
|
DispatchQueue.global().sync {
|
|
// Need to sort the locations alphabetically because they get returned in a completely arbitrary order. Also
|
|
// need to do so while ignoring the word "the" because a bunch of locations have it and it's not helpful to put
|
|
// those all down in "T".
|
|
diningLocations = newDiningLocations.sorted { firstLoc, secondLoc in
|
|
func removeThe(_ name: String) -> String {
|
|
let lowercased = name.lowercased()
|
|
if lowercased.hasPrefix("the ") {
|
|
return String(name.dropFirst(4))
|
|
}
|
|
return name
|
|
}
|
|
return removeThe(firstLoc.name).localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending
|
|
}
|
|
lastRefreshed = Date()
|
|
isLoading = false
|
|
}
|
|
case .failure(let error):
|
|
print(error)
|
|
loadFailed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: [DiningLocation] {
|
|
diningLocations.filter { location in
|
|
let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText)
|
|
let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon
|
|
return searchedLocations && openLocations
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack() {
|
|
if isLoading {
|
|
VStack {
|
|
if loadFailed {
|
|
Image(systemName: "wifi.exclamationmark.circle")
|
|
.resizable()
|
|
.frame(width: 75, height: 75)
|
|
.foregroundStyle(.accent)
|
|
Text("An error occurred while fetching dining data. Please check your network connection and try again.")
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Button(action: {
|
|
loadFailed = false
|
|
getDiningData()
|
|
}) {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.padding(.top, 10)
|
|
} else {
|
|
Image(systemName: "fork.knife.circle")
|
|
.resizable()
|
|
.frame(width: 75, height: 75)
|
|
.foregroundStyle(.accent)
|
|
.rotationEffect(.degrees(rotationDegrees))
|
|
.onAppear {
|
|
withAnimation(animation) {
|
|
rotationDegrees = 360.0
|
|
}
|
|
}
|
|
Text("Loading...")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
} else {
|
|
VStack() {
|
|
List {
|
|
// Always show the visiting chef link on iOS 26+, since the bottom mounted search bar makes this work okay. On
|
|
// older iOS versions, hide the button while searching to make it easier to go through search results.
|
|
if #unavailable(iOS 26.0), searchText.isEmpty {
|
|
Section(content: {
|
|
NavigationLink(destination: VisitingChefs()) {
|
|
Text("Today's Visiting Chefs")
|
|
}
|
|
})
|
|
} else {
|
|
Section(content: {
|
|
NavigationLink(destination: VisitingChefs()) {
|
|
Text("Today's Visiting Chefs")
|
|
}
|
|
})
|
|
}
|
|
Section(content: {
|
|
LocationList(diningLocations: filteredLocations)
|
|
}, footer: {
|
|
if let lastRefreshed {
|
|
VStack(alignment: .center) {
|
|
Text("Last refreshed: \(lastRefreshed.formatted())")
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
.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")
|
|
}
|
|
NavigationLink(destination: AboutView()) {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.accentColor)
|
|
Text("About")
|
|
}
|
|
Button(action: {
|
|
showingDonationSheet = true
|
|
}) {
|
|
Label("Donate", systemImage: "heart")
|
|
}
|
|
} label: {
|
|
Image(systemName: "slider.horizontal.3")
|
|
.foregroundStyle(.accent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
getDiningData()
|
|
}
|
|
.sheet(isPresented: $showingDonationSheet) {
|
|
DonationView()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|