Rewrote huge chunks of backend code to use a model

A model is now used to store all of the dining location information. This means that the data is now shared between all views, making it much easier to refresh and removing lots of excess API calls. dining-all is now called for the current day and the following 6 days on app launch and on refresh, but beyond that no additional API calls need to be made (excluding occupancy info). This means no more loading screens between views!
The window for hours in DetailView has been shifted to now show the current day and the next 6 days rather than the hours for each day in the current calendar week. This makes a lot more sense, because who cares what last Tuesday's hours were on Saturday, you'd rather know what's coming up in the next week.
The visiting chef screen now supports scrolling through 7 days of visiting chefs instead of just today and tomorrow.
Some basic frameworks laid for the visiting chef notification feature, however it does not work yet and is not exposed in the app.
Also fixed sorting and searching bugs introduced by changes in the previous commit.
This commit is contained in:
2025-10-02 01:01:18 -04:00
parent f01c041885
commit dba5511ed5
13 changed files with 627 additions and 510 deletions

View File

@@ -9,56 +9,55 @@ import SwiftUI
import SafariServices
struct DetailView: View {
@Binding var location: DiningLocation
@State var locationId: Int
@Environment(Favorites.self) var favorites
@State private var isLoading: Bool = true
@State private var rotationDegrees: Double = 0
@Environment(DiningModel.self) var model
@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 gets the location that we're meant to be displaying details about using the provided ID.
private var location: DiningLocation {
return model.locationsByDay[0].first { $0.id == locationId }!
}
// 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))")
// This creates a list of the time strings for the current day and following 6 days to display in the "Upcoming Hours" section.
// I realized that it makes a lot more sense to do today + 6 rather than just the current calendar week's hours, because who
// cares what Tuesday's hours were on Saturday, you want to know what Sunday's hours will be.
private var weeklyHours: [WeeklyHours] {
var newWeeklyHours: [WeeklyHours] = []
for day in model.locationsByDay {
for location in day {
if location.id == locationId {
let weekdayFormatter = DateFormatter()
weekdayFormatter.dateFormat = "EEEE"
if let times = location.diningTimes, !times.isEmpty {
var timeStrings: [String] = []
for time in times {
timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
}
newWeeklyHours.append(
WeeklyHours(
day: weekdayFormatter.string(from: location.date),
date: location.date,
timeStrings: timeStrings
))
} else {
newWeeklyHours.append(
WeeklyHours(
day: weekdayFormatter.string(from: location.date),
date: location.date,
timeStrings: ["Closed"]
))
}
newWeeklyHours.append(timeStrings)
} else {
newWeeklyHours.append(["Closed"])
}
case .failure(let error):
print(error)
}
}
weeklyHours = newWeeklyHours
isLoading = false
print(weeklyHours)
return newWeeklyHours
}
// Still a little broken, does not work for refresh. Need to fix.
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 {
@@ -76,227 +75,189 @@ struct DetailView: View {
}
}
// 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
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)
}
}
Text("Loading...")
Button(action: {
showingSafari = true
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
.font(.title3)
}
}
Text(location.summary)
.font(.title2)
.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)
}
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(location.summary)
.font(.title2)
Text("")
.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)
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))
}
Divider()
}
} else {
Text("Not Open Today")
.foregroundStyle(.secondary)
}
.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()
}
}
HStack(spacing: 0) {
ForEach(Range(1...5), id: \.self) { index in
if occupancyPercentage > (20 * Double(index)) {
Image(systemName: "person.fill")
} else {
Image(systemName: "person")
}
.padding(.bottom, 12)
}
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("This Week's Hours")
Text("Today's Visiting Chefs")
.font(.title3)
.fontWeight(.semibold)
ForEach(weeklyHours.indices, id: \.self) { index in
ForEach(visitingChefs, id: \.self) { chef in
HStack(alignment: .top) {
Text("\(daysOfWeek[index])")
Text(chef.name)
Spacer()
VStack {
ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in
Text(weeklyHours[index][innerIndex])
.foregroundStyle(.secondary)
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)
// 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)
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("Upcoming Hours")
.font(.title3)
.fontWeight(.semibold)
ForEach(weeklyHours, id: \.self) { day in
HStack(alignment: .top) {
Text(day.day)
Spacer()
VStack {
ForEach(day.timeStrings, id: \.self) { timeString in
Text(timeString)
.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)
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingSafari) {
SafariView(url: URL(string: location.mapsUrl)!)
}
.refreshable {
await getWeeklyHours()
await getOccupancy()
.padding(.horizontal, 8)
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingSafari) {
SafariView(url: URL(string: location.mapsUrl)!)
}
.refreshable {
do {
try await model.getHoursByDay()
} catch {
print(error)
}
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))
//}

View File

@@ -0,0 +1,115 @@
//
// LocationList.swift
// RIT Dining
//
// Created by Campbell on 10/1/25.
//
import SwiftUI
// This view handles the actual location list, because having it inside ContentView was too complex (both visually and for the
// type checker too, apparently).
struct LocationList: View {
@Binding var diningLocations: [DiningLocation]
@Binding var openLocationsFirst: Bool
@Binding var openLocationsOnly: Bool
@Binding var searchText: String
@Environment(Favorites.self) var favorites
// The dining locations need to be sorted before being displayed. Favorites should always be shown first, followed by non-favorites.
// Afterwards, filters the sorted list based on any current search text and the "open locations only" filtering option.
private var filteredLocations: [DiningLocation] {
var newLocations = diningLocations
// Because "The Commons" should be C for "Commons" and not T for "The".
func removeThe(_ name: String) -> String {
let lowercased = name.lowercased()
if lowercased.hasPrefix("the ") {
return String(name.dropFirst(4))
}
return name
}
newLocations.sort { firstLoc, secondLoc in
let firstLocIsFavorite = favorites.contains(firstLoc)
let secondLocIsFavorite = favorites.contains(secondLoc)
// Favorites get priority!
if firstLocIsFavorite != secondLocIsFavorite {
return firstLocIsFavorite && !secondLocIsFavorite
}
// Additional sorting rule that sorts open locations ahead of closed locations, if enabled.
if openLocationsFirst {
let firstIsOpen = (firstLoc.open == .open || firstLoc.open == .closingSoon)
let secondIsOpen = (secondLoc.open == .open || secondLoc.open == .closingSoon)
if firstIsOpen != secondIsOpen {
return firstIsOpen && !secondIsOpen
}
}
return removeThe(firstLoc.name)
.localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending
}
// Search/open only filtering step.
newLocations = newLocations.filter { location in
let searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText)
let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon
return searchedLocations && openLocations
}
return newLocations
}
var body: some View {
ForEach(filteredLocations, id: \.self) { location in
NavigationLink(destination: DetailView(locationId: location.id)) {
VStack(alignment: .leading) {
HStack {
Text(location.name)
if favorites.contains(location) {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
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("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
.foregroundStyle(.secondary)
}
} else {
Text("Not Open Today")
.foregroundStyle(.secondary)
}
}
}
.swipeActions {
Button(action: {
withAnimation {
if favorites.contains(location) {
favorites.remove(location)
} else {
favorites.add(location)
}
}
}) {
if favorites.contains(location) {
Label("Unfavorite", systemImage: "star")
} else {
Label("Favorite", systemImage: "star")
}
}
.tint(favorites.contains(location) ? .yellow : nil)
}
}
}
}

View File

@@ -13,164 +13,113 @@ struct IdentifiableURL: Identifiable {
}
struct VisitingChefs: View {
@Environment(DiningModel.self) var model
@State private var locationsWithChefs: [DiningLocation] = []
@State private var isLoading: Bool = true
@State private var rotationDegrees: Double = 0
@State private var daySwitcherRotation: Double = 0
@State private var safariUrl: IdentifiableURL?
@State private var isTomorrow: Bool = false
@State private var chefDays: [String] = []
@State private var focusedIndex: Int = 0
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
// Asynchronously fetch the data for all of the locations on the given date (only ever today or tomorrow) to get the visiting chef
// information.
private func getDiningDataForDate(date: String) async {
var newDiningLocations: [DiningLocation] = []
switch await getAllDiningInfo(date: date) {
case .success(let locations):
for i in 0..<locations.locations.count {
let diningInfo = parseLocationInfo(location: locations.locations[i], forDate: nil)
print(diningInfo.name)
// Only save the locations that actually have visiting chefs to avoid extra iterations later.
if let visitingChefs = diningInfo.visitingChefs, !visitingChefs.isEmpty {
newDiningLocations.append(diningInfo)
// Builds a list of days that each contain a list of dining locations that have visiting chefs to make displaying them
// as easy as possible.
private var locationsWithChefsByDay: [[DiningLocation]] {
var locationsWithChefsByDay = [[DiningLocation]]()
for day in model.locationsByDay {
var locationsWithChefs = [DiningLocation]()
for location in day {
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
locationsWithChefs.append(location)
}
}
locationsWithChefs = newDiningLocations
isLoading = false
case .failure(let error):
print(error)
locationsWithChefsByDay.append(locationsWithChefs)
}
}
private func getDiningData() async {
isLoading = true
let dateString: String
if !isTomorrow {
dateString = getAPIFriendlyDateString(date: Date())
print("fetching visiting chefs for date \(dateString) (today)")
} else {
let calendar = Calendar.current
let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date())!
dateString = getAPIFriendlyDateString(date: tomorrow)
print("fetching visiting chefs for date \(dateString) (tomorrow)")
}
await getDiningDataForDate(date: dateString)
return locationsWithChefsByDay
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack(alignment: .center) {
if !isTomorrow {
Text("Today's Visiting Chefs")
Button(action: {
focusedIndex -= 1
}) {
Image(systemName: "chevron.left.circle")
.font(.title)
.fontWeight(.bold)
} else {
Text("Tomorrow's Visiting Chefs")
.font(.title)
.fontWeight(.bold)
}
.disabled(focusedIndex == 0)
Spacer()
Text("Visiting Chefs for \(visitingChefDateDisplay.string(from: model.daysRepresented[focusedIndex]))")
.font(.title)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
Spacer()
Button(action: {
withAnimation(Animation.linear.speed(1.5)) {
if isTomorrow {
daySwitcherRotation = 0.0
} else {
daySwitcherRotation = 180.0
}
}
isTomorrow.toggle()
Task {
await getDiningData()
}
focusedIndex += 1
}) {
Image(systemName: "chevron.right.circle")
.rotationEffect(.degrees(daySwitcherRotation))
.font(.title)
}
.disabled(focusedIndex == 6)
}
if isLoading {
if locationsWithChefsByDay[focusedIndex].isEmpty {
VStack {
Image(systemName: "fork.knife.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
rotationDegrees = 0.0
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("Loading...")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 25)
} else {
if locationsWithChefs.isEmpty {
Divider()
Text("No visiting chefs today")
.font(.title2)
.foregroundStyle(.secondary)
}
ForEach(locationsWithChefs, id: \.self) { location in
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack(alignment: .leading) {
Divider()
HStack(alignment: .center) {
Text(location.name)
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(action: {
safariUrl = IdentifiableURL(url: URL(string: location.mapsUrl)!)
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
}
}
ForEach(visitingChefs, id: \.self) { chef in
Spacer()
Text(chef.name)
.fontWeight(.semibold)
HStack(spacing: 3) {
if !isTomorrow {
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)
}
} else {
Text("Arriving Tomorrow")
.foregroundStyle(.red)
}
Text("")
.foregroundStyle(.secondary)
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
Text(chef.description)
}
ForEach(locationsWithChefsByDay[focusedIndex], id: \.self) { location in
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
VStack(alignment: .leading) {
Divider()
HStack(alignment: .center) {
Text(location.name)
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(action: {
safariUrl = IdentifiableURL(url: URL(string: location.mapsUrl)!)
}) {
Image(systemName: "map")
.foregroundStyle(.accent)
}
}
.padding(.bottom, 20)
ForEach(visitingChefs, id: \.self) { chef in
Spacer()
Text(chef.name)
.fontWeight(.semibold)
HStack(spacing: 3) {
if focusedIndex == 0 {
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)
}
} else {
Text("Arriving on \(weekdayFromDate.string(from: model.daysRepresented[focusedIndex]))")
.foregroundStyle(.red)
}
Text("")
.foregroundStyle(.secondary)
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
.foregroundStyle(.secondary)
}
Text(chef.description)
}
}
.padding(.bottom, 20)
}
}
}
@@ -179,11 +128,12 @@ struct VisitingChefs: View {
.sheet(item: $safariUrl) { url in
SafariView(url: url.url)
}
.task {
await getDiningData()
}
.refreshable {
await getDiningData()
do {
try await model.getHoursByDay()
} catch {
print(error)
}
}
}
}

View File

@@ -0,0 +1,78 @@
//
// VisitingChefsPush.swift
// RIT Dining
//
// Created by Campbell on 10/1/25.
//
import SwiftUI
struct VisitingChefPush: View {
@AppStorage("visitingChefPushEnabled") var pushEnabled: Bool = false
@Environment(NotifyingChefs.self) var notifyingChefs
@State private var pushAllowed: Bool = false
private let visitingChefs = [
"California Rollin' Sushi",
"D'Mangu",
"Esan's Kitchen",
"Halal n Out",
"just chik'n",
"KO-BQ",
"Macarollin'",
"P.H. Express",
"Tandoor of India"
]
var body: some View {
VStack {
Form {
Section(header: Text("Visiting Chef Notifications"),
footer: Text(!pushAllowed ? "You must allow notifications from RIT Dining to use this feature." : "")) {
Toggle(isOn: $pushEnabled) {
Text("Notifications Enabled")
}
.disabled(!pushAllowed)
}
Section(footer: Text("Get notified when a specific visiting chef is on campus and where they'll be.")) {
ForEach(visitingChefs, id: \.self) { chef in
Toggle(isOn: Binding(
get: {
notifyingChefs.contains(chef)
},
set: { isOn in
if isOn {
notifyingChefs.add(chef)
} else {
notifyingChefs.remove(chef)
}
}
)) {
Text(chef)
}
}
}
.disabled(!pushAllowed || !pushEnabled)
}
Spacer()
}
.onAppear {
Task {
let center = UNUserNotificationCenter.current()
do {
try await center.requestAuthorization(options: [.alert])
} catch {
print(error)
}
let settings = await center.notificationSettings()
guard (settings.authorizationStatus == .authorized) else { pushEnabled = false; return }
pushAllowed = true
}
}
.navigationTitle("Notifications")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
VisitingChefPush()
}