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:
2025-09-17 22:38:09 -04:00
parent 9d16be646a
commit 2c607553ac
14 changed files with 216 additions and 33 deletions

View 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()
}

View 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))
}

View 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()
}

View 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()
}