mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-05 05:25:29 -05:00
Added donation view
It seems reasonable to ask for some money here or there, since my Apple Developer Program membership was quite expensive just for this app
This commit is contained in:
46
RIT Dining/Views/AboutView.swift
Normal file
46
RIT Dining/Views/AboutView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// AboutView.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 9/12/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
let appVersionString: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
|
||||
let buildNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image("Icon")
|
||||
.resizable()
|
||||
.frame(width: 128, height: 128)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
Text("RIT Dining App")
|
||||
.font(.title)
|
||||
Text("because the RIT dining website is slow!")
|
||||
Text("Version \(appVersionString) (\(buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://github.com/NinjaCheetah/RIT-Dining")!)
|
||||
}) {
|
||||
Label("GitHub Repository", systemImage: "globe")
|
||||
}
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://tigercenter.rit.edu/")!)
|
||||
}) {
|
||||
Label("TigerCenter API", systemImage: "globe")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("About")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AboutView()
|
||||
}
|
||||
254
RIT Dining/Views/DetailView.swift
Normal file
254
RIT Dining/Views/DetailView.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// DetailView.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 9/1/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
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()
|
||||
display.timeZone = TimeZone(identifier: "America/New_York")
|
||||
display.dateStyle = .none
|
||||
display.timeStyle = .short
|
||||
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) {
|
||||
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)
|
||||
HStack(alignment: .top, spacing: 3) {
|
||||
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)
|
||||
}
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack {
|
||||
if let times = location.diningTimes, !times.isEmpty {
|
||||
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")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
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, 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)
|
||||
Text("IMPORTANT: Some locations' descriptions may refer to them as being cashless during certain hours. This is outdated information, as all RIT Dining locations are now cashless 24/7.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.navigationTitle("Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showingSafari) {
|
||||
SafariView(url: URL(string: location.mapsUrl)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DetailView(location: 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: nil,
|
||||
dailySpecials: nil))
|
||||
}
|
||||
109
RIT Dining/Views/DonationView.swift
Normal file
109
RIT Dining/Views/DonationView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// DonationView.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 9/17/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DonationView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var symbolDrawn: Bool = true
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
HStack {
|
||||
if #available(iOS 26.0, *) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.drawOn, isActive: symbolDrawn)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
symbolDrawn = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Text("Donate")
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.font(.title)
|
||||
Text("The RIT Dining app is free and open source software!")
|
||||
.fontWeight(.bold)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("However, the Apple Developer Program is expensive, and I paid $106.19 pretty much just to distribute this app and nothing else. If you can, I'd appreciate it if you wouldn't mind tossing a coin or two my way to help and make that expense a little less painful.")
|
||||
.multilineTextAlignment(.center)
|
||||
Text("No pressure though.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button(action: {
|
||||
openURL(URL(string: "https://ko-fi.com/ninjacheetah")!)
|
||||
}) {
|
||||
HStack(alignment: .center) {
|
||||
Image("kofiLogo")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading) {
|
||||
Text("Tip Me on Ko-fi")
|
||||
.fontWeight(.bold)
|
||||
Text("Chip in as much or as little as you'd like!")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
}
|
||||
.padding(.all, 6)
|
||||
.background (
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button(action: {
|
||||
openURL(URL(string: "paypal.me/NinjaCheetahX")!)
|
||||
}) {
|
||||
HStack(alignment: .center) {
|
||||
Image("paypalLogo")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading) {
|
||||
Text("Send Me Money Directly")
|
||||
.fontWeight(.bold)
|
||||
Text("I have nothing specific to say here!")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
}
|
||||
.padding(.all, 6)
|
||||
.background (
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.toolbar {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DonationView()
|
||||
}
|
||||
206
RIT Dining/Views/VisitingChefs.swift
Normal file
206
RIT Dining/Views/VisitingChefs.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// VisitingChefs.swift
|
||||
// RIT Dining
|
||||
//
|
||||
// Created by Campbell on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
struct VisitingChefs: View {
|
||||
@State private var locationsWithChefs: [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
|
||||
|
||||
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 {
|
||||
// Only save the locations that actually have visiting chefs to avoid extra iterations later.
|
||||
if let visitingChefs = diningInfo.visitingChefs, !visitingChefs.isEmpty {
|
||||
newDiningLocations.append(diningInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().sync {
|
||||
locationsWithChefs = newDiningLocations
|
||||
isLoading = false
|
||||
}
|
||||
case .failure(let error): print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getDiningData() {
|
||||
isLoading = true
|
||||
let dateString: String
|
||||
if !isTomorrow {
|
||||
dateString = getAPIFriendlyDateString(date: Date())
|
||||
print("fetching visiting chefs for date \(dateString) (today)")
|
||||
} else {
|
||||
let calendar = Calendar.current
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: Date())!
|
||||
dateString = getAPIFriendlyDateString(date: tomorrow)
|
||||
print("fetching visiting chefs for date \(dateString) (tomorrow)")
|
||||
}
|
||||
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 {
|
||||
if locationsWithChefs.isEmpty {
|
||||
Text("No visiting chefs today")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(locationsWithChefs, 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, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.sheet(item: $safariUrl) { url in
|
||||
SafariView(url: url.url)
|
||||
}
|
||||
.onAppear {
|
||||
getDiningData()
|
||||
}
|
||||
.refreshable {
|
||||
getDiningData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VisitingChefs()
|
||||
}
|
||||
Reference in New Issue
Block a user