RIT-Dining/RIT Dining/Views/DetailView.swift
NinjaCheetah f01c041885
Rewrote lots of bad "async" code
Code that should've been fully async that was not previously actually async, such as the networking code, is now actually async. This makes it much easier to read and means that it makes a lot more sense in the context of the program. This also means that all uses of DispatchQueue.main.sync{} and DispatchQueue.main.async{} are now gone.
DetailView's fetching code benefitted the most from these changes, and is now just one iterative function instead of two cursed functions involving callbacks. As a result, you can now also pull down on the detail view to refresh the data, which is handy. Note that occupancy isn't updated when doing this currently due to a task issue I haven't figured out yet, but you can just go back out and in to update that anyway.
ContentView will also now automatically refresh the data if the last refreshed date is no longer today, so if you let the app sit in the background and then focus it, dates should automatically update to being for today.
2025-10-01 00:13:55 -04:00

303 lines
13 KiB
Swift

//
// DetailView.swift
// RIT Dining
//
// Created by Campbell on 9/1/25.
//
import SwiftUI
import SafariServices
struct DetailView: View {
@Binding var location: DiningLocation
@Environment(Favorites.self) var favorites
@State private var isLoading: Bool = true
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false
@State private var openString: String = ""
@State private var weeklyHours: [[String]] = []
@State private var occupancyLoading: Bool = true
@State private var occupancyPercentage: Double = 0.0
private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
// This function is now actaully async and iterative! Wow! It doesn't suck ass anymore!
private func getWeeklyHours() async {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let dayOfWeek = calendar.component(.weekday, from: today)
let week = calendar.range(of: .weekday, in: .weekOfYear, for: today)!
.compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) }
var newWeeklyHours: [[String]] = []
for day in week {
let date_string = day.formatted(.iso8601
.year().month().day()
.dateSeparator(.dash))
switch await getSingleDiningInfo(date: date_string, locId: location.id) {
case .success(let location):
let diningInfo = parseLocationInfo(location: location, forDate: day)
if let times = diningInfo.diningTimes, !times.isEmpty {
var timeStrings: [String] = []
for time in times {
timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
}
newWeeklyHours.append(timeStrings)
} else {
newWeeklyHours.append(["Closed"])
}
case .failure(let error):
print(error)
}
}
weeklyHours = newWeeklyHours
isLoading = false
print(weeklyHours)
}
private func getOccupancy() async {
// Only fetch occupancy data if the location is actually open right now. Otherwise, just exit early and hide the spinner.
if location.open == .open || location.open == .closingSoon {
occupancyLoading = true
switch await getOccupancyPercentage(mdoId: location.mdoId) {
case .success(let occupancy):
occupancyPercentage = occupancy
occupancyLoading = false
case .failure(let error):
print(error)
occupancyLoading = false
}
} else {
occupancyLoading = false
}
}
// Same label update timer from ContentView.
private func updateOpenStatuses() async {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
location.updateOpenStatus()
}
}
var body: some View {
if isLoading {
VStack {
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)
}
.task {
await getWeeklyHours()
}
.padding()
} else {
ScrollView {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text(location.name)
.font(.title)
.fontWeight(.bold)
Spacer()
Button(action: {
if favorites.contains(location) {
favorites.remove(location)
} else {
favorites.add(location)
}
}) {
if favorites.contains(location) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.font(.title3)
} else {
Image(systemName: "star")
.foregroundStyle(.yellow)
.font(.title3)
}
}
Button(action: {
showingSafari = true
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
.font(.title3)
}
}
Text(location.summary)
.font(.title2)
.foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 3) {
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)
}
Text("")
.foregroundStyle(.secondary)
VStack {
if let times = location.diningTimes, !times.isEmpty {
Text(openString)
.foregroundStyle(.secondary)
.onAppear {
openString = ""
for time in times {
openString += "\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime)), "
}
openString = String(openString.prefix(openString.count - 2))
}
} else {
Text("Not Open Today")
.foregroundStyle(.secondary)
}
}
}
HStack(spacing: 0) {
ForEach(Range(1...5), id: \.self) { index in
if occupancyPercentage > (20 * Double(index)) {
Image(systemName: "person.fill")
} else {
Image(systemName: "person")
}
}
ProgressView()
.progressViewStyle(.circular)
.frame(width: 18, height: 18)
.opacity(occupancyLoading ? 1 : 0)
.task {
await getOccupancy()
}
}
.foregroundStyle(Color.accent.opacity(occupancyLoading ? 0.5 : 1.0))
.font(.title3)
.padding(.bottom, 12)
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack(alignment: .leading) {
Text("Today's Visiting Chefs")
.font(.title3)
.fontWeight(.semibold)
ForEach(visitingChefs, id: \.self) { chef in
HStack(alignment: .top) {
Text(chef.name)
Spacer()
VStack(alignment: .trailing) {
switch chef.status {
case .hereNow:
Text("Here Now")
.foregroundStyle(.green)
case .gone:
Text("Left For Today")
.foregroundStyle(.red)
case .arrivingLater:
Text("Arriving Later")
.foregroundStyle(.red)
case .arrivingSoon:
Text("Arriving Soon")
.foregroundStyle(.orange)
case .leavingSoon:
Text("Leaving Soon")
.foregroundStyle(.orange)
}
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
}
Divider()
}
}
.padding(.bottom, 12)
}
if let dailySpecials = location.dailySpecials, !dailySpecials.isEmpty {
VStack(alignment: .leading) {
Text("Today's Daily Specials")
.font(.title3)
.fontWeight(.semibold)
ForEach(dailySpecials, id: \.self) { special in
HStack(alignment: .top) {
Text(special.name)
Spacer()
Text(special.type)
.foregroundStyle(.secondary)
}
Divider()
}
}
.padding(.bottom, 12)
}
VStack(alignment: .leading) {
Text("This Week's Hours")
.font(.title3)
.fontWeight(.semibold)
ForEach(weeklyHours.indices, id: \.self) { index in
HStack(alignment: .top) {
Text("\(daysOfWeek[index])")
Spacer()
VStack {
ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in
Text(weeklyHours[index][innerIndex])
.foregroundStyle(.secondary)
}
}
}
Divider()
}
}
.padding(.bottom, 12)
// Ideally I'd like this text to be justified to more effectively use the screen space.
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)!)
}
.refreshable {
await getWeeklyHours()
await getOccupancy()
}
}
}
}
//#Preview {
// DetailView(location: DiningLocation(
// id: 0,
// mdoId: 0,
// name: "Example",
// summary: "A Place",
// desc: "A long description of the place",
// mapsUrl: "https://example.com",
// diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
// open: .open,
// visitingChefs: nil,
// dailySpecials: nil))
//}