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:
Campbell 2025-11-12 00:18:09 -05:00
parent 85aa9e636d
commit 32203033b6
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
13 changed files with 228 additions and 27 deletions

View File

@ -265,7 +265,7 @@
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -300,7 +300,7 @@
CODE_SIGN_ENTITLEMENTS = "RIT Dining/RIT Dining.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;

View File

@ -0,0 +1,51 @@
//
// DietaryRestrictions.swift
// RIT Dining
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
enum Allergen: String, Codable, CaseIterable {
case coconut
case egg
case gluten
case milk
case peanut
case sesame
case shellfish
case soy
case treenut
case wheat
}
@Observable
class DietaryRestrictions {
private var dietaryRestrictions: Set<String>
private let key = "DietaryRestrictions"
init() {
let favorites = UserDefaults.standard.array(forKey: key) as? [String] ?? [String]()
dietaryRestrictions = Set(favorites)
}
func contains(_ restriction: Allergen) -> Bool {
dietaryRestrictions.contains(restriction.rawValue)
}
func add(_ restriction: Allergen) {
dietaryRestrictions.insert(restriction.rawValue)
save()
}
func remove(_ restriction: Allergen) {
dietaryRestrictions.remove(restriction.rawValue)
save()
}
func save() {
let favorites = Array(dietaryRestrictions)
UserDefaults.standard.set(favorites, forKey: key)
}
}

View File

@ -0,0 +1,16 @@
//
// MenuDietaryRestrictionsModel.swift
// RIT Dining
//
// Created by Campbell on 11/11/25.
//
import SwiftUI
@Observable
class MenuDietaryRestrictionsModel {
var filteredDietaryMarkers: Set<String> = []
var dietaryRestrictions = DietaryRestrictions()
var isVegetarian: Bool = false
var isVegan: Bool = false
}

View File

@ -1,5 +1,5 @@
//
// Model.swift
// TigerCenterModel.swift
// RIT Dining
//
// Created by Campbell on 10/1/25.

View File

@ -7,7 +7,7 @@
import Foundation
// A weekend food trucks even representing when it's happening and what food trucks will be there.
/// A weekend food trucks even representing when it's happening and what food trucks will be there.
struct FoodTruckEvent: Hashable {
let date: Date
let openTime: Date

View File

@ -7,12 +7,11 @@
import Foundation
// I'll be honest, I am NOT good at representing other people's JSON in my code. This kinda sucks but it gets the job done and can
// be improved later when I feel like it.
/// Struct to parse the response data from the TigerCenter API when getting the information for a dining location.
struct DiningLocationParser: Decodable {
// An individual "event", which is just an open period for the location.
/// An individual "event", which is just an open period for the location.
struct Event: Decodable {
// Hour exceptions for the given event.
/// Hour exceptions for the given event.
struct HoursException: Decodable {
let id: Int
let name: String
@ -27,14 +26,13 @@ struct DiningLocationParser: Decodable {
let daysOfWeek: [String]
let exceptions: [HoursException]?
}
// An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because
// visiting chefs have descriptions but specials do not.
/// An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because visiting chefs have descriptions but specials do not.
struct Menu: Decodable {
let name: String
let description: String?
let category: String
}
// Other basic information to read from a location's JSON that we'll need later.
/// Other basic information to read from a location's JSON that we'll need later.
let id: Int
let mdoId: Int
let name: String
@ -45,12 +43,12 @@ struct DiningLocationParser: Decodable {
let menus: [Menu]
}
// Struct that probably doesn't need to exist but this made parsing the list of location responses easy.
/// Struct that probably doesn't need to exist but this made parsing the list of location responses easy.
struct DiningLocationsParser: Decodable {
let locations: [DiningLocationParser]
}
// Enum to represent the four possible states a given location can be in.
/// Enum to represent the four possible states a given location can be in.
enum OpenStatus {
case open
case closed
@ -58,13 +56,13 @@ enum OpenStatus {
case closingSoon
}
// An individual open period for a location.
/// An individual open period for a location.
struct DiningTimes: Equatable, Hashable {
var openTime: Date
var closeTime: Date
}
// Enum to represent the five possible states a visiting chef can be in.
/// Enum to represent the five possible states a visiting chef can be in.
enum VisitingChefStatus {
case hereNow
case gone
@ -73,7 +71,7 @@ enum VisitingChefStatus {
case leavingSoon
}
// A visiting chef present at a location.
/// A visiting chef present at a location.
struct VisitingChef: Equatable, Hashable {
let name: String
let description: String
@ -82,19 +80,19 @@ struct VisitingChef: Equatable, Hashable {
var status: VisitingChefStatus
}
// A daily special at a location.
/// A daily special at a location.
struct DailySpecial: Equatable, Hashable {
let name: String
let type: String
}
// The IDs required to get the menu for a location from FD MealPlanner. Only present if the location appears in the map.
/// The IDs required to get the menu for a location from FD MealPlanner. Only present if the location appears in the map.
struct FDMPIds: Hashable {
let locationId: Int
let accountId: Int
}
// The basic information about a dining location needed to display it in the app after parsing is finished.
/// The basic information about a dining location needed to display it in the app after parsing is finished.
struct DiningLocation: Identifiable, Hashable {
let id: Int
let mdoId: Int
@ -110,9 +108,9 @@ struct DiningLocation: Identifiable, Hashable {
let dailySpecials: [DailySpecial]?
}
// Parser to read the occupancy data for a location.
/// Parser to read the occupancy data for a location.
struct DiningOccupancyParser: Decodable {
// Represents a per-hour occupancy rating.
/// Represents a per-hour occupancy rating.
struct HourlyOccupancy: Decodable {
let hour: Int
let today: Int
@ -130,7 +128,7 @@ struct DiningOccupancyParser: Decodable {
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 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

View File

@ -24,12 +24,13 @@ struct AboutView: View {
.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.")
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.")
HStack {
Button(action: {
openURL(URL(string: "https://github.com/NinjaCheetah/RIT-Dining")!)
openURL(URL(string: "https://github.com/NinjaCheetah/TigerDine")!)
}) {
Text("Source Code")
}

View File

@ -33,7 +33,7 @@ struct DonationView: View {
.fontWeight(.bold)
}
.font(.title)
Text("The RIT Dining app is free and open source software!")
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.")

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

@ -68,9 +68,6 @@ struct MenuItemView: View {
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.bottom, 12)
.onAppear {
print(menuItem.allergens)
}
}
VStack(alignment: .leading) {
Text("Nutrition Facts")

View File

@ -17,6 +17,8 @@ struct MenuView: View {
@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
@ -63,6 +65,42 @@ struct MenuView: View {
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
@ -164,6 +202,20 @@ struct MenuView: View {
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
@ -172,6 +224,9 @@ struct MenuView: View {
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)
}
}
.sheet(isPresented: $showingDietaryRestrictionsSheet) {
MenuDietaryRestrictionsSheet(dietaryRestrictionsModel: $dietaryRestrictionsModel)
}
}
}
}