diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index cb8ddb6..99ce77a 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -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; diff --git a/RIT Dining/Data/DietaryRestrictions.swift b/RIT Dining/Data/DietaryRestrictions.swift new file mode 100644 index 0000000..29fd5aa --- /dev/null +++ b/RIT Dining/Data/DietaryRestrictions.swift @@ -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 + 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) + } +} diff --git a/RIT Dining/Data/MenuDietaryRestrictionsModel.swift b/RIT Dining/Data/MenuDietaryRestrictionsModel.swift new file mode 100644 index 0000000..1ba937e --- /dev/null +++ b/RIT Dining/Data/MenuDietaryRestrictionsModel.swift @@ -0,0 +1,16 @@ +// +// MenuDietaryRestrictionsModel.swift +// RIT Dining +// +// Created by Campbell on 11/11/25. +// + +import SwiftUI + +@Observable +class MenuDietaryRestrictionsModel { + var filteredDietaryMarkers: Set = [] + var dietaryRestrictions = DietaryRestrictions() + var isVegetarian: Bool = false + var isVegan: Bool = false +} diff --git a/RIT Dining/Data/Model.swift b/RIT Dining/Data/TigerCenterModel.swift similarity index 98% rename from RIT Dining/Data/Model.swift rename to RIT Dining/Data/TigerCenterModel.swift index cb61d3a..b40243e 100644 --- a/RIT Dining/Data/Model.swift +++ b/RIT Dining/Data/TigerCenterModel.swift @@ -1,5 +1,5 @@ // -// Model.swift +// TigerCenterModel.swift // RIT Dining // // Created by Campbell on 10/1/25. diff --git a/RIT Dining/Data/Types/FoodTruckTypes.swift b/RIT Dining/Data/Types/FoodTruckTypes.swift index 1613380..c533880 100644 --- a/RIT Dining/Data/Types/FoodTruckTypes.swift +++ b/RIT Dining/Data/Types/FoodTruckTypes.swift @@ -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 diff --git a/RIT Dining/Data/Types/TigerCenterTypes.swift b/RIT Dining/Data/Types/TigerCenterTypes.swift index b7bf0b6..0a09af5 100644 --- a/RIT Dining/Data/Types/TigerCenterTypes.swift +++ b/RIT Dining/Data/Types/TigerCenterTypes.swift @@ -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 diff --git a/RIT Dining/Views/AboutView.swift b/RIT Dining/Views/AboutView.swift index 19b98e0..6fd8d57 100644 --- a/RIT Dining/Views/AboutView.swift +++ b/RIT Dining/Views/AboutView.swift @@ -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") } diff --git a/RIT Dining/Views/DonationView.swift b/RIT Dining/Views/DonationView.swift index 7b8459d..51dd327 100644 --- a/RIT Dining/Views/DonationView.swift +++ b/RIT Dining/Views/DonationView.swift @@ -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.") diff --git a/RIT Dining/Views/Menus/MenuDietaryRestrictionsSheet.swift b/RIT Dining/Views/Menus/MenuDietaryRestrictionsSheet.swift new file mode 100644 index 0000000..19bdefd --- /dev/null +++ b/RIT Dining/Views/Menus/MenuDietaryRestrictionsSheet.swift @@ -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") + } + } + } + } +} diff --git a/RIT Dining/Views/MenuItemView.swift b/RIT Dining/Views/Menus/MenuItemView.swift similarity index 97% rename from RIT Dining/Views/MenuItemView.swift rename to RIT Dining/Views/Menus/MenuItemView.swift index 04aab64..3cecff1 100644 --- a/RIT Dining/Views/MenuItemView.swift +++ b/RIT Dining/Views/Menus/MenuItemView.swift @@ -68,9 +68,6 @@ struct MenuItemView: View { .foregroundStyle(.secondary) .textSelection(.enabled) .padding(.bottom, 12) - .onAppear { - print(menuItem.allergens) - } } VStack(alignment: .leading) { Text("Nutrition Facts") diff --git a/RIT Dining/Views/MenuView.swift b/RIT Dining/Views/Menus/MenuView.swift similarity index 74% rename from RIT Dining/Views/MenuView.swift rename to RIT Dining/Views/Menus/MenuView.swift index 0651cea..8285d3d 100644 --- a/RIT Dining/Views/MenuView.swift +++ b/RIT Dining/Views/Menus/MenuView.swift @@ -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) + } } } } diff --git a/RIT Dining/Views/VisitingChefs.swift b/RIT Dining/Views/Visiting Chefs/VisitingChefs.swift similarity index 100% rename from RIT Dining/Views/VisitingChefs.swift rename to RIT Dining/Views/Visiting Chefs/VisitingChefs.swift diff --git a/RIT Dining/Views/VisitingChefsPush.swift b/RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift similarity index 100% rename from RIT Dining/Views/VisitingChefsPush.swift rename to RIT Dining/Views/Visiting Chefs/VisitingChefsPush.swift