mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
This update mostly includes improvements related to sorting and filtering the main dining location list, including: - Favorites! You can mark locations as your favorites by swiping them on the list or pressing the star button on their detail page. Favorites are sorted to the top. - "Hide Closed Locations" has been moved to a dedicated sort/filter button in the bottom left corner. This looks best on iOS 26+, where it sits nicely to the left of the search bar. - Added an "Open Locations First" option to sort open locations above closed locations, if you want to know what's open quicker without entirely hiding closed locations. Other improvements: - Made most asynchronous code properly async instead of bouncing between DispatchQueue.main.async{} and .sync{}. - Added a timer to refresh open statuses every 3 seconds while on the main list, so that when the time changes the open statuses will change appropriately. It seemed silly to force you to fetch the data again just to do a quick "hey is current time in range?". - Made date formatter shared so there aren't 3 separate copies of it.
309 lines
14 KiB
Swift
309 lines
14 KiB
Swift
//
|
|
// DetailView.swift
|
|
// RIT Dining
|
|
//
|
|
// Created by Campbell on 9/1/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SafariServices
|
|
|
|
struct DetailView: View {
|
|
@State 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 week: [Date] = []
|
|
@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)
|
|
}
|
|
|
|
private func requestDone(result: Result<DiningLocationParser, Error>) -> Void {
|
|
switch result {
|
|
case .success(let location):
|
|
let diningInfo = parseLocationInfo(location: location)
|
|
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))")
|
|
}
|
|
weeklyHours.append(timeStrings)
|
|
} else {
|
|
weeklyHours.append(["Closed"])
|
|
}
|
|
case .failure(let error):
|
|
print(error)
|
|
}
|
|
if week.count > 0 {
|
|
DispatchQueue.global().async {
|
|
let date_string = week.removeFirst().formatted(.iso8601
|
|
.year().month().day()
|
|
.dateSeparator(.dash))
|
|
getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone)
|
|
}
|
|
} else {
|
|
isLoading = false
|
|
print(weeklyHours)
|
|
}
|
|
}
|
|
|
|
private func getWeeklyHours() {
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
let dayOfWeek = calendar.component(.weekday, from: today)
|
|
week = calendar.range(of: .weekday, in: .weekOfYear, for: today)!
|
|
.compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) }
|
|
DispatchQueue.global().async {
|
|
let date_string = week.removeFirst().formatted(.iso8601
|
|
.year().month().day()
|
|
.dateSeparator(.dash))
|
|
getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone)
|
|
}
|
|
}
|
|
|
|
private func getOccupancy() {
|
|
// 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 {
|
|
DispatchQueue.main.async {
|
|
getOccupancyPercentage(locationId: location.id) { result in
|
|
switch result {
|
|
case .success(let occupancy):
|
|
DispatchQueue.main.sync {
|
|
occupancyPercentage = occupancy
|
|
occupancyLoading = false
|
|
}
|
|
case .failure(let error):
|
|
print(error)
|
|
DispatchQueue.main.sync {
|
|
occupancyLoading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
occupancyLoading = false
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.onAppear {
|
|
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)
|
|
.onAppear {
|
|
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)!)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DetailView(location: DiningLocation(
|
|
id: 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))
|
|
}
|