mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
234 lines
10 KiB
Swift
234 lines
10 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])
|
|
print(diningInfo.name)
|
|
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()
|
|
}
|