mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2025-10-19 06:36:18 -04:00
Major sorting/filtering improvements
This update mostly includes improvements related to sorting and filtering the main dining location list, including: - Favorites! You can mark locations as your favorites by swiping them on the list or pressing the star button on their detail page. Favorites are sorted to the top. - "Hide Closed Locations" has been moved to a dedicated sort/filter button in the bottom left corner. This looks best on iOS 26+, where it sits nicely to the left of the search bar. - Added an "Open Locations First" option to sort open locations above closed locations, if you want to know what's open quicker without entirely hiding closed locations. Other improvements: - Made most asynchronous code properly async instead of bouncing between DispatchQueue.main.async{} and .sync{}. - Added a timer to refresh open statuses every 3 seconds while on the main list, so that when the time changes the open statuses will change appropriately. It seemed silly to force you to fetch the data again just to do a quick "hey is current time in range?". - Made date formatter shared so there aren't 3 separate copies of it.
This commit is contained in:
parent
c505de4b5a
commit
f8e4c37cd4
@ -257,7 +257,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -274,7 +274,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -291,7 +291,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@ -308,7 +308,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
@ -7,55 +7,11 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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 {
|
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("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 isLoading: Bool = true
|
||||||
@State private var loadFailed: Bool = false
|
@State private var loadFailed: Bool = false
|
||||||
@State private var showingDonationSheet: 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.
|
// 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] = []
|
var newDiningLocations: [DiningLocation] = []
|
||||||
getAllDiningInfo(date: nil) { result in
|
getAllDiningInfo(date: nil) { result in
|
||||||
DispatchQueue.global().async {
|
switch result {
|
||||||
switch result {
|
case .success(let locations):
|
||||||
case .success(let locations):
|
for i in 0..<locations.locations.count {
|
||||||
for i in 0..<locations.locations.count {
|
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
||||||
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
newDiningLocations.append(diningInfo)
|
||||||
DispatchQueue.global().sync {
|
|
||||||
newDiningLocations.append(diningInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DispatchQueue.global().sync {
|
|
||||||
// Need to sort the locations alphabetically because they get returned in a completely arbitrary order. Also
|
|
||||||
// need to do so while ignoring the word "the" because a bunch of locations have it and it's not helpful to put
|
|
||||||
// those all down in "T".
|
|
||||||
diningLocations = newDiningLocations.sorted { firstLoc, secondLoc in
|
|
||||||
func removeThe(_ name: String) -> 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
|
|
||||||
}
|
}
|
||||||
|
diningLocations = newDiningLocations
|
||||||
|
lastRefreshed = Date()
|
||||||
|
isLoading = false
|
||||||
|
case .failure(let error):
|
||||||
|
print(error)
|
||||||
|
loadFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow for searching the list and hiding closed locations. Gets a list of locations that match the search and a list that match
|
// Start a perpetually running timer to refresh the open statuses, so that they automatically switch as appropriate without
|
||||||
// the open only filter (.open and .closingSoon) and then returns the ones that match both lists.
|
// needing to refresh the data. You don't need to yell at the API again to know that the location opening at 11:00 AM should now
|
||||||
|
// display "Open" instead of "Opening Soon" now that it's 11:01.
|
||||||
|
private func updateOpenStatuses() async {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
||||||
|
for location in diningLocations.indices {
|
||||||
|
diningLocations[location].updateOpenStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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] {
|
private var filteredLocations: [DiningLocation] {
|
||||||
diningLocations.filter { location in
|
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 searchedLocations = searchText.isEmpty || location.name.localizedCaseInsensitiveContains(searchText)
|
||||||
let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon
|
let openLocations = !openLocationsOnly || location.open == .open || location.open == .closingSoon
|
||||||
return searchedLocations && openLocations
|
return searchedLocations && openLocations
|
||||||
}
|
}
|
||||||
|
return newLocations
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -132,7 +110,9 @@ struct ContentView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Button(action: {
|
Button(action: {
|
||||||
loadFailed = false
|
loadFailed = false
|
||||||
getDiningData()
|
Task {
|
||||||
|
await getDiningData()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
@ -156,23 +136,67 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack() {
|
VStack() {
|
||||||
List {
|
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: {
|
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: {
|
}, footer: {
|
||||||
if let lastRefreshed {
|
if let lastRefreshed {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
@ -185,21 +209,21 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("RIT Dining")
|
.navigationTitle("RIT Dining")
|
||||||
.searchable(text: $searchText, prompt: "Search...")
|
.searchable(text: $searchText, prompt: "Search")
|
||||||
.refreshable {
|
.refreshable {
|
||||||
getDiningData()
|
await getDiningData()
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Menu {
|
Menu {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
getDiningData()
|
Task {
|
||||||
|
await getDiningData()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
Toggle(isOn: $openLocationsOnly) {
|
Divider()
|
||||||
Label("Hide Closed Locations", systemImage: "eye.slash")
|
|
||||||
}
|
|
||||||
NavigationLink(destination: AboutView()) {
|
NavigationLink(destination: AboutView()) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
@ -212,14 +236,34 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "slider.horizontal.3")
|
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 {
|
.environment(favorites)
|
||||||
getDiningData()
|
.task {
|
||||||
|
await getDiningData()
|
||||||
|
await updateOpenStatuses()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingDonationSheet) {
|
.sheet(isPresented: $showingDonationSheet) {
|
||||||
DonationView()
|
DonationView()
|
||||||
|
38
RIT Dining/Data/Favorites.swift
Normal file
38
RIT Dining/Data/Favorites.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// Favorites.swift
|
||||||
|
// RIT Dining
|
||||||
|
//
|
||||||
|
// Created by Campbell on 9/22/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class Favorites {
|
||||||
|
private var favoriteLocations: Set<Int>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -244,3 +244,22 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
|||||||
visitingChefs: visitingChefs,
|
visitingChefs: visitingChefs,
|
||||||
dailySpecials: dailySpecials)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -94,7 +94,7 @@ struct DiningLocation: Identifiable, Hashable {
|
|||||||
let desc: String
|
let desc: String
|
||||||
let mapsUrl: String
|
let mapsUrl: String
|
||||||
let diningTimes: [DiningTimes]?
|
let diningTimes: [DiningTimes]?
|
||||||
let open: OpenStatus
|
var open: OpenStatus
|
||||||
let visitingChefs: [VisitingChef]?
|
let visitingChefs: [VisitingChef]?
|
||||||
let dailySpecials: [DailySpecial]?
|
let dailySpecials: [DailySpecial]?
|
||||||
}
|
}
|
||||||
|
@ -31,9 +31,17 @@ func getAPIFriendlyDateString(date: Date) -> String {
|
|||||||
return formatter.string(from: date)
|
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
|
// 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 {
|
extension View {
|
||||||
func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
|
func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,8 @@ struct AboutView: View {
|
|||||||
Text("because the RIT dining website is slow!")
|
Text("because the RIT dining website is slow!")
|
||||||
Text("Version \(appVersionString) (\(buildNumber))")
|
Text("Version \(appVersionString) (\(buildNumber))")
|
||||||
.foregroundStyle(.secondary)
|
.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()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openURL(URL(string: "https://github.com/NinjaCheetah/RIT-Dining")!)
|
openURL(URL(string: "https://github.com/NinjaCheetah/RIT-Dining")!)
|
||||||
@ -32,7 +34,12 @@ struct AboutView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
openURL(URL(string: "https://tigercenter.rit.edu/")!)
|
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()
|
.padding()
|
||||||
|
@ -10,6 +10,7 @@ import SafariServices
|
|||||||
|
|
||||||
struct DetailView: View {
|
struct DetailView: View {
|
||||||
@State var location: DiningLocation
|
@State var location: DiningLocation
|
||||||
|
@Environment(Favorites.self) var favorites
|
||||||
@State private var isLoading: Bool = true
|
@State private var isLoading: Bool = true
|
||||||
@State private var rotationDegrees: Double = 0
|
@State private var rotationDegrees: Double = 0
|
||||||
@State private var showingSafari: Bool = false
|
@State private var showingSafari: Bool = false
|
||||||
@ -26,14 +27,6 @@ struct DetailView: View {
|
|||||||
.repeatForever(autoreverses: false)
|
.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<DiningLocationParser, Error>) -> Void {
|
private func requestDone(result: Result<DiningLocationParser, Error>) -> Void {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let location):
|
case .success(let location):
|
||||||
@ -41,7 +34,7 @@ struct DetailView: View {
|
|||||||
if let times = diningInfo.diningTimes, !times.isEmpty {
|
if let times = diningInfo.diningTimes, !times.isEmpty {
|
||||||
var timeStrings: [String] = []
|
var timeStrings: [String] = []
|
||||||
for time in times {
|
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)
|
weeklyHours.append(timeStrings)
|
||||||
} else {
|
} else {
|
||||||
@ -129,6 +122,23 @@ struct DetailView: View {
|
|||||||
.font(.title)
|
.font(.title)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
Spacer()
|
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: {
|
Button(action: {
|
||||||
showingSafari = true
|
showingSafari = true
|
||||||
}) {
|
}) {
|
||||||
@ -164,7 +174,7 @@ struct DetailView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
openString = ""
|
openString = ""
|
||||||
for time in times {
|
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))
|
openString = String(openString.prefix(openString.count - 2))
|
||||||
}
|
}
|
||||||
@ -220,7 +230,7 @@ struct DetailView: View {
|
|||||||
Text("Leaving Soon")
|
Text("Leaving Soon")
|
||||||
.foregroundStyle(.orange)
|
.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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,43 +26,29 @@ struct VisitingChefs: View {
|
|||||||
.repeatForever(autoreverses: false)
|
.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
|
// Asynchronously fetch the data for all of the locations on the given date (only ever today or tomorrow) to get the visiting chef
|
||||||
// information.
|
// information.
|
||||||
private func getDiningDataForDate(date: String) {
|
private func getDiningDataForDate(date: String) async {
|
||||||
var newDiningLocations: [DiningLocation] = []
|
var newDiningLocations: [DiningLocation] = []
|
||||||
getAllDiningInfo(date: date) { result in
|
getAllDiningInfo(date: date) { result in
|
||||||
DispatchQueue.global().async {
|
switch result {
|
||||||
switch result {
|
case .success(let locations):
|
||||||
case .success(let locations):
|
for i in 0..<locations.locations.count {
|
||||||
for i in 0..<locations.locations.count {
|
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
||||||
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
print(diningInfo.name)
|
||||||
print(diningInfo.name)
|
// Only save the locations that actually have visiting chefs to avoid extra iterations later.
|
||||||
DispatchQueue.global().sync {
|
if let visitingChefs = diningInfo.visitingChefs, !visitingChefs.isEmpty {
|
||||||
// Only save the locations that actually have visiting chefs to avoid extra iterations later.
|
newDiningLocations.append(diningInfo)
|
||||||
if let visitingChefs = diningInfo.visitingChefs, !visitingChefs.isEmpty {
|
|
||||||
newDiningLocations.append(diningInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DispatchQueue.global().sync {
|
|
||||||
locationsWithChefs = newDiningLocations
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
case .failure(let error): print(error)
|
|
||||||
}
|
}
|
||||||
|
locationsWithChefs = newDiningLocations
|
||||||
|
isLoading = false
|
||||||
|
case .failure(let error): print(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getDiningData() {
|
private func getDiningData() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
let dateString: String
|
let dateString: String
|
||||||
if !isTomorrow {
|
if !isTomorrow {
|
||||||
@ -74,7 +60,7 @@ struct VisitingChefs: View {
|
|||||||
dateString = getAPIFriendlyDateString(date: tomorrow)
|
dateString = getAPIFriendlyDateString(date: tomorrow)
|
||||||
print("fetching visiting chefs for date \(dateString) (tomorrow)")
|
print("fetching visiting chefs for date \(dateString) (tomorrow)")
|
||||||
}
|
}
|
||||||
getDiningDataForDate(date: dateString)
|
await getDiningDataForDate(date: dateString)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -100,7 +86,9 @@ struct VisitingChefs: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
isTomorrow.toggle()
|
isTomorrow.toggle()
|
||||||
getDiningData()
|
Task {
|
||||||
|
await getDiningData()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.right.circle")
|
Image(systemName: "chevron.right.circle")
|
||||||
.rotationEffect(.degrees(daySwitcherRotation))
|
.rotationEffect(.degrees(daySwitcherRotation))
|
||||||
@ -176,7 +164,7 @@ struct VisitingChefs: View {
|
|||||||
}
|
}
|
||||||
Text("•")
|
Text("•")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("\(display.string(from: chef.openTime)) - \(display.string(from: chef.closeTime))")
|
Text("\(dateDisplay.string(from: chef.openTime)) - \(dateDisplay.string(from: chef.closeTime))")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(chef.description)
|
Text(chef.description)
|
||||||
@ -192,11 +180,11 @@ struct VisitingChefs: View {
|
|||||||
.sheet(item: $safariUrl) { url in
|
.sheet(item: $safariUrl) { url in
|
||||||
SafariView(url: url.url)
|
SafariView(url: url.url)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.task {
|
||||||
getDiningData()
|
await getDiningData()
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
getDiningData()
|
await getDiningData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user