Added filtering options to dining menu view

There is now a filter button by the search bar in the menu view for locations! This opens a menu that allows you to filter by diets and to filter out any allergens that you need to avoid. These options are all written to UserDefaults, allowing you to set your options once and have them persist across menus and sessions.
Also started on some refactors, these will be furthered in a later commit.
This commit is contained in:
2025-11-12 00:18:09 -05:00
parent 85aa9e636d
commit 32203033b6
13 changed files with 228 additions and 27 deletions

View File

@@ -0,0 +1,83 @@
//
// MenuDietaryRestrictionsSheet.swift
// RIT Dining
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
struct MenuDietaryRestrictionsSheet: View {
@Environment(\.dismiss) var dismiss
@Binding var dietaryRestrictionsModel: MenuDietaryRestrictionsModel
var body: some View {
NavigationView {
Form {
Section(header: Text("Diet")) {
Toggle(isOn: Binding(
get: {
dietaryRestrictionsModel.filteredDietaryMarkers.contains("Beef")
},
set: { isOn in
if isOn {
dietaryRestrictionsModel.filteredDietaryMarkers.insert("Beef")
} else {
dietaryRestrictionsModel.filteredDietaryMarkers.remove("Beef")
}
} )
) {
Text("No Beef")
}
Toggle(isOn: Binding(
get: {
dietaryRestrictionsModel.filteredDietaryMarkers.contains("Pork")
},
set: { isOn in
if isOn {
dietaryRestrictionsModel.filteredDietaryMarkers.insert("Pork")
} else {
dietaryRestrictionsModel.filteredDietaryMarkers.remove("Pork")
}
} )
) {
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")
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
//
// MenuItemView.swift
// RIT Dining
//
// 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")
)
}

View File

@@ -0,0 +1,236 @@
//
// MenuView.swift
// RIT Dining
//
// 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] = []
@State 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 dietary restrictions, starting with pork/beef since those are tagged.
if !dietaryRestrictionsModel.filteredDietaryMarkers.isEmpty {
newItems = newItems.filter { item in
for marker in dietaryRestrictionsModel.filteredDietaryMarkers {
if item.dietaryMarkers.contains(marker) {
return false
}
}
return true
}
}
// 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 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")
}
}
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)
}