diff --git a/RIT Dining.xcodeproj/project.pbxproj b/RIT Dining.xcodeproj/project.pbxproj index 324b703..e9e37c3 100644 --- a/RIT Dining.xcodeproj/project.pbxproj +++ b/RIT Dining.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -274,7 +274,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -291,7 +291,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -308,7 +308,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/RIT Dining/ContentView.swift b/RIT Dining/ContentView.swift index f5083c6..6fe0539 100644 --- a/RIT Dining/ContentView.swift +++ b/RIT Dining/ContentView.swift @@ -7,55 +7,11 @@ import SwiftUI -struct LocationList: View { - let diningLocations: [DiningLocation] - - // I forgot this before and was really confused why all of the times were in UTC. - private let display: DateFormatter = { - let display = DateFormatter() - display.timeZone = TimeZone(identifier: "America/New_York") - display.dateStyle = .none - display.timeStyle = .short - return display - }() - - var body: some View { - ForEach(diningLocations, id: \.self) { location in - NavigationLink(destination: DetailView(location: location)) { - VStack(alignment: .leading) { - Text(location.name) - 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("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))") - .foregroundStyle(.secondary) - } - } else { - Text("Not Open Today") - .foregroundStyle(.secondary) - } - } - } - } - } -} - struct ContentView: View { - // Stored in AppStorage because making this setting persistent makes sense. Some people only ever want to see open locations. + // Save sort/filter options in AppStorage so that they actually get saved. @AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false + @AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false + @State private var favorites = Favorites() @State private var isLoading: Bool = true @State private var loadFailed: Bool = false @State private var showingDonationSheet: Bool = false @@ -71,51 +27,73 @@ struct ContentView: View { } // Asynchronously fetch the data for all of the locations and parse their data to display it. - private func getDiningData() { + private func getDiningData() async { var newDiningLocations: [DiningLocation] = [] getAllDiningInfo(date: nil) { result in - DispatchQueue.global().async { - switch result { - case .success(let locations): - for i in 0.. String { - let lowercased = name.lowercased() - if lowercased.hasPrefix("the ") { - return String(name.dropFirst(4)) - } - return name - } - return removeThe(firstLoc.name).localizedCaseInsensitiveCompare(removeThe(secondLoc.name)) == .orderedAscending - } - lastRefreshed = Date() - isLoading = false - } - case .failure(let error): - print(error) - loadFailed = true + switch result { + case .success(let locations): + for i in 0.. 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 { @@ -132,7 +110,9 @@ struct ContentView: View { .multilineTextAlignment(.center) Button(action: { loadFailed = false - getDiningData() + Task { + await getDiningData() + } }) { Label("Refresh", systemImage: "arrow.clockwise") } @@ -156,23 +136,67 @@ struct ContentView: View { } else { VStack() { List { - // Always show the visiting chef link on iOS 26+, since the bottom mounted search bar makes this work okay. On - // older iOS versions, hide the button while searching to make it easier to go through search results. - if #unavailable(iOS 26.0), searchText.isEmpty { - Section(content: { - NavigationLink(destination: VisitingChefs()) { - Text("Today's Visiting Chefs") - } - }) - } else { - Section(content: { - NavigationLink(destination: VisitingChefs()) { - Text("Today's Visiting Chefs") - } - }) - } Section(content: { - LocationList(diningLocations: filteredLocations) + NavigationLink(destination: VisitingChefs()) { + Text("Today's Visiting Chefs") + } + }) + Section(content: { + ForEach(filteredLocations, id: \.self) { 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) + } + } }, footer: { if let lastRefreshed { VStack(alignment: .center) { @@ -185,21 +209,21 @@ struct ContentView: View { } } .navigationTitle("RIT Dining") - .searchable(text: $searchText, prompt: "Search...") + .searchable(text: $searchText, prompt: "Search") .refreshable { - getDiningData() + await getDiningData() } .toolbar { ToolbarItem(placement: .primaryAction) { Menu { Button(action: { - getDiningData() + Task { + await getDiningData() + } }) { Label("Refresh", systemImage: "arrow.clockwise") } - Toggle(isOn: $openLocationsOnly) { - Label("Hide Closed Locations", systemImage: "eye.slash") - } + Divider() NavigationLink(destination: AboutView()) { Image(systemName: "info.circle") .foregroundColor(.accentColor) @@ -212,14 +236,34 @@ struct ContentView: View { } } label: { Image(systemName: "slider.horizontal.3") - .foregroundStyle(.accent) } } + ToolbarItemGroup(placement: .bottomBar) { + Menu { + Toggle(isOn: $openLocationsOnly) { + Label("Hide Closed Locations", systemImage: "eye.slash") + } + Toggle(isOn: $openLocationsFirst) { + Label("Open Locations First", systemImage: "arrow.up.arrow.down") + } + } label: { + 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) + } } } } - .onAppear { - getDiningData() + .environment(favorites) + .task { + await getDiningData() + await updateOpenStatuses() } .sheet(isPresented: $showingDonationSheet) { DonationView() diff --git a/RIT Dining/Data/Favorites.swift b/RIT Dining/Data/Favorites.swift new file mode 100644 index 0000000..2cc56a4 --- /dev/null +++ b/RIT Dining/Data/Favorites.swift @@ -0,0 +1,38 @@ +// +// Favorites.swift +// RIT Dining +// +// Created by Campbell on 9/22/25. +// + +import SwiftUI + +@Observable +class Favorites { + private var favoriteLocations: Set + private let key = "Favorites" + + init() { + let favorites = UserDefaults.standard.array(forKey: key) as? [Int] ?? [Int]() + favoriteLocations = Set(favorites) + } + + func contains(_ location: DiningLocation) -> Bool { + favoriteLocations.contains(location.id) + } + + func add(_ location: DiningLocation) { + favoriteLocations.insert(location.id) + save() + } + + func remove(_ location: DiningLocation) { + favoriteLocations.remove(location.id) + save() + } + + func save() { + let favorites = Array(favoriteLocations) + UserDefaults.standard.set(favorites, forKey: key) + } +} diff --git a/RIT Dining/Data/Parsers.swift b/RIT Dining/Data/Parsers.swift index 7ebd0ba..ea4c01f 100644 --- a/RIT Dining/Data/Parsers.swift +++ b/RIT Dining/Data/Parsers.swift @@ -244,3 +244,22 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation { visitingChefs: visitingChefs, dailySpecials: dailySpecials) } + +extension DiningLocation { + mutating func updateOpenStatus() { + var openStatus: OpenStatus = .closed + if let diningTimes = diningTimes, !diningTimes.isEmpty { + for i in diningTimes.indices { + openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime) + // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to + // accurately catch Gracie's multiple open periods each day. + if openStatus != .closed { + break + } + } + self.open = openStatus + } else { + self.open = .closed + } + } +} diff --git a/RIT Dining/Data/Types.swift b/RIT Dining/Data/Types.swift index da2f470..fefae0d 100644 --- a/RIT Dining/Data/Types.swift +++ b/RIT Dining/Data/Types.swift @@ -94,7 +94,7 @@ struct DiningLocation: Identifiable, Hashable { let desc: String let mapsUrl: String let diningTimes: [DiningTimes]? - let open: OpenStatus + var open: OpenStatus let visitingChefs: [VisitingChef]? let dailySpecials: [DailySpecial]? } diff --git a/RIT Dining/SharedComponents.swift b/RIT Dining/SharedComponents.swift index fbab5ca..011228f 100644 --- a/RIT Dining/SharedComponents.swift +++ b/RIT Dining/SharedComponents.swift @@ -31,9 +31,17 @@ func getAPIFriendlyDateString(date: Date) -> String { return formatter.string(from: date) } +// The common date formatter that I'm using everywhere that open periods are shown within the app. +let dateDisplay: DateFormatter = { + let display = DateFormatter() + display.timeZone = TimeZone(identifier: "America/New_York") + display.dateStyle = .none + display.timeStyle = .short + return display +}() // 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. +// modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!) extension View { func apply(@ViewBuilder _ block: (Self) -> V) -> V { block(self) } } diff --git a/RIT Dining/Views/AboutView.swift b/RIT Dining/Views/AboutView.swift index 85080e5..9ca0805 100644 --- a/RIT Dining/Views/AboutView.swift +++ b/RIT Dining/Views/AboutView.swift @@ -23,6 +23,8 @@ struct AboutView: View { Text("because the RIT dining website is slow!") Text("Version \(appVersionString) (\(buildNumber))") .foregroundStyle(.secondary) + Text("The RIT Dining app is powered by the TigerCenter API. Dining location occupancy information is sourced from the RIT maps API.") + .multilineTextAlignment(.center) Spacer() Button(action: { openURL(URL(string: "https://github.com/NinjaCheetah/RIT-Dining")!) @@ -32,7 +34,12 @@ struct AboutView: View { Button(action: { openURL(URL(string: "https://tigercenter.rit.edu/")!) }) { - Label("TigerCenter API", systemImage: "globe") + Label("TigerCenter", systemImage: "globe") + } + Button(action: { + openURL(URL(string: "https://maps.rit.edu/")!) + }) { + Label("RIT Maps", systemImage: "globe") } } .padding() diff --git a/RIT Dining/Views/DetailView.swift b/RIT Dining/Views/DetailView.swift index 9292761..4f38c4a 100644 --- a/RIT Dining/Views/DetailView.swift +++ b/RIT Dining/Views/DetailView.swift @@ -10,6 +10,7 @@ import SafariServices struct DetailView: View { @State var location: DiningLocation + @Environment(Favorites.self) var favorites @State private var isLoading: Bool = true @State private var rotationDegrees: Double = 0 @State private var showingSafari: Bool = false @@ -26,14 +27,6 @@ struct DetailView: View { .repeatForever(autoreverses: false) } - private let display: DateFormatter = { - let display = DateFormatter() - display.timeZone = TimeZone(identifier: "America/New_York") - display.dateStyle = .none - display.timeStyle = .short - return display - }() - private func requestDone(result: Result) -> Void { switch result { case .success(let location): @@ -41,7 +34,7 @@ struct DetailView: View { if let times = diningInfo.diningTimes, !times.isEmpty { var timeStrings: [String] = [] for time in times { - timeStrings.append("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))") + timeStrings.append("\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime))") } weeklyHours.append(timeStrings) } else { @@ -129,6 +122,23 @@ struct DetailView: View { .font(.title) .fontWeight(.bold) Spacer() + 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) + } + } Button(action: { showingSafari = true }) { @@ -164,7 +174,7 @@ struct DetailView: View { .onAppear { openString = "" for time in times { - openString += "\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime)), " + openString += "\(dateDisplay.string(from: time.openTime)) - \(dateDisplay.string(from: time.closeTime)), " } openString = String(openString.prefix(openString.count - 2)) } @@ -220,7 +230,7 @@ struct DetailView: View { Text("Leaving Soon") .foregroundStyle(.orange) } - Text("\(display.string(from: chef.openTime)) - \(display.string(from: chef.closeTime))") + Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))") .foregroundStyle(.secondary) } } diff --git a/RIT Dining/Views/VisitingChefs.swift b/RIT Dining/Views/VisitingChefs.swift index a49cd3a..5382e21 100644 --- a/RIT Dining/Views/VisitingChefs.swift +++ b/RIT Dining/Views/VisitingChefs.swift @@ -26,43 +26,29 @@ struct VisitingChefs: View { .repeatForever(autoreverses: false) } - private let display: DateFormatter = { - let display = DateFormatter() - display.timeZone = TimeZone(identifier: "America/New_York") - display.dateStyle = .none - display.timeStyle = .short - return display - }() - // Asynchronously fetch the data for all of the locations on the given date (only ever today or tomorrow) to get the visiting chef // information. - private func getDiningDataForDate(date: String) { + private func getDiningDataForDate(date: String) async { var newDiningLocations: [DiningLocation] = [] getAllDiningInfo(date: date) { result in - DispatchQueue.global().async { - switch result { - case .success(let locations): - for i in 0..