Improved visiting chef and daily specials information
Reworked the detail view for locations to use the screen space more effectively and show the day's visiting chefs and daily specials, as well as the hours for the entire week. The visiting chef screen has also been massively reworked to show all visiting chefs under what location they're in, with their times for the day and an indicator marking their status (one of: "Here Now", "Arriving Later", "Ariving Soon", "Leaving Soon", and "Left For Today"). These markers are also used in location's detail view. There's also an arrow in the top right that switches the visiting chef screen from today's visiting chefs to tomorrow's, so you can scout out what you might want to get tomorrow. Locations are also now sorted alphabetically on the main menu, to make finding the location you're looking for easier.
@ -253,8 +253,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -271,6 +272,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@ -282,8 +284,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = 5GF7GKNTK4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -300,6 +303,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
@ -1,172 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "notification40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "notification60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "settings58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "settings87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "spotlight80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "spotlight120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "iphone120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "iphone180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadNotification20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadNotification40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadSettings29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadSettings58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadSpotlight40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadSpotlight80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "ipad76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "ipad152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "ipadPro167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "appstore1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"filename" : "RIT Dining Temp Logo.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "mac16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "mac1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 122 KiB |
Before Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 492 KiB |
Before Width: | Height: | Size: 9.1 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 7.3 KiB |
@ -82,7 +82,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().sync {
|
||||
diningLocations = newDiningLocations
|
||||
diningLocations = newDiningLocations.sorted { $0.name < $1.name }
|
||||
lastRefreshed = Date()
|
||||
isLoading = false
|
||||
}
|
||||
@ -125,7 +125,7 @@ struct ContentView: View {
|
||||
List {
|
||||
if searchText.isEmpty {
|
||||
Section(content: {
|
||||
NavigationLink(destination: VisitingChefs(diningLocations: diningLocations)) {
|
||||
NavigationLink(destination: VisitingChefs()) {
|
||||
Text("Today's Visiting Chefs")
|
||||
}
|
||||
})
|
||||
|
@ -8,19 +8,6 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
// Gross disgusting UIKit code :(
|
||||
// There isn't a direct way to use integrated Safari from SwiftUI, except maybe in iOS 26? I'm not targeting that though so I must fall
|
||||
// back on UIKit stuff.
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
SFSafariViewController(url: url)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
||||
}
|
||||
|
||||
struct DetailView: View {
|
||||
@State var location: DiningLocation
|
||||
@State private var isLoading: Bool = true
|
||||
@ -111,9 +98,19 @@ struct DetailView: View {
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(location.name)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
HStack(alignment: .center) {
|
||||
Text(location.name)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingSafari = true
|
||||
}) {
|
||||
Image(systemName: "map")
|
||||
.foregroundStyle(.accent)
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
Text(location.summary)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
@ -151,13 +148,60 @@ struct DetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
Button(action: {
|
||||
showingSafari = true
|
||||
}) {
|
||||
Text("View on Map")
|
||||
.padding(.bottom, 12)
|
||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Today's Visiting Chefs")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
ForEach(visitingChefs, id: \.self) { chef in
|
||||
HStack(alignment: .top) {
|
||||
Text(chef.name)
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
switch chef.status {
|
||||
case .hereNow:
|
||||
Text("Here Now")
|
||||
.foregroundStyle(.green)
|
||||
case .gone:
|
||||
Text("Left For Today")
|
||||
.foregroundStyle(.red)
|
||||
case .arrivingLater:
|
||||
Text("Arriving Later")
|
||||
.foregroundStyle(.red)
|
||||
case .arrivingSoon:
|
||||
Text("Arriving Soon")
|
||||
.foregroundStyle(.orange)
|
||||
case .leavingSoon:
|
||||
Text("Leaving Soon")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text("\(display.string(from: chef.openTime)) - \(display.string(from: chef.closeTime))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
if let dailySpecials = location.dailySpecials, !dailySpecials.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Today's Daily Specials")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
ForEach(dailySpecials, id: \.self) { special in
|
||||
HStack(alignment: .top) {
|
||||
Text(special.name)
|
||||
Spacer()
|
||||
Text(special.type)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
VStack(alignment: .leading) {
|
||||
Text("This Week's Hours")
|
||||
.font(.title3)
|
||||
@ -176,7 +220,8 @@ struct DetailView: View {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
.padding(.bottom, 12)
|
||||
// Ideally I'd like this text to be justified to more effectively use the screen space.
|
||||
Text(location.desc)
|
||||
.font(.body)
|
||||
.padding(.bottom, 10)
|
||||
@ -204,5 +249,6 @@ struct DetailView: View {
|
||||
mapsUrl: "https://example.com",
|
||||
diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
|
||||
open: .open,
|
||||
visitingChefs: nil))
|
||||
visitingChefs: nil,
|
||||
dailySpecials: nil))
|
||||
}
|
||||
|
@ -17,11 +17,7 @@ enum InvalidHTTPError: Error {
|
||||
// Get information for all dining locations.
|
||||
func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<DiningLocationsParser, Error>) -> Void) {
|
||||
// The endpoint requires that you specify a date, so get today's.
|
||||
let date_string: String = if let date { date } else {
|
||||
Date().formatted(.iso8601
|
||||
.year().month().day()
|
||||
.dateSeparator(.dash))
|
||||
}
|
||||
let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
|
||||
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)"
|
||||
|
||||
guard let url = URL(string: url_string) else {
|
||||
@ -51,11 +47,7 @@ func getAllDiningInfo(date: String?, completionHandler: @escaping (Result<Dining
|
||||
// Get information for just one dining location based on its location ID.
|
||||
func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @escaping (Result<DiningLocationParser, Error>) -> Void) {
|
||||
// The current date and the location ID are required to get information for just one location.
|
||||
let date_string: String = if let date { date } else {
|
||||
Date().formatted(.iso8601
|
||||
.year().month().day()
|
||||
.dateSeparator(.dash))
|
||||
}
|
||||
let date_string: String = date ?? getAPIFriendlyDateString(date: Date())
|
||||
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)"
|
||||
print("making request to \(url_string)")
|
||||
|
||||
@ -83,6 +75,26 @@ func getSingleDiningInfo(date: String?, locationId: Int, completionHandler: @esc
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus {
|
||||
// This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next
|
||||
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
var openStatus: OpenStatus = .closed
|
||||
if now >= openTime && now <= closeTime {
|
||||
if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
||||
openStatus = .closingSoon
|
||||
} else {
|
||||
openStatus = .open
|
||||
}
|
||||
} else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now {
|
||||
openStatus = .openingSoon
|
||||
} else {
|
||||
openStatus = .closed
|
||||
}
|
||||
return openStatus
|
||||
}
|
||||
|
||||
func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
print("beginning parse for \(location.name)")
|
||||
|
||||
@ -99,7 +111,8 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
mapsUrl: location.mapsUrl,
|
||||
diningTimes: nil,
|
||||
open: .closed,
|
||||
visitingChefs: nil)
|
||||
visitingChefs: nil,
|
||||
dailySpecials: nil)
|
||||
}
|
||||
|
||||
var openStrings: [String] = []
|
||||
@ -109,26 +122,32 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
// are exceptions, use those times for the day, otherwise we can just use the default times.
|
||||
for event in location.events {
|
||||
if let exceptions = event.exceptions, !exceptions.isEmpty {
|
||||
// Early return if the exception for the day specifies that the location is closed. Used for things like holidays.
|
||||
if !exceptions[0].open {
|
||||
return DiningLocation(
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
summary: location.summary,
|
||||
desc: desc,
|
||||
mapsUrl: location.mapsUrl,
|
||||
diningTimes: nil,
|
||||
open: .closed,
|
||||
visitingChefs: nil)
|
||||
// Only save the exception times if the location is actually open during those times.
|
||||
if exceptions[0].open {
|
||||
openStrings.append(exceptions[0].startTime)
|
||||
closeStrings.append(exceptions[0].endTime)
|
||||
}
|
||||
openStrings.append(exceptions[0].startTime)
|
||||
closeStrings.append(exceptions[0].endTime)
|
||||
} else {
|
||||
openStrings.append(event.startTime)
|
||||
closeStrings.append(event.endTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Early return if there are no valid opening times, most likely because the day's exceptions dictate that the location is closed.
|
||||
// Mostly comes into play on holidays.
|
||||
if openStrings.isEmpty || closeStrings.isEmpty {
|
||||
return DiningLocation(
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
summary: location.summary,
|
||||
desc: desc,
|
||||
mapsUrl: location.mapsUrl,
|
||||
diningTimes: nil,
|
||||
open: .closed,
|
||||
visitingChefs: nil,
|
||||
dailySpecials: nil)
|
||||
}
|
||||
|
||||
// I hate all of this date component nonsense.
|
||||
var openDates: [Date] = []
|
||||
var closeDates: [Date] = []
|
||||
@ -176,17 +195,7 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
// 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes.
|
||||
var openStatus: OpenStatus = .closed
|
||||
for i in diningTimes.indices {
|
||||
if now >= diningTimes[i].openTime && now <= diningTimes[i].closeTime {
|
||||
if diningTimes[i].closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
||||
openStatus = .closingSoon
|
||||
} else {
|
||||
openStatus = .open
|
||||
}
|
||||
} else if diningTimes[i].openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && diningTimes[i].closeTime > now {
|
||||
openStatus = .openingSoon
|
||||
} else {
|
||||
openStatus = .closed
|
||||
}
|
||||
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 {
|
||||
@ -195,20 +204,92 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
}
|
||||
|
||||
// Parse the "menus" array and keep track of visiting chefs at this location, if there are any. If not then we can just save nil.
|
||||
// Eventually this will parse out the times, but that's complicated because that data is formatted poorly and inconsistently and
|
||||
// I'm not interested in messing with that quite yet.
|
||||
let visitingChefs: [VisitngChef]?
|
||||
// The time formats used for visiting chefs are inconsistent and suck so that part of this code might be kind of rough. I can
|
||||
// probably make it a little better but I think most of the blame goes to TigerCenter here.
|
||||
// Also save the daily specials. This is more of a footnote because that's just taking a string and saving it as two strings.
|
||||
let visitingChefs: [VisitingChef]?
|
||||
let dailySpecials: [DailySpecial]?
|
||||
if !location.menus.isEmpty {
|
||||
var chefs: [VisitngChef] = []
|
||||
var chefs: [VisitingChef] = []
|
||||
var specials: [DailySpecial] = []
|
||||
for menu in location.menus {
|
||||
if menu.category == "Visiting Chef" {
|
||||
print("found visiting chef: \(menu.name)")
|
||||
chefs.append(VisitngChef(name: menu.name, description: menu.description!))
|
||||
var name: String = menu.name
|
||||
let splitString = name.split(separator: "(", maxSplits: 1)
|
||||
name = String(splitString[0])
|
||||
// Time parsing nonsense starts here. Extracts the time from a string like "Chef (4-7p.m.)", splits it at the "-",
|
||||
// strips the non-numerical characters from each part, parses it as a number and adds 12 hours as needed, then creates
|
||||
// a Date instance for that time on today's date.
|
||||
let timeStrings = String(splitString[1]).replacingOccurrences(of: ")", with: "").split(separator: "-", maxSplits: 1)
|
||||
print("raw open range: \(timeStrings)")
|
||||
let openTime: Date
|
||||
let closeTime: Date
|
||||
if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) {
|
||||
// If the time is NOT in the morning, add 12 hours.
|
||||
let openHour = if openString.contains("a.m") {
|
||||
Int(openString.filter("0123456789".contains))!
|
||||
} else {
|
||||
Int(openString)! + 12
|
||||
}
|
||||
let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0)
|
||||
openTime = calendar.date(
|
||||
bySettingHour: openTimeComponents.hour!,
|
||||
minute: openTimeComponents.minute!,
|
||||
second: openTimeComponents.second!,
|
||||
of: now)!
|
||||
} else {
|
||||
break
|
||||
}
|
||||
if let closeString = timeStrings.last?.filter("0123456789".contains) {
|
||||
// I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have
|
||||
// yet to see any evidence of a visiting chef leaving before noon so far.
|
||||
let closeHour = Int(closeString)! + 12
|
||||
let closeTimeComponents = DateComponents(hour: closeHour, minute: 0, second: 0)
|
||||
closeTime = calendar.date(
|
||||
bySettingHour: closeTimeComponents.hour!,
|
||||
minute: closeTimeComponents.minute!,
|
||||
second: closeTimeComponents.second!,
|
||||
of: now)!
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse the chef's status, mapping the OpenStatus to a VisitingChefStatus.
|
||||
let visitngChefStatus: VisitingChefStatus = switch parseOpenStatus(openTime: openTime, closeTime: closeTime) {
|
||||
case .open:
|
||||
.hereNow
|
||||
case .closed:
|
||||
if now < openTime {
|
||||
.arrivingLater
|
||||
} else {
|
||||
.gone
|
||||
}
|
||||
case .openingSoon:
|
||||
.arrivingSoon
|
||||
case .closingSoon:
|
||||
.leavingSoon
|
||||
}
|
||||
|
||||
chefs.append(VisitingChef(
|
||||
name: name,
|
||||
description: menu.description ?? "No description available", // Some don't have descriptions, apparently.
|
||||
openTime: openTime,
|
||||
closeTime: closeTime,
|
||||
status: visitngChefStatus))
|
||||
} else if menu.category == "Daily Specials" {
|
||||
print("found daily special: \(menu.name)")
|
||||
let splitString = menu.name.split(separator: "(", maxSplits: 1)
|
||||
specials.append(DailySpecial(
|
||||
name: String(splitString[0]),
|
||||
type: String(splitString.count > 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: "")))
|
||||
}
|
||||
}
|
||||
visitingChefs = chefs
|
||||
dailySpecials = specials
|
||||
} else {
|
||||
visitingChefs = nil
|
||||
dailySpecials = nil
|
||||
}
|
||||
|
||||
return DiningLocation(
|
||||
@ -219,5 +300,6 @@ func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
|
||||
mapsUrl: location.mapsUrl,
|
||||
diningTimes: diningTimes,
|
||||
open: openStatus,
|
||||
visitingChefs: visitingChefs)
|
||||
visitingChefs: visitingChefs,
|
||||
dailySpecials: dailySpecials)
|
||||
}
|
||||
|
32
RIT Dining/SharedComponents.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// SharedComponents.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 9/8/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
|
||||
// Gross disgusting UIKit code :(
|
||||
// There isn't a direct way to use integrated Safari from SwiftUI, except maybe in iOS 26? I'm not targeting that though so I must fall
|
||||
// back on UIKit stuff.
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
SFSafariViewController(url: url)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
||||
}
|
||||
|
||||
func getAPIFriendlyDateString(date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
@ -62,10 +62,27 @@ struct DiningTimes: Equatable, Hashable {
|
||||
var closeTime: Date
|
||||
}
|
||||
|
||||
// Enum to represent the five possible states a visiting chef can be in.
|
||||
enum VisitingChefStatus {
|
||||
case hereNow
|
||||
case gone
|
||||
case arrivingLater
|
||||
case arrivingSoon
|
||||
case leavingSoon
|
||||
}
|
||||
|
||||
// A visitng chef present at a location.
|
||||
struct VisitngChef: Equatable, Hashable {
|
||||
struct VisitingChef: Equatable, Hashable {
|
||||
let name: String
|
||||
let description: String
|
||||
var openTime: Date
|
||||
var closeTime: Date
|
||||
let status: VisitingChefStatus
|
||||
}
|
||||
|
||||
struct DailySpecial: Equatable, Hashable {
|
||||
let name: String
|
||||
let type: String
|
||||
}
|
||||
|
||||
// The basic information about a dining location needed to display it in the app after parsing is finished.
|
||||
@ -77,5 +94,6 @@ struct DiningLocation: Identifiable, Hashable {
|
||||
let mapsUrl: String
|
||||
let diningTimes: [DiningTimes]?
|
||||
let open: OpenStatus
|
||||
let visitingChefs: [VisitngChef]?
|
||||
let visitingChefs: [VisitingChef]?
|
||||
let dailySpecials: [DailySpecial]?
|
||||
}
|
||||
|
@ -7,41 +7,194 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
struct VisitingChefs: View {
|
||||
@State var diningLocations: [DiningLocation]
|
||||
@State private var diningLocations: [DiningLocation] = []
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@State private var daySwitcherRotation: Double = 0
|
||||
@State private var safariUrl: IdentifiableURL?
|
||||
@State private var isTomorrow: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(diningLocations, id: \.self) { location in
|
||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||
VStack {
|
||||
Text(location.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
ForEach(visitingChefs, id: \.self) { chef in
|
||||
Text(chef.name)
|
||||
.fontWeight(.semibold)
|
||||
Text(chef.description)
|
||||
private var animation: Animation {
|
||||
.linear
|
||||
.speed(0.1)
|
||||
.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) {
|
||||
var newDiningLocations: [DiningLocation] = []
|
||||
getAllDiningInfo(date: date) { result in
|
||||
DispatchQueue.global().async {
|
||||
switch result {
|
||||
case .success(let locations):
|
||||
for i in 0..<locations.locations.count {
|
||||
let diningInfo = parseLocationInfo(location: locations.locations[i])
|
||||
print(diningInfo.name)
|
||||
DispatchQueue.global().sync {
|
||||
newDiningLocations.append(diningInfo)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 15)
|
||||
DispatchQueue.global().sync {
|
||||
diningLocations = newDiningLocations
|
||||
isLoading = false
|
||||
}
|
||||
case .failure(let error): print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getDiningData() {
|
||||
isLoading = true
|
||||
let dateString: String
|
||||
if !isTomorrow {
|
||||
dateString = getAPIFriendlyDateString(date: Date())
|
||||
print("default really really really long string to make this line more obvious for debugging: \(dateString)")
|
||||
} else {
|
||||
let calendar = Calendar.current
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date())!
|
||||
dateString = getAPIFriendlyDateString(date: tomorrow)
|
||||
print("really really really long string to make this line more obvious for debugging: \(dateString)")
|
||||
}
|
||||
getDiningDataForDate(date: dateString)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
if !isTomorrow {
|
||||
Text("Today's Visiting Chefs")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
} else {
|
||||
Text("Tomorrow's Visiting Chefs")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: {
|
||||
withAnimation(Animation.linear.speed(1.5)) {
|
||||
if isTomorrow {
|
||||
daySwitcherRotation = 0.0
|
||||
} else {
|
||||
daySwitcherRotation = 180.0
|
||||
}
|
||||
}
|
||||
isTomorrow.toggle()
|
||||
getDiningData()
|
||||
}) {
|
||||
Image(systemName: "chevron.right.circle")
|
||||
.rotationEffect(.degrees(daySwitcherRotation))
|
||||
.font(.title)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
VStack {
|
||||
Image(systemName: "fork.knife.circle")
|
||||
.resizable()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.onAppear {
|
||||
rotationDegrees = 0.0
|
||||
withAnimation(animation) {
|
||||
rotationDegrees = 360.0
|
||||
}
|
||||
}
|
||||
Text("Loading...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 25)
|
||||
} else {
|
||||
ForEach(diningLocations, id: \.self) { location in
|
||||
if let visitingChefs = location.visitingChefs, !visitingChefs.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Divider()
|
||||
HStack(alignment: .center) {
|
||||
Text(location.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
safariUrl = IdentifiableURL(url: URL(string: location.mapsUrl)!)
|
||||
}) {
|
||||
Image(systemName: "map")
|
||||
.foregroundStyle(.accent)
|
||||
}
|
||||
}
|
||||
ForEach(visitingChefs, id: \.self) { chef in
|
||||
Spacer()
|
||||
Text(chef.name)
|
||||
.fontWeight(.semibold)
|
||||
HStack(spacing: 3) {
|
||||
if !isTomorrow {
|
||||
switch chef.status {
|
||||
case .hereNow:
|
||||
Text("Here Now")
|
||||
.foregroundStyle(.green)
|
||||
case .gone:
|
||||
Text("Left For Today")
|
||||
.foregroundStyle(.red)
|
||||
case .arrivingLater:
|
||||
Text("Arriving Later")
|
||||
.foregroundStyle(.red)
|
||||
case .arrivingSoon:
|
||||
Text("Arriving Soon")
|
||||
.foregroundStyle(.orange)
|
||||
case .leavingSoon:
|
||||
Text("Leaving Soon")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} else {
|
||||
Text("Arriving Tomorrow")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(display.string(from: chef.openTime)) - \(display.string(from: chef.closeTime))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(chef.description)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.navigationTitle("Visiting Chefs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $safariUrl) { url in
|
||||
SafariView(url: url.url)
|
||||
}
|
||||
.onAppear {
|
||||
getDiningData()
|
||||
}
|
||||
.refreshable {
|
||||
getDiningData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VisitingChefs(
|
||||
diningLocations: [DiningLocation(
|
||||
id: 0,
|
||||
name: "Example",
|
||||
summary: "A Place",
|
||||
desc: "A long description of the place",
|
||||
mapsUrl: "https://example.com",
|
||||
diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
|
||||
open: .open,
|
||||
visitingChefs: [VisitngChef(name: "Example Chef (1-2 p.m.)", description: "Serves example food")])])
|
||||
VisitingChefs()
|
||||
}
|
||||
|