mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-04 21:25:27 -05:00
Replace all instances of "RIT Dining" with "TigerDine"
The project and some files were still named that way, so that's been fixed now. The bundle ID is stuck that way forever but oh well. Nobody will see that.
This commit is contained in:
70
TigerDine/Views/AboutView.swift
Normal file
70
TigerDine/Views/AboutView.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// AboutView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/12/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
let appVersionString: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
|
||||
let buildNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Image("Icon")
|
||||
.resizable()
|
||||
.frame(width: 128, height: 128)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
Text("TigerDine")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
Text("An unofficial RIT Dining app")
|
||||
.font(.subheadline)
|
||||
Text("Version \(appVersionString) (\(buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Dining locations, their descriptions, and their opening hours are sourced from the RIT student-run TigerCenter API. Building occupancy information is sourced from the official RIT maps API. Menu and nutritional information is sourced from the data provided to FD MealPlanner by RIT Dining through the FD MealPlanner API.")
|
||||
Text("This app is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Rochester Institute of Technology. This app is student created and maintained.")
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://github.com/NinjaCheetah/TigerDine")!)
|
||||
}) {
|
||||
Label("Source Code", systemImage: "network")
|
||||
}
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://tigercenter.rit.edu/")!)
|
||||
}) {
|
||||
Label("TigerCenter", systemImage: "fork.knife.circle")
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://maps.rit.edu/")!)
|
||||
}) {
|
||||
Label("Official RIT Map", systemImage: "map")
|
||||
}
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://fdmealplanner.com/")!)
|
||||
}) {
|
||||
Label("FD MealPlanner", systemImage: "menucard")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("About")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AboutView()
|
||||
}
|
||||
285
TigerDine/Views/DetailView.swift
Normal file
285
TigerDine/Views/DetailView.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// DetailView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/1/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
struct DetailView: View {
|
||||
@State var locationId: Int
|
||||
@Environment(Favorites.self) var favorites
|
||||
@Environment(DiningModel.self) var model
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showingSafari: Bool = false
|
||||
@State private var occupancyLoading: Bool = true
|
||||
@State private var occupancyPercentage: Double = 0.0
|
||||
|
||||
// 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 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"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(location.name)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
Text(location.summary)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading) {
|
||||
switch location.open {
|
||||
case .open:
|
||||
Text("Open")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.green)
|
||||
case .closed:
|
||||
Text("Closed")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
case .openingSoon:
|
||||
Text("Opening Soon")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
case .closingSoon:
|
||||
Text("Closing Soon")
|
||||
.font(.title3)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
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)
|
||||
#endif
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
HStack(alignment: .center) {
|
||||
// Favorites toggle button.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// THIS FEATURE DISABLED AT RIT'S REQUEST FOR SECURITY REASONS.
|
||||
// No hard feelings or anything though, I get it.
|
||||
// // Open OnDemand. Unfortunately the locations use arbitrary IDs, so just open the main OnDemand page.
|
||||
// Button(action: {
|
||||
// openURL(URL(string: "https://ondemand.rit.edu")!)
|
||||
// }) {
|
||||
// Image(systemName: "cart")
|
||||
// .font(.title3)
|
||||
// }
|
||||
// .disabled(location.open == .closed || location.open == .openingSoon)
|
||||
// Open this location on the RIT map in embedded Safari.
|
||||
Button(action: {
|
||||
showingSafari = true
|
||||
}) {
|
||||
Image(systemName: "map")
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
if let fdmpIds = location.fdmpIds {
|
||||
NavigationLink(destination: MenuView(accountId: fdmpIds.accountId, locationId: fdmpIds.locationId)) {
|
||||
Text("View Menu")
|
||||
}
|
||||
.padding(.top, 5)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.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("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)
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
109
TigerDine/Views/DonationView.swift
Normal file
109
TigerDine/Views/DonationView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// DonationView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/17/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DonationView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var symbolDrawn: Bool = true
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
HStack {
|
||||
if #available(iOS 26.0, *) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.drawOn, isActive: symbolDrawn)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
symbolDrawn = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Text("Donate")
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.font(.title)
|
||||
Text("The TigerDine app is free and open source software!")
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("However, the Apple Developer Program is expensive, and I paid $106.19 pretty much just to distribute this app and nothing else. If you can, I'd appreciate it if you wouldn't mind tossing a coin or two my way to help and make that expense a little less painful.")
|
||||
.multilineTextAlignment(.center)
|
||||
Text("No pressure though.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://ko-fi.com/ninjacheetah")!)
|
||||
}) {
|
||||
HStack(alignment: .center) {
|
||||
Image("kofiLogo")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading) {
|
||||
Text("Tip Me on Ko-fi")
|
||||
.fontWeight(.bold)
|
||||
Text("Chip in as much or as little as you'd like!")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
}
|
||||
.padding(.all, 6)
|
||||
.background (
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://paypal.me/NinjaCheetahX")!)
|
||||
}) {
|
||||
HStack(alignment: .center) {
|
||||
Image("paypalLogo")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading) {
|
||||
Text("Send Me Money Directly")
|
||||
.fontWeight(.bold)
|
||||
Text("I have nothing specific to say here!")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
}
|
||||
.padding(.all, 6)
|
||||
.background (
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.toolbar {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DonationView()
|
||||
}
|
||||
105
TigerDine/Views/FoodTruckView.swift
Normal file
105
TigerDine/Views/FoodTruckView.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// FoodTruckView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 10/5/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
struct FoodTruckView: View {
|
||||
@State private var foodTruckEvents: [FoodTruckEvent] = []
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var loadFailed: Bool = false
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@State private var showingSafari: Bool = false
|
||||
|
||||
private var animation: Animation {
|
||||
.linear
|
||||
.speed(0.1)
|
||||
.repeatForever(autoreverses: false)
|
||||
}
|
||||
|
||||
private func doFoodTruckStuff() async {
|
||||
switch await getFoodTruckPage() {
|
||||
case .success(let schedule):
|
||||
foodTruckEvents = parseWeekendFoodTrucks(htmlString: schedule)
|
||||
isLoading = false
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
loadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
VStack {
|
||||
if loadFailed {
|
||||
Image(systemName: "wifi.exclamationmark.circle")
|
||||
.resizable()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
Text("An error occurred while fetching food truck data. Please check your network connection and try again.")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Image(systemName: "truck.box")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.onAppear {
|
||||
withAnimation(animation) {
|
||||
rotationDegrees = 360.0
|
||||
}
|
||||
}
|
||||
Text("One moment...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await doFoodTruckStuff()
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Weekend Food Trucks")
|
||||
.font(.title)
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingSafari = true
|
||||
}) {
|
||||
Image(systemName: "network")
|
||||
.foregroundStyle(.accent)
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
ForEach(foodTruckEvents, id: \.self) { event in
|
||||
Divider()
|
||||
Text(visitingChefDateDisplay.string(from: event.date))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("\(dateDisplay.string(from: event.openTime)) - \(dateDisplay.string(from: event.closeTime))")
|
||||
.font(.title3)
|
||||
ForEach(event.trucks, id: \.self) { truck in
|
||||
Text(truck)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
Text("Food truck data is sourced directly from the RIT Events website, and may not be presented correctly. Use the button in the top right to access the RIT Events website directly to see the original source of the information.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.sheet(isPresented: $showingSafari) {
|
||||
SafariView(url: URL(string: "https://www.rit.edu/events/weekend-food-trucks")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
TigerDine/Views/LocationList.swift
Normal file
115
TigerDine/Views/LocationList.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// LocationList.swift
|
||||
// TigerDine
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
TigerDine/Views/Menus/MenuDietaryRestrictionsSheet.swift
Normal file
61
TigerDine/Views/Menus/MenuDietaryRestrictionsSheet.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MenuDietaryRestrictionsSheet.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 11/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuDietaryRestrictionsSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@ObservedObject var dietaryRestrictionsModel: MenuDietaryRestrictionsModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("Diet")) {
|
||||
Toggle(isOn: $dietaryRestrictionsModel.noBeef) {
|
||||
Text("No Beef")
|
||||
}
|
||||
Toggle(isOn: $dietaryRestrictionsModel.noPork) {
|
||||
Text("No Pork")
|
||||
}
|
||||
Toggle(isOn: $dietaryRestrictionsModel.isVegetarian) {
|
||||
Text("Vegetarian")
|
||||
}
|
||||
Toggle(isOn: $dietaryRestrictionsModel.isVegan) {
|
||||
Text("Vegan")
|
||||
}
|
||||
}
|
||||
Section(header: Text("Allergens")) {
|
||||
ForEach(Allergen.allCases, id: \.self) { allergen in
|
||||
Toggle(isOn: Binding(
|
||||
get: {
|
||||
dietaryRestrictionsModel.dietaryRestrictions.contains(allergen)
|
||||
},
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
dietaryRestrictionsModel.dietaryRestrictions.add(allergen)
|
||||
} else {
|
||||
dietaryRestrictionsModel.dietaryRestrictions.remove(allergen)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text(allergen.rawValue.capitalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Menu Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
TigerDine/Views/Menus/MenuItemView.swift
Normal file
117
TigerDine/Views/Menus/MenuItemView.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// MenuItemView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 11/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuItemView: View {
|
||||
@State var menuItem: FDMenuItem
|
||||
|
||||
private var infoString: String {
|
||||
// Calories SHOULD always be available, so start there.
|
||||
var str = "\(menuItem.calories) Cal • "
|
||||
// Price might be $0.00, so don't display it if that's the case because that's obviously wrong. RIT Dining would never give
|
||||
// us free food!
|
||||
if menuItem.price == 0.0 {
|
||||
str += "Price Unavailable"
|
||||
} else {
|
||||
str += "$\(String(format: "%.2f", menuItem.price))"
|
||||
}
|
||||
// Same with the price, the serving size might be 0 which is also wrong so don't display that.
|
||||
if menuItem.servingSize != 0 {
|
||||
str += " • \(menuItem.servingSize) \(menuItem.servingSizeUnit)"
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(menuItem.name)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
Text(menuItem.category)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(infoString)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(menuItem.dietaryMarkers, id: \.self) { dietaryMarker in
|
||||
Text(dietaryMarker)
|
||||
.foregroundStyle(Color.white)
|
||||
.font(.caption)
|
||||
.padding(5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill({
|
||||
switch dietaryMarker {
|
||||
case "Vegan", "Vegetarian":
|
||||
return Color.green
|
||||
default:
|
||||
return Color.orange
|
||||
}
|
||||
}())
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
if !menuItem.allergens.isEmpty {
|
||||
Text("Allergens")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
Text(menuItem.allergens.joined(separator: ", "))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Nutrition Facts")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
ForEach(menuItem.nutritionalEntries, id: \.self) { entry in
|
||||
HStack(alignment: .top) {
|
||||
Text(entry.type)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", entry.amount))\(entry.unit)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
Text("Ingredients")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
Text(menuItem.ingredients)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.navigationTitle("Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuItemView(
|
||||
menuItem: FDMenuItem(
|
||||
id: 0,
|
||||
name: "Chocolate Chip Muffin",
|
||||
exactName: "Muffin Chocolate Chip Thaw and Serve A; Case; 72 Ounce; 12",
|
||||
category: "Baked Goods",
|
||||
allergens: ["Wheat", "Gluten", "Egg", "Milk", "Soy"],
|
||||
calories: 470,
|
||||
nutritionalEntries: [FDNutritionalEntry(type: "Example", amount: 0.0, unit: "g")],
|
||||
dietaryMarkers: ["Vegetarian"],
|
||||
ingredients: "Some ingredients that you'd expect to find inside of a chocolate chip muffin",
|
||||
price: 2.79,
|
||||
servingSize: 1,
|
||||
servingSizeUnit: "Each")
|
||||
)
|
||||
}
|
||||
237
TigerDine/Views/Menus/MenuView.swift
Normal file
237
TigerDine/Views/Menus/MenuView.swift
Normal file
@@ -0,0 +1,237 @@
|
||||
//
|
||||
// MenuView.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 11/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MenuView: View {
|
||||
@State var accountId: Int
|
||||
@State var locationId: Int
|
||||
@State private var menuItems: [FDMenuItem] = []
|
||||
@State private var searchText: String = ""
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var loadFailed: Bool = false
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@State private var selectedMealPeriod: Int = 0
|
||||
@State private var openPeriods: [Int] = []
|
||||
@StateObject private var dietaryRestrictionsModel = MenuDietaryRestrictionsModel()
|
||||
@State private var showingDietaryRestrictionsSheet: Bool = false
|
||||
|
||||
private var animation: Animation {
|
||||
.linear
|
||||
.speed(0.1)
|
||||
.repeatForever(autoreverses: false)
|
||||
}
|
||||
|
||||
func getOpenPeriods() async {
|
||||
// Only run this if we haven't already gotten the open periods. This is somewhat of a bandaid solution to the issue of
|
||||
// fetching this information more than once, but hey it works!
|
||||
if openPeriods.isEmpty {
|
||||
switch await getFDMealPlannerOpenings(locationId: locationId) {
|
||||
case .success(let openingResults):
|
||||
openPeriods = openingResults.data.map { Int($0.id) }
|
||||
selectedMealPeriod = openPeriods[0]
|
||||
// Since this only runs once when the view first loads, we can safely use this to call the method to get the data
|
||||
// the first time. This also ensures that it doesn't happen until we have the opening periods collected.
|
||||
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
loadFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getMenuForPeriod(mealPeriodId: Int) async {
|
||||
switch await getFDMealPlannerMenu(locationId: locationId, accountId: accountId, mealPeriodId: mealPeriodId) {
|
||||
case .success(let menus):
|
||||
menuItems = parseFDMealPlannerMenu(menu: menus)
|
||||
isLoading = false
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
loadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
func getPriceString(price: Double) -> String {
|
||||
if price == 0.0 {
|
||||
return "Price Unavailable"
|
||||
} else {
|
||||
return "$\(String(format: "%.2f", price))"
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredMenuItems: [FDMenuItem] {
|
||||
var newItems = menuItems
|
||||
// Filter out allergens.
|
||||
newItems = newItems.filter { item in
|
||||
if !item.allergens.isEmpty {
|
||||
for allergen in item.allergens {
|
||||
if let checkingAllergen = Allergen(rawValue: allergen.lowercased()) {
|
||||
if dietaryRestrictionsModel.dietaryRestrictions.contains(checkingAllergen) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Filter down to vegetarian/vegan only if enabled.
|
||||
if dietaryRestrictionsModel.isVegetarian || dietaryRestrictionsModel.isVegan {
|
||||
newItems = newItems.filter { item in
|
||||
if dietaryRestrictionsModel.isVegetarian && (item.dietaryMarkers.contains("Vegetarian") || item.dietaryMarkers.contains("Vegan")) {
|
||||
return true
|
||||
} else if dietaryRestrictionsModel.isVegan && (item.dietaryMarkers.contains("Vegan")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Filter out pork/beef.
|
||||
if dietaryRestrictionsModel.noBeef {
|
||||
newItems = newItems.filter { item in
|
||||
item.dietaryMarkers.contains("Beef") == false
|
||||
}
|
||||
}
|
||||
if dietaryRestrictionsModel.noPork {
|
||||
newItems = newItems.filter { item in
|
||||
item.dietaryMarkers.contains("Pork") == false
|
||||
}
|
||||
}
|
||||
// Filter down to search contents.
|
||||
newItems = newItems.filter { item in
|
||||
let searchedLocations = searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
|
||||
return searchedLocations
|
||||
}
|
||||
newItems.sort { firstItem, secondItem in
|
||||
return firstItem.name.localizedCaseInsensitiveCompare(secondItem.name) == .orderedAscending
|
||||
}
|
||||
return newItems
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
VStack {
|
||||
if loadFailed {
|
||||
Image(systemName: "wifi.exclamationmark.circle")
|
||||
.resizable()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
Text("An error occurred while fetching the menu. Please check your network connection and try again.")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Image(systemName: "fork.knife.circle")
|
||||
.resizable()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.onAppear {
|
||||
withAnimation(animation) {
|
||||
rotationDegrees = 360.0
|
||||
}
|
||||
}
|
||||
Text("One moment...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await getOpenPeriods()
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
VStack {
|
||||
if !menuItems.isEmpty {
|
||||
List {
|
||||
Section {
|
||||
ForEach(filteredMenuItems) { item in
|
||||
NavigationLink(destination: MenuItemView(menuItem: item)) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
ForEach(item.dietaryMarkers, id: \.self) { dietaryMarker in
|
||||
Text(dietaryMarker)
|
||||
.foregroundStyle(Color.white)
|
||||
.font(.caption)
|
||||
.padding(5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill({
|
||||
switch dietaryMarker {
|
||||
case "Vegan", "Vegetarian":
|
||||
return Color.green
|
||||
default:
|
||||
return Color.orange
|
||||
}
|
||||
}())
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("\(item.calories) Cal • \(getPriceString(price: item.price))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Menu")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.searchable(text: $searchText, prompt: "Search")
|
||||
} else {
|
||||
Image(systemName: "clock.badge.exclamationmark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
Text("No menu is available for the selected meal period today. Try selecting a different meal period.")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Picker("Meal Period", selection: $selectedMealPeriod) {
|
||||
ForEach(openPeriods, id: \.self) { period in
|
||||
Text(fdmpMealPeriodsMap[period]!).tag(period)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "clock")
|
||||
Text("Meal Periods")
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button(action: {
|
||||
showingDietaryRestrictionsSheet = true
|
||||
}) {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
}
|
||||
if #unavailable(iOS 26.0) {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
if #available(iOS 26.0, *) {
|
||||
ToolbarSpacer(.flexible, placement: .bottomBar)
|
||||
DefaultToolbarItem(kind: .search, placement: .bottomBar)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedMealPeriod) {
|
||||
rotationDegrees = 0
|
||||
isLoading = true
|
||||
Task {
|
||||
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingDietaryRestrictionsSheet) {
|
||||
MenuDietaryRestrictionsSheet(dietaryRestrictionsModel: dietaryRestrictionsModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuView(accountId: 1, locationId: 1)
|
||||
}
|
||||
143
TigerDine/Views/Visiting Chefs/VisitingChefs.swift
Normal file
143
TigerDine/Views/Visiting Chefs/VisitingChefs.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// VisitingChefs.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
struct VisitingChefs: View {
|
||||
@Environment(DiningModel.self) var model
|
||||
@State private var locationsWithChefs: [DiningLocation] = []
|
||||
@State private var safariUrl: IdentifiableURL?
|
||||
@State private var chefDays: [String] = []
|
||||
@State private var focusedIndex: Int = 0
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
locationsWithChefsByDay.append(locationsWithChefs)
|
||||
}
|
||||
return locationsWithChefsByDay
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Button(action: {
|
||||
focusedIndex -= 1
|
||||
}) {
|
||||
Image(systemName: "chevron.left.circle")
|
||||
.font(.title)
|
||||
}
|
||||
.disabled(focusedIndex == 0)
|
||||
Spacer()
|
||||
Text("Visiting Chefs for \(visitingChefDateDisplay.string(from: model.daysRepresented[focusedIndex]))")
|
||||
.font(.title)
|
||||
.fontWeight(.semibold)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
focusedIndex += 1
|
||||
}) {
|
||||
Image(systemName: "chevron.right.circle")
|
||||
.font(.title)
|
||||
}
|
||||
.disabled(focusedIndex == 6)
|
||||
}
|
||||
if locationsWithChefsByDay[focusedIndex].isEmpty {
|
||||
VStack {
|
||||
Divider()
|
||||
Text("No visiting chefs today")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.sheet(item: $safariUrl) { url in
|
||||
SafariView(url: url.url)
|
||||
}
|
||||
.refreshable {
|
||||
do {
|
||||
try await model.getHoursByDay()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VisitingChefs()
|
||||
}
|
||||
148
TigerDine/Views/Visiting Chefs/VisitingChefsPush.swift
Normal file
148
TigerDine/Views/Visiting Chefs/VisitingChefsPush.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// VisitingChefsPush.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 10/1/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VisitingChefPush: View {
|
||||
@AppStorage("visitingChefPushEnabled") var pushEnabled: Bool = false
|
||||
@AppStorage("notificationOffset") var notificationOffset: Int = 2
|
||||
@Environment(DiningModel.self) var model
|
||||
@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 TigerDine to use this feature." : "")) {
|
||||
Toggle(isOn: $pushEnabled) {
|
||||
Text("Notifications Enabled")
|
||||
}
|
||||
.disabled(!pushAllowed)
|
||||
.onChange(of: pushEnabled) {
|
||||
if pushEnabled {
|
||||
Task {
|
||||
await model.scheduleAllPushes()
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
await model.cancelAllPushes()
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Send Notifications", selection: $notificationOffset) {
|
||||
Text("1 Hour Before").tag(1)
|
||||
Text("2 Hours Before").tag(2)
|
||||
Text("3 Hours Before").tag(3)
|
||||
}
|
||||
.disabled(!pushAllowed || !pushEnabled)
|
||||
.onChange(of: notificationOffset) {
|
||||
Task {
|
||||
// If we changed the offset, we need to reschedule everything.
|
||||
await model.cancelAllPushes()
|
||||
await model.scheduleAllPushes()
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(footer: Text("Get notified when and where a specific visiting chef will be on campus.")) {
|
||||
ForEach(visitingChefs, id: \.self) { chef in
|
||||
Toggle(isOn: Binding(
|
||||
get: {
|
||||
model.notifyingChefs.contains(chef)
|
||||
},
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
model.notifyingChefs.add(chef)
|
||||
Task {
|
||||
await model.schedulePushesForChef(chef)
|
||||
}
|
||||
} else {
|
||||
model.notifyingChefs.remove(chef)
|
||||
model.visitingChefPushes.cancelPushesForChef(name: chef)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text(chef)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!pushAllowed || !pushEnabled)
|
||||
#if DEBUG
|
||||
Section(header: Text("DEBUG - Scheduled Pushes")) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await model.scheduleAllPushes()
|
||||
}
|
||||
}) {
|
||||
Text("Schedule All")
|
||||
}
|
||||
Button(action: {
|
||||
let uuids = model.visitingChefPushes.pushes.map(\.uuid)
|
||||
Task {
|
||||
await cancelVisitingChefNotifs(uuids: uuids)
|
||||
model.visitingChefPushes.pushes.removeAll()
|
||||
}
|
||||
}) {
|
||||
Text("Cancel All")
|
||||
}
|
||||
.tint(.red)
|
||||
ForEach(model.visitingChefPushes.pushes, id: \.uuid) { push in
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(push.name) at \(push.location)")
|
||||
Text(push.uuid)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(push.startTime) - \(push.endTime)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.swipeActions {
|
||||
Button(action: {
|
||||
Task {
|
||||
await cancelVisitingChefNotifs(uuids: [push.uuid])
|
||||
model.visitingChefPushes.pushes.remove(at: model.visitingChefPushes.pushes.firstIndex(of: push)!)
|
||||
}
|
||||
}) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
do {
|
||||
try await center.requestAuthorization(options: [.alert, .sound])
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
let settings = await center.notificationSettings()
|
||||
guard (settings.authorizationStatus == .authorized) else { pushEnabled = false; return }
|
||||
pushAllowed = true
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VisitingChefPush()
|
||||
}
|
||||
Reference in New Issue
Block a user