mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
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:
parent
f01c041885
commit
dba5511ed5
@ -255,9 +255,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -289,9 +290,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
@ -46,6 +46,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: location.mapsUrl,
|
||||||
|
date: forDate ?? Date(),
|
||||||
diningTimes: nil,
|
diningTimes: nil,
|
||||||
open: .closed,
|
open: .closed,
|
||||||
visitingChefs: nil,
|
visitingChefs: nil,
|
||||||
@ -69,12 +70,10 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) {
|
if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) {
|
||||||
// Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the week
|
// Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the
|
||||||
// it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no hours
|
// week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no
|
||||||
// for this location.
|
// hours for this location.
|
||||||
let weekdayFormatter = DateFormatter()
|
if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) {
|
||||||
weekdayFormatter.dateFormat = "EEEE"
|
|
||||||
if event.daysOfWeek.contains(weekdayFormatter.string(from: forDate ?? Date()).uppercased()) {
|
|
||||||
openStrings.append(event.startTime)
|
openStrings.append(event.startTime)
|
||||||
closeStrings.append(event.endTime)
|
closeStrings.append(event.endTime)
|
||||||
}
|
}
|
||||||
@ -92,6 +91,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: location.mapsUrl,
|
||||||
|
date: forDate ?? Date(),
|
||||||
diningTimes: nil,
|
diningTimes: nil,
|
||||||
open: .closed,
|
open: .closed,
|
||||||
visitingChefs: nil,
|
visitingChefs: nil,
|
||||||
@ -249,6 +249,7 @@ func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> Dining
|
|||||||
summary: location.summary,
|
summary: location.summary,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
mapsUrl: location.mapsUrl,
|
mapsUrl: location.mapsUrl,
|
||||||
|
date: forDate ?? Date(),
|
||||||
diningTimes: diningTimes,
|
diningTimes: diningTimes,
|
||||||
open: openStatus,
|
open: openStatus,
|
||||||
visitingChefs: visitingChefs,
|
visitingChefs: visitingChefs,
|
@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// FetchData.swift
|
// Requests.swift
|
||||||
// RIT Dining
|
// RIT Dining
|
||||||
//
|
//
|
||||||
// Created by Campbell on 8/31/25.
|
// Created by Campbell on 8/31/25.
|
@ -40,6 +40,19 @@ let dateDisplay: DateFormatter = {
|
|||||||
return display
|
return display
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
let visitingChefDateDisplay: DateFormatter = {
|
||||||
|
let display = DateFormatter()
|
||||||
|
display.dateFormat = "EEEE, MMM d"
|
||||||
|
display.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return display
|
||||||
|
}()
|
||||||
|
|
||||||
|
let weekdayFromDate: DateFormatter = {
|
||||||
|
let weekdayFormatter = DateFormatter()
|
||||||
|
weekdayFormatter.dateFormat = "EEEE"
|
||||||
|
return weekdayFormatter
|
||||||
|
}()
|
||||||
|
|
||||||
// Custom view extension that just applies modifiers in a block to the object it's applied to. Mostly useful for splitting up conditional
|
// Custom view extension that just applies modifiers in a block to the object it's applied to. Mostly useful for splitting up conditional
|
||||||
// modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!)
|
// modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!)
|
||||||
extension View {
|
extension View {
|
@ -7,82 +7,18 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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 {
|
|
||||||
@State var filteredLocations: [DiningLocation]
|
|
||||||
@Environment(Favorites.self) var favorites
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ForEach($filteredLocations) { $location in
|
|
||||||
NavigationLink(destination: DetailView(location: $location)) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
// Save sort/filter options in AppStorage so that they actually get saved.
|
// Save sort/filter options in AppStorage so that they actually get saved.
|
||||||
@AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false
|
@AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false
|
||||||
@AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false
|
@AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false
|
||||||
@State private var favorites = Favorites()
|
@State private var favorites = Favorites()
|
||||||
|
@State private var notifyingChefs = NotifyingChefs()
|
||||||
|
@State private var model = DiningModel()
|
||||||
@State private var isLoading: Bool = true
|
@State private var isLoading: Bool = true
|
||||||
@State private var loadFailed: Bool = false
|
@State private var loadFailed: Bool = false
|
||||||
@State private var showingDonationSheet: Bool = false
|
@State private var showingDonationSheet: Bool = false
|
||||||
@State private var rotationDegrees: Double = 0
|
@State private var rotationDegrees: Double = 0
|
||||||
@State private var diningLocations: [DiningLocation] = []
|
@State private var diningLocations: [DiningLocation] = []
|
||||||
@State private var lastRefreshed: Date?
|
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
private var animation: Animation {
|
private var animation: Animation {
|
||||||
@ -91,20 +27,13 @@ struct ContentView: View {
|
|||||||
.repeatForever(autoreverses: false)
|
.repeatForever(autoreverses: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asynchronously fetch the data for all of the locations and parse their data to display it.
|
// Small wrapper around the method on the model so that errors can be handled by showing the uh error screen.
|
||||||
private func getDiningData() async {
|
private func getDiningData() async {
|
||||||
var newDiningLocations: [DiningLocation] = []
|
do {
|
||||||
switch await getAllDiningInfo(date: nil) {
|
try await model.getHoursByDay()
|
||||||
case .success(let locations):
|
|
||||||
for i in 0..<locations.locations.count {
|
|
||||||
let diningInfo = parseLocationInfo(location: locations.locations[i], forDate: nil)
|
|
||||||
newDiningLocations.append(diningInfo)
|
|
||||||
}
|
|
||||||
diningLocations = newDiningLocations
|
|
||||||
lastRefreshed = Date()
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
case .failure(let error):
|
} catch {
|
||||||
print(error)
|
isLoading = true
|
||||||
loadFailed = true
|
loadFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,12 +43,10 @@ struct ContentView: View {
|
|||||||
// display "Open" instead of "Opening Soon" now that it's 11:01.
|
// display "Open" instead of "Opening Soon" now that it's 11:01.
|
||||||
private func updateOpenStatuses() async {
|
private func updateOpenStatuses() async {
|
||||||
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
||||||
for location in diningLocations.indices {
|
model.updateOpenStatuses()
|
||||||
diningLocations[location].updateOpenStatus()
|
|
||||||
}
|
|
||||||
// If the last refreshed date isn't today, that means we probably passed midnight and need to refresh the data.
|
// If the last refreshed date isn't today, that means we probably passed midnight and need to refresh the data.
|
||||||
// So do that.
|
// So do that.
|
||||||
if !Calendar.current.isDateInToday(lastRefreshed ?? Date()) {
|
if !Calendar.current.isDateInToday(model.lastRefreshed ?? Date()) {
|
||||||
Task {
|
Task {
|
||||||
await getDiningData()
|
await getDiningData()
|
||||||
}
|
}
|
||||||
@ -127,45 +54,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
var body: some View {
|
||||||
NavigationStack() {
|
NavigationStack() {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
@ -208,13 +96,18 @@ struct ContentView: View {
|
|||||||
List {
|
List {
|
||||||
Section(content: {
|
Section(content: {
|
||||||
NavigationLink(destination: VisitingChefs()) {
|
NavigationLink(destination: VisitingChefs()) {
|
||||||
Text("Today's Visiting Chefs")
|
Text("Upcoming Visiting Chefs")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Section(content: {
|
Section(content: {
|
||||||
LocationList(filteredLocations: filteredLocations)
|
LocationList(
|
||||||
|
diningLocations: $model.locationsByDay[0],
|
||||||
|
openLocationsFirst: $openLocationsFirst,
|
||||||
|
openLocationsOnly: $openLocationsOnly,
|
||||||
|
searchText: $searchText
|
||||||
|
)
|
||||||
}, footer: {
|
}, footer: {
|
||||||
if let lastRefreshed {
|
if let lastRefreshed = model.lastRefreshed {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
Text("Last refreshed: \(lastRefreshed.formatted())")
|
Text("Last refreshed: \(lastRefreshed.formatted())")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -239,6 +132,11 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
|
// NavigationLink(destination: VisitingChefPush()) {
|
||||||
|
// Image(systemName: "bell.badge")
|
||||||
|
// .foregroundColor(.accentColor)
|
||||||
|
// Text("Notifications")
|
||||||
|
// }
|
||||||
Divider()
|
Divider()
|
||||||
NavigationLink(destination: AboutView()) {
|
NavigationLink(destination: AboutView()) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
@ -277,6 +175,8 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(favorites)
|
.environment(favorites)
|
||||||
|
.environment(notifyingChefs)
|
||||||
|
.environment(model)
|
||||||
.task {
|
.task {
|
||||||
await getDiningData()
|
await getDiningData()
|
||||||
await updateOpenStatuses()
|
await updateOpenStatuses()
|
||||||
|
59
RIT Dining/Data/Model.swift
Normal file
59
RIT Dining/Data/Model.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// Model.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// Created by Campbell on 10/1/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class DiningModel {
|
||||||
|
var locationsByDay = [[DiningLocation]]()
|
||||||
|
var daysRepresented = [Date]()
|
||||||
|
var lastRefreshed: Date?
|
||||||
|
|
||||||
|
// This is the actual method responsible for making requests to the API for the current day and next 6 days to collect all
|
||||||
|
// of the information that the app needs for the various view. Making it part of the model allows it to be updated from
|
||||||
|
// any view at any time, and prevents excess API requests (if you never refresh, the app will never need to make more than 7
|
||||||
|
// calls per launch).
|
||||||
|
func getHoursByDay() async throws {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let week: [Date] = (0..<7).compactMap { offset in
|
||||||
|
calendar.date(byAdding: .day, value: offset, to: today)
|
||||||
|
}
|
||||||
|
daysRepresented = week
|
||||||
|
var newLocationsByDay = [[DiningLocation]]()
|
||||||
|
for day in week {
|
||||||
|
let dateString = day.formatted(.iso8601
|
||||||
|
.year().month().day()
|
||||||
|
.dateSeparator(.dash))
|
||||||
|
switch await getAllDiningInfo(date: dateString) {
|
||||||
|
case .success(let locations):
|
||||||
|
var newDiningLocations = [DiningLocation]()
|
||||||
|
for i in 0..<locations.locations.count {
|
||||||
|
let diningInfo = parseLocationInfo(location: locations.locations[i], forDate: day)
|
||||||
|
newDiningLocations.append(diningInfo)
|
||||||
|
}
|
||||||
|
newLocationsByDay.append(newDiningLocations)
|
||||||
|
case .failure(let error):
|
||||||
|
throw(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationsByDay = newLocationsByDay
|
||||||
|
lastRefreshed = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterates through all of the locations and updates their open status indicator based on the current time. Does a replace
|
||||||
|
// to make sure that it updates any views observing this model.
|
||||||
|
func updateOpenStatuses() {
|
||||||
|
locationsByDay = locationsByDay.map { day in
|
||||||
|
day.map { location in
|
||||||
|
var location = location
|
||||||
|
location.updateOpenStatus()
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
RIT Dining/Data/NotifyingChefs.swift
Normal file
38
RIT Dining/Data/NotifyingChefs.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// NotifyingChefs.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// Created by Campbell on 10/1/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class NotifyingChefs {
|
||||||
|
private var notifyingChefs: Set<String>
|
||||||
|
private let key = "NotifyingChefs"
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let chefs = UserDefaults.standard.array(forKey: key) as? [String] ?? [String]()
|
||||||
|
notifyingChefs = Set(chefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ chef: String) -> Bool {
|
||||||
|
notifyingChefs.contains(chef.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ chef: String) {
|
||||||
|
notifyingChefs.insert(chef.lowercased())
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ chef: String) {
|
||||||
|
notifyingChefs.remove(chef.lowercased())
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
let chefs = Array(notifyingChefs)
|
||||||
|
UserDefaults.standard.set(chefs, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
@ -96,25 +96,13 @@ struct DiningLocation: Identifiable, Hashable {
|
|||||||
let summary: String
|
let summary: String
|
||||||
let desc: String
|
let desc: String
|
||||||
let mapsUrl: String
|
let mapsUrl: String
|
||||||
|
let date: Date
|
||||||
let diningTimes: [DiningTimes]?
|
let diningTimes: [DiningTimes]?
|
||||||
var open: OpenStatus
|
var open: OpenStatus
|
||||||
let visitingChefs: [VisitingChef]?
|
let visitingChefs: [VisitingChef]?
|
||||||
let dailySpecials: [DailySpecial]?
|
let dailySpecials: [DailySpecial]?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parser used to parse the data from the maps.rit.edu/api/api-dining.php used as a middleman to translate the IDs from TigerCenter
|
|
||||||
// to the IDs used for the maps API.
|
|
||||||
struct MapsMiddlemanParser: Decodable {
|
|
||||||
// Properties of the location, which are all I need.
|
|
||||||
struct Properties: Decodable {
|
|
||||||
let name: String
|
|
||||||
let url: String
|
|
||||||
let id: String
|
|
||||||
let mdoid: String
|
|
||||||
}
|
|
||||||
let properties: Properties
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parser to read the occupancy data for a location.
|
// Parser to read the occupancy data for a location.
|
||||||
struct DiningOccupancyParser: Decodable {
|
struct DiningOccupancyParser: Decodable {
|
||||||
// Represents a per-hour occupancy rating.
|
// Represents a per-hour occupancy rating.
|
||||||
@ -134,3 +122,10 @@ struct DiningOccupancyParser: Decodable {
|
|||||||
let open_status: String
|
let open_status: String
|
||||||
let intra_loc_hours: [HourlyOccupancy]
|
let intra_loc_hours: [HourlyOccupancy]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Struct used to represent a day and its hours as strings. Type used for the hours of today and the next 6 days used in DetailView.
|
||||||
|
struct WeeklyHours: Hashable {
|
||||||
|
let day: String
|
||||||
|
let date: Date
|
||||||
|
let timeStrings: [String]
|
||||||
|
}
|
||||||
|
5
RIT Dining/RIT Dining.entitlements
Normal file
5
RIT Dining/RIT Dining.entitlements
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
@ -9,56 +9,55 @@ import SwiftUI
|
|||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
struct DetailView: View {
|
struct DetailView: View {
|
||||||
@Binding var location: DiningLocation
|
@State var locationId: Int
|
||||||
@Environment(Favorites.self) var favorites
|
@Environment(Favorites.self) var favorites
|
||||||
@State private var isLoading: Bool = true
|
@Environment(DiningModel.self) var model
|
||||||
@State private var rotationDegrees: Double = 0
|
|
||||||
@State private var showingSafari: Bool = false
|
@State private var showingSafari: Bool = false
|
||||||
@State private var openString: String = ""
|
@State private var openString: String = ""
|
||||||
@State private var weeklyHours: [[String]] = []
|
|
||||||
@State private var occupancyLoading: Bool = true
|
@State private var occupancyLoading: Bool = true
|
||||||
@State private var occupancyPercentage: Double = 0.0
|
@State private var occupancyPercentage: Double = 0.0
|
||||||
private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
||||||
|
|
||||||
private var animation: Animation {
|
// This gets the location that we're meant to be displaying details about using the provided ID.
|
||||||
.linear
|
private var location: DiningLocation {
|
||||||
.speed(0.1)
|
return model.locationsByDay[0].first { $0.id == locationId }!
|
||||||
.repeatForever(autoreverses: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function is now actaully async and iterative! Wow! It doesn't suck ass anymore!
|
// This creates a list of the time strings for the current day and following 6 days to display in the "Upcoming Hours" section.
|
||||||
private func getWeeklyHours() async {
|
// I realized that it makes a lot more sense to do today + 6 rather than just the current calendar week's hours, because who
|
||||||
let calendar = Calendar.current
|
// cares what Tuesday's hours were on Saturday, you want to know what Sunday's hours will be.
|
||||||
let today = calendar.startOfDay(for: Date())
|
private var weeklyHours: [WeeklyHours] {
|
||||||
let dayOfWeek = calendar.component(.weekday, from: today)
|
var newWeeklyHours: [WeeklyHours] = []
|
||||||
let week = calendar.range(of: .weekday, in: .weekOfYear, for: today)!
|
for day in model.locationsByDay {
|
||||||
.compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) }
|
for location in day {
|
||||||
var newWeeklyHours: [[String]] = []
|
if location.id == locationId {
|
||||||
for day in week {
|
let weekdayFormatter = DateFormatter()
|
||||||
let date_string = day.formatted(.iso8601
|
weekdayFormatter.dateFormat = "EEEE"
|
||||||
.year().month().day()
|
if let times = location.diningTimes, !times.isEmpty {
|
||||||
.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] = []
|
var timeStrings: [String] = []
|
||||||
for time in times {
|
for time in times {
|
||||||
timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
|
timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))")
|
||||||
}
|
}
|
||||||
newWeeklyHours.append(timeStrings)
|
newWeeklyHours.append(
|
||||||
|
WeeklyHours(
|
||||||
|
day: weekdayFormatter.string(from: location.date),
|
||||||
|
date: location.date,
|
||||||
|
timeStrings: timeStrings
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
newWeeklyHours.append(["Closed"])
|
newWeeklyHours.append(
|
||||||
}
|
WeeklyHours(
|
||||||
case .failure(let error):
|
day: weekdayFormatter.string(from: location.date),
|
||||||
print(error)
|
date: location.date,
|
||||||
|
timeStrings: ["Closed"]
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
weeklyHours = newWeeklyHours
|
}
|
||||||
isLoading = false
|
}
|
||||||
print(weeklyHours)
|
return newWeeklyHours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still a little broken, does not work for refresh. Need to fix.
|
||||||
private func getOccupancy() async {
|
private func getOccupancy() async {
|
||||||
// Only fetch occupancy data if the location is actually open right now. Otherwise, just exit early and hide the spinner.
|
// 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 {
|
if location.open == .open || location.open == .closingSoon {
|
||||||
@ -76,34 +75,7 @@ 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 {
|
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 {
|
ScrollView {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
@ -246,16 +218,16 @@ struct DetailView: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("This Week's Hours")
|
Text("Upcoming Hours")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
ForEach(weeklyHours.indices, id: \.self) { index in
|
ForEach(weeklyHours, id: \.self) { day in
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("\(daysOfWeek[index])")
|
Text(day.day)
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack {
|
VStack {
|
||||||
ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in
|
ForEach(day.timeStrings, id: \.self) { timeString in
|
||||||
Text(weeklyHours[index][innerIndex])
|
Text(timeString)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -280,23 +252,12 @@ struct DetailView: View {
|
|||||||
SafariView(url: URL(string: location.mapsUrl)!)
|
SafariView(url: URL(string: location.mapsUrl)!)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await getWeeklyHours()
|
do {
|
||||||
|
try await model.getHoursByDay()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
await getOccupancy()
|
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))
|
|
||||||
//}
|
|
||||||
|
115
RIT Dining/Views/LocationList.swift
Normal file
115
RIT Dining/Views/LocationList.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -13,112 +13,62 @@ struct IdentifiableURL: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VisitingChefs: View {
|
struct VisitingChefs: View {
|
||||||
|
@Environment(DiningModel.self) var model
|
||||||
@State private var locationsWithChefs: [DiningLocation] = []
|
@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 safariUrl: IdentifiableURL?
|
||||||
@State private var isTomorrow: Bool = false
|
@State private var chefDays: [String] = []
|
||||||
|
@State private var focusedIndex: Int = 0
|
||||||
|
|
||||||
private var animation: Animation {
|
// Builds a list of days that each contain a list of dining locations that have visiting chefs to make displaying them
|
||||||
.linear
|
// as easy as possible.
|
||||||
.speed(0.1)
|
private var locationsWithChefsByDay: [[DiningLocation]] {
|
||||||
.repeatForever(autoreverses: false)
|
var locationsWithChefsByDay = [[DiningLocation]]()
|
||||||
}
|
for day in model.locationsByDay {
|
||||||
|
var locationsWithChefs = [DiningLocation]()
|
||||||
// Asynchronously fetch the data for all of the locations on the given date (only ever today or tomorrow) to get the visiting chef
|
for location in day {
|
||||||
// information.
|
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||||
private func getDiningDataForDate(date: String) async {
|
locationsWithChefs.append(location)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
locationsWithChefs = newDiningLocations
|
locationsWithChefsByDay.append(locationsWithChefs)
|
||||||
isLoading = false
|
|
||||||
case .failure(let error):
|
|
||||||
print(error)
|
|
||||||
}
|
}
|
||||||
}
|
return locationsWithChefsByDay
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
if !isTomorrow {
|
Button(action: {
|
||||||
Text("Today's Visiting Chefs")
|
focusedIndex -= 1
|
||||||
|
}) {
|
||||||
|
Image(systemName: "chevron.left.circle")
|
||||||
.font(.title)
|
.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()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(Animation.linear.speed(1.5)) {
|
focusedIndex += 1
|
||||||
if isTomorrow {
|
|
||||||
daySwitcherRotation = 0.0
|
|
||||||
} else {
|
|
||||||
daySwitcherRotation = 180.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isTomorrow.toggle()
|
|
||||||
Task {
|
|
||||||
await getDiningData()
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.right.circle")
|
Image(systemName: "chevron.right.circle")
|
||||||
.rotationEffect(.degrees(daySwitcherRotation))
|
|
||||||
.font(.title)
|
.font(.title)
|
||||||
}
|
}
|
||||||
|
.disabled(focusedIndex == 6)
|
||||||
}
|
}
|
||||||
if isLoading {
|
if locationsWithChefsByDay[focusedIndex].isEmpty {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "fork.knife.circle")
|
Divider()
|
||||||
.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 {
|
|
||||||
Text("No visiting chefs today")
|
Text("No visiting chefs today")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach(locationsWithChefs, id: \.self) { location in
|
}
|
||||||
|
ForEach(locationsWithChefsByDay[focusedIndex], id: \.self) { location in
|
||||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Divider()
|
Divider()
|
||||||
@ -139,7 +89,7 @@ struct VisitingChefs: View {
|
|||||||
Text(chef.name)
|
Text(chef.name)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
if !isTomorrow {
|
if focusedIndex == 0 {
|
||||||
switch chef.status {
|
switch chef.status {
|
||||||
case .hereNow:
|
case .hereNow:
|
||||||
Text("Here Now")
|
Text("Here Now")
|
||||||
@ -158,7 +108,7 @@ struct VisitingChefs: View {
|
|||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Arriving Tomorrow")
|
Text("Arriving on \(weekdayFromDate.string(from: model.daysRepresented[focusedIndex]))")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
Text("•")
|
Text("•")
|
||||||
@ -173,17 +123,17 @@ struct VisitingChefs: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
}
|
}
|
||||||
.sheet(item: $safariUrl) { url in
|
.sheet(item: $safariUrl) { url in
|
||||||
SafariView(url: url.url)
|
SafariView(url: url.url)
|
||||||
}
|
}
|
||||||
.task {
|
|
||||||
await getDiningData()
|
|
||||||
}
|
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await getDiningData()
|
do {
|
||||||
|
try await model.getHoursByDay()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
78
RIT Dining/Views/VisitingChefsPush.swift
Normal file
78
RIT Dining/Views/VisitingChefsPush.swift
Normal 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()
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user