Improved DetailView, preliminary visiting chef support

The DetailView now presents information in a more appealing way, and also fetches the opening hours for the entire week, so you can see more than just the current day's hours for a location.
Also added preliminary support for parsing visiting chef information. Times are not being parsed yet because the formatting for them is super bad and inconsistent, but the names and descriptions are parsed. A "Today's Visiting Chefs" button has been added to the top of ContentView that brings you to a basic screen listing all of the locations with visiting chefs and telling you what they are. Currently times are presented as part of the name of the location like they are in the TigerCenter response data.
This commit is contained in:
Campbell 2025-09-08 01:26:31 -04:00
parent 30c025e113
commit ea2538ce18
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
6 changed files with 299 additions and 67 deletions

View File

@ -254,7 +254,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -283,7 +283,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;

View File

@ -70,12 +70,12 @@ struct ContentView: View {
// Asynchronously fetch the data for all of the locations and parse their data to display it.
private func getDiningData() {
var newDiningLocations: [DiningLocation] = []
getAllDiningInfo { result in
getAllDiningInfo(date: nil) { 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])
let diningInfo = parseLocationInfo(location: locations.locations[i])
print(diningInfo.name)
DispatchQueue.global().sync {
newDiningLocations.append(diningInfo)
@ -123,6 +123,13 @@ struct ContentView: View {
} else {
VStack() {
List {
if searchText.isEmpty {
Section(content: {
NavigationLink(destination: VisitingChefs(diningLocations: diningLocations)) {
Text("Today's Visiting Chefs")
}
})
}
Section(content: {
LocationList(diningLocations: filteredLocations)
}, footer: {

View File

@ -23,7 +23,19 @@ struct SafariView: UIViewControllerRepresentable {
struct DetailView: View {
@State var location: DiningLocation
@State private var isLoading: Bool = true
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false
@State private var openString: String = ""
@State private var week: [Date] = []
@State private var weeklyHours: [[String]] = []
private let daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private let display: DateFormatter = {
let display = DateFormatter()
@ -33,15 +45,79 @@ struct DetailView: View {
return display
}()
private func requestDone(result: Result<DiningLocationParser, Error>) -> Void {
switch result {
case .success(let location):
let diningInfo = parseLocationInfo(location: location)
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))")
}
weeklyHours.append(timeStrings)
} else {
weeklyHours.append(["Closed"])
}
case .failure(let error):
print(error)
}
if week.count > 0 {
DispatchQueue.global().async {
let date_string = week.removeFirst().formatted(.iso8601
.year().month().day()
.dateSeparator(.dash))
getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone)
}
} else {
isLoading = false
print(weeklyHours)
}
}
private func getWeeklyHours() {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let dayOfWeek = calendar.component(.weekday, from: today)
week = calendar.range(of: .weekday, in: .weekOfYear, for: today)!
.compactMap { calendar.date(byAdding: .day, value: $0 - dayOfWeek, to: today) }
DispatchQueue.global().async {
let date_string = week.removeFirst().formatted(.iso8601
.year().month().day()
.dateSeparator(.dash))
getSingleDiningInfo(date: date_string, locationId: location.id, completionHandler: requestDone)
}
}
var body: some View {
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)
}
.onAppear {
getWeeklyHours()
}
.padding()
} else {
ScrollView {
VStack(alignment: .leading) {
Text(location.name)
.font(.title)
.fontWeight(.bold)
Text(location.summary)
.font(.title2)
.foregroundStyle(.secondary)
HStack(alignment: .top) {
HStack(alignment: .top, spacing: 3) {
switch location.open {
case .open:
Text("Open")
@ -56,11 +132,18 @@ struct DetailView: View {
Text("Closing Soon")
.foregroundStyle(.orange)
}
Text("")
.foregroundStyle(.secondary)
VStack {
if let times = location.diningTimes, !times.isEmpty {
ForEach(times, id: \.self) { time in
Text("\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime))")
Text(openString)
.foregroundStyle(.secondary)
.onAppear {
openString = ""
for time in times {
openString += "\(display.string(from: time.openTime)) - \(display.string(from: time.closeTime)), "
}
openString = String(openString.prefix(openString.count - 2))
}
} else {
Text("Not Open Today")
@ -75,6 +158,25 @@ struct DetailView: View {
Text("View on Map")
}
.padding(.bottom, 10)
VStack(alignment: .leading) {
Text("This Week's Hours")
.font(.title3)
.fontWeight(.semibold)
ForEach(weeklyHours.indices, id: \.self) { index in
HStack(alignment: .top) {
Text("\(daysOfWeek[index])")
Spacer()
VStack {
ForEach(weeklyHours[index].indices, id: \.self) { innerIndex in
Text(weeklyHours[index][innerIndex])
.foregroundStyle(.secondary)
}
}
}
Divider()
}
}
.padding(.bottom, 10)
Text(location.desc)
.font(.body)
.padding(.bottom, 10)
@ -90,6 +192,7 @@ struct DetailView: View {
SafariView(url: URL(string: location.mapsUrl)!)
}
}
}
}
#Preview {
@ -100,5 +203,6 @@ struct DetailView: View {
desc: "A long description of the place",
mapsUrl: "https://example.com",
diningTimes: [DiningTimes(openTime: Date(), closeTime: Date())],
open: .open))
open: .open,
visitingChefs: nil))
}

View File

@ -11,13 +11,17 @@ 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 getAllDiningInfo(completionHandler: @escaping (Result<DiningLocationsParser, Error>) -> Void) {
// This API requesting 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.
// 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 = Date().formatted(.iso8601
let date_string: String = if let date { date } else {
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 {
@ -44,7 +48,42 @@ func getAllDiningInfo(completionHandler: @escaping (Result<DiningLocationsParser
}.resume()
}
func getLocationInfo(location: DiningLocationParser) -> DiningLocation {
// 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 url_string = "https://tigercenter.rit.edu/tigerCenterApi/tc/dining-single?date=\(date_string)&locId=\(locationId)"
print("making request to \(url_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<DiningLocationParser, Error> = Result(catching: { try JSONDecoder().decode(DiningLocationParser.self, from: data) })
completionHandler(decoded)
}.resume()
}
func parseLocationInfo(location: DiningLocationParser) -> DiningLocation {
print("beginning parse for \(location.name)")
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
@ -59,7 +98,8 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation {
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: nil,
open: .closed)
open: .closed,
visitingChefs: nil)
}
var openStrings: [String] = []
@ -78,7 +118,8 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation {
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: nil,
open: .closed)
open: .closed,
visitingChefs: nil)
}
openStrings.append(exceptions[0].startTime)
closeStrings.append(exceptions[0].endTime)
@ -153,6 +194,23 @@ func getLocationInfo(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]?
if !location.menus.isEmpty {
var chefs: [VisitngChef] = []
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!))
}
}
visitingChefs = chefs
} else {
visitingChefs = nil
}
return DiningLocation(
id: location.id,
name: location.name,
@ -160,5 +218,6 @@ func getLocationInfo(location: DiningLocationParser) -> DiningLocation {
desc: desc,
mapsUrl: location.mapsUrl,
diningTimes: diningTimes,
open: openStatus)
open: openStatus,
visitingChefs: visitingChefs)
}

View File

@ -11,7 +11,7 @@ import Foundation
// be improved later when I feel like it.
struct DiningLocationParser: Decodable {
// An individual "event", which is just an open period for the location.
struct Events: Decodable {
struct Event: Decodable {
// Hour exceptions for the given event.
struct HoursException: Decodable {
let id: Int
@ -26,13 +26,21 @@ struct DiningLocationParser: Decodable {
let endTime: String
let exceptions: [HoursException]?
}
// An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because
// visiting chefs have descriptions but specials do not.
struct Menu: Decodable {
let name: String
let description: String?
let category: String
}
// Other basic information to read from a location's JSON that we'll need later.
let id: Int
let name: String
let summary: String
let description: String
let mapsUrl: String
let events: [Events]
let events: [Event]
let menus: [Menu]
}
// Struct that probably doesn't need to exist but this made parsing the list of location responses easy.
@ -54,6 +62,12 @@ struct DiningTimes: Equatable, Hashable {
var closeTime: Date
}
// A visitng chef present at a location.
struct VisitngChef: Equatable, Hashable {
let name: String
let description: String
}
// The basic information about a dining location needed to display it in the app after parsing is finished.
struct DiningLocation: Identifiable, Hashable {
let id: Int
@ -63,4 +77,5 @@ struct DiningLocation: Identifiable, Hashable {
let mapsUrl: String
let diningTimes: [DiningTimes]?
let open: OpenStatus
let visitingChefs: [VisitngChef]?
}

View File

@ -0,0 +1,47 @@
//
// VisitingChefs.swift
// RIT Dining
//
// Created by Campbell on 9/8/25.
//
import SwiftUI
struct VisitingChefs: View {
@State var diningLocations: [DiningLocation]
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)
}
}
.padding(.bottom, 15)
}
}
}
.navigationTitle("Visiting Chefs")
.navigationBarTitleDisplayMode(.inline)
}
}
#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")])])
}