Initial commit
@ -94,6 +94,8 @@
|
||||
);
|
||||
mainGroup = 376AE0522E6495EB00AB698B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 376AE05C2E6495EB00AB698B /* Products */;
|
||||
projectDirPath = "";
|
||||
@ -261,6 +263,7 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -289,6 +292,7 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -1,6 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.000",
|
||||
"green" : "0.412",
|
||||
"red" : "0.969"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
@ -1,31 +1,172 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename" : "mac16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"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" : {
|
||||
|
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/appstore1024.png
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad152.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/ipad76.png
Normal file
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 4.7 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadPro167.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings29.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/ipadSettings58.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 7.3 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone120.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/iphone180.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac1024.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac128.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac16.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac256.png
Normal file
After Width: | Height: | Size: 122 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac32.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac512.png
Normal file
After Width: | Height: | Size: 492 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/mac64.png
Normal file
After Width: | Height: | Size: 9.1 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/notification40.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/notification60.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/settings58.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/settings87.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight120.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
RIT Dining/Assets.xcassets/AppIcon.appiconset/spotlight80.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
@ -2,20 +2,126 @@
|
||||
// ContentView.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell Bagley on 8/31/25.
|
||||
// Created by Campbell on 8/31/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Location: Hashable {
|
||||
let name: String
|
||||
let todaysHours: String
|
||||
let isOpen: openStatus
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
@State private var isLoading = true
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@State private var diningLocations: [Location] = []
|
||||
|
||||
private var animation: Animation {
|
||||
.linear
|
||||
.speed(0.1)
|
||||
.repeatForever(autoreverses: false)
|
||||
}
|
||||
|
||||
// Asynchronously fetch the data for all of the locations and parse their data to display it.
|
||||
private func getDiningData() {
|
||||
var newDiningLocations: [Location] = []
|
||||
getDiningLocation { result in
|
||||
DispatchQueue.global().async {
|
||||
switch result {
|
||||
case .success(let locations):
|
||||
for i in 0..<locations.locations.count {
|
||||
let diningInfo = getLocationInfo(location: locations.locations[i])
|
||||
print(diningInfo.name)
|
||||
|
||||
// I forgot this before and was really confused why all of the times were in UTC.
|
||||
let display = DateFormatter()
|
||||
display.timeZone = TimeZone(identifier: "America/New_York")
|
||||
display.dateStyle = .none
|
||||
display.timeStyle = .short
|
||||
|
||||
// Parse the open status and times to create the hours string. If either time is missing, assume it has no openings
|
||||
// and use "Not Open Today". If there are times, then set those to be displayed.
|
||||
let todaysHours: String
|
||||
if diningInfo.openTime == .none || diningInfo.closeTime == .none {
|
||||
todaysHours = "Not Open Today"
|
||||
} else {
|
||||
print("Open:", display.string(from: diningInfo.openTime!),
|
||||
"Close:", display.string(from: diningInfo.closeTime!))
|
||||
|
||||
todaysHours = "\(display.string(from: diningInfo.openTime!)) - \(display.string(from: diningInfo.closeTime!))"
|
||||
}
|
||||
DispatchQueue.global().sync {
|
||||
newDiningLocations.append(
|
||||
Location(
|
||||
name: diningInfo.name,
|
||||
todaysHours: todaysHours,
|
||||
isOpen: diningInfo.open
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().sync {
|
||||
diningLocations = newDiningLocations
|
||||
isLoading = false
|
||||
}
|
||||
case .failure(let error): print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack() {
|
||||
if isLoading {
|
||||
VStack {
|
||||
Image(systemName: "fork.knife.circle")
|
||||
.resizable()
|
||||
.frame(width: 75, height: 75)
|
||||
.foregroundStyle(.accent)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.onAppear {
|
||||
withAnimation(animation) {
|
||||
rotationDegrees = 360.0
|
||||
}
|
||||
}
|
||||
Text("Loading...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
Form {
|
||||
ForEach(diningLocations, id: \.self) { location in
|
||||
VStack(alignment: .leading) {
|
||||
Text(location.name)
|
||||
Text(location.todaysHours)
|
||||
switch location.isOpen {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("RIT Dining")
|
||||
.refreshable {
|
||||
getDiningData()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
getDiningData()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
177
RIT Dining/FetchData.swift
Normal file
@ -0,0 +1,177 @@
|
||||
//
|
||||
// FetchData.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 8/31/25.
|
||||
//
|
||||
|
||||
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 DiningLocation: Decodable {
|
||||
struct Events: Decodable {
|
||||
struct HoursException: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let open: Bool
|
||||
}
|
||||
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let exceptions: [HoursException]?
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let name: String
|
||||
let events: [Events]
|
||||
}
|
||||
|
||||
struct DiningLocations: Decodable {
|
||||
let locations: [DiningLocation]
|
||||
}
|
||||
|
||||
enum InvalidHTTPError: Error {
|
||||
case invalid
|
||||
}
|
||||
|
||||
// This code came from another project of mine and was used to fetch the GitHub API for update checking. I just copied it here, but it can
|
||||
// probably be made simpler for this use case.
|
||||
func getDiningLocation(completionHandler: @escaping (Result<DiningLocations, Error>) -> Void) {
|
||||
// The endpoint requires that you specify a date, so get today's.
|
||||
let date_string = Date().formatted(.iso8601
|
||||
.year().month().day()
|
||||
.dateSeparator(.dash))
|
||||
let url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-all?date=\(date_string)"
|
||||
|
||||
guard let url = URL(string: url_string) else {
|
||||
print("Invalid URL")
|
||||
return
|
||||
}
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
guard case .none = error else { return }
|
||||
|
||||
guard let data = data else {
|
||||
print("Data error.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
|
||||
completionHandler(.failure(InvalidHTTPError.invalid))
|
||||
return
|
||||
}
|
||||
|
||||
let decoded: Result<DiningLocations, Error> = Result(catching: { try JSONDecoder().decode(DiningLocations.self, from: data) })
|
||||
completionHandler(decoded)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
enum openStatus {
|
||||
case open
|
||||
case closed
|
||||
case openingSoon
|
||||
case closingSoon
|
||||
}
|
||||
|
||||
struct DiningInfo {
|
||||
let id: Int
|
||||
let name: String
|
||||
let openTime: Date?
|
||||
let closeTime: Date?
|
||||
let open: openStatus
|
||||
}
|
||||
|
||||
func getLocationInfo(location: DiningLocation) -> DiningInfo {
|
||||
print("beginning parse for \(location.name)")
|
||||
|
||||
// Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week.
|
||||
if location.events.isEmpty {
|
||||
return DiningInfo(
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
openTime: .none,
|
||||
closeTime: .none,
|
||||
open: .closed)
|
||||
}
|
||||
|
||||
let openString: String
|
||||
let closeString: String
|
||||
|
||||
// Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there
|
||||
// are exceptions, use those times for the day, otherwise we can just use the default times.
|
||||
if let exceptions = location.events[0].exceptions, !exceptions.isEmpty {
|
||||
// Early return if the exception for the day specifies that the location is closed. Used for things like holidays.
|
||||
if !location.events[0].exceptions![0].open {
|
||||
return DiningInfo(
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
openTime: .none,
|
||||
closeTime: .none,
|
||||
open: .closed)
|
||||
}
|
||||
openString = location.events[0].exceptions![0].startTime
|
||||
closeString = location.events[0].exceptions![0].endTime
|
||||
} else {
|
||||
openString = location.events[0].startTime
|
||||
closeString = location.events[0].endTime
|
||||
}
|
||||
|
||||
// I hate all of this date component nonsense.
|
||||
let openParts = openString.split(separator: ":").map { Int($0) ?? 0 }
|
||||
let openTimeComponents = DateComponents(hour: openParts[0], minute: openParts[1], second: openParts[2])
|
||||
|
||||
let closeParts = closeString.split(separator: ":").map { Int($0) ?? 0 }
|
||||
let closeTimeComponents = DateComponents(hour: closeParts[0], minute: closeParts[1], second: closeParts[2])
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
let openDate = calendar.date(
|
||||
bySettingHour: openTimeComponents.hour!,
|
||||
minute: openTimeComponents.minute!,
|
||||
second: openTimeComponents.second!,
|
||||
of: now)!
|
||||
|
||||
var closeDate = calendar.date(
|
||||
bySettingHour: closeTimeComponents.hour!,
|
||||
minute: closeTimeComponents.minute!,
|
||||
second: closeTimeComponents.second!,
|
||||
of: now)!
|
||||
|
||||
// If the closing time is less than or equal to the opening time, it's probably midnight and means either open until midnight
|
||||
// or open 24/7, in the case of Bytes.
|
||||
if closeDate <= openDate {
|
||||
closeDate = calendar.date(byAdding: .day, value: 1, to: closeDate)!
|
||||
}
|
||||
|
||||
// This can probably be done in a cleaner way 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 isOpen = (now >= openDate && now <= closeDate)
|
||||
let openStatus: openStatus
|
||||
if isOpen {
|
||||
if closeDate < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
||||
openStatus = .closingSoon
|
||||
} else {
|
||||
openStatus = .open
|
||||
}
|
||||
} else {
|
||||
if openDate < calendar.date(byAdding: .minute, value: 30, to: now)! {
|
||||
openStatus = .openingSoon
|
||||
} else {
|
||||
openStatus = .closed
|
||||
}
|
||||
}
|
||||
|
||||
return DiningInfo(
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
openTime: openDate,
|
||||
closeTime: closeDate,
|
||||
open: openStatus)
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
// RIT_DiningApp.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell Bagley on 8/31/25.
|
||||
// Created by Campbell on 8/31/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|