Files
RIT-Dining/TigerDine/ContentView.swift
NinjaCheetah 0c07c509f3 Add feedback submission sheet
Also fixed a bug where locations with overlapping close and open times would show "Closing Soon" for 30 minutes just to then switch to "Open" when it rolled over.
2026-02-17 00:01:55 -05:00

208 lines
8.5 KiB
Swift

//
// ContentView.swift
// TigerDine
//
// Created by Campbell on 8/31/25.
//
import SwiftUI
struct ContentView: View {
// Save sort/filter options in AppStorage so that they actually get saved.
@AppStorage("openLocationsOnly") var openLocationsOnly: Bool = false
@AppStorage("openLocationsFirst") var openLocationsFirst: Bool = false
@Environment(DiningModel.self) var model
@Binding var targetLocationId: Int?
@Binding var handledLocationId: Int?
@State private var loadFailed: Bool = false
@State private var showingDonationSheet: Bool = false
@State private var showingFeedbackSheet: Bool = false
@State private var searchText: String = ""
@State private var path = NavigationPath()
// Small wrapper around the method on the model so that errors can be handled by showing the uh error screen.
private func getDiningData(bustCache: Bool = false) async {
do {
if bustCache {
try await model.getHoursByDay()
}
else {
try await model.getHoursByDayCached()
}
} catch {
loadFailed = true
}
}
// Start a perpetually running timer to refresh the open statuses, so that they automatically switch as appropriate without
// needing to refresh the data. You don't need to yell at the API again to know that the location opening at 11:00 AM should now
// display "Open" instead of "Opening Soon" now that it's 11:01.
private func updateOpenStatuses() async {
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
model.updateOpenStatuses()
// If the last refreshed date isn't today, that means we probably passed midnight and need to refresh the data.
// So do that.
if !Calendar.current.isDateInToday(model.lastRefreshed ?? Date()) {
Task {
await getDiningData()
}
}
}
}
private func handleOpenDeepLink() {
guard
model.isLoaded,
let targetLocationId,
handledLocationId != targetLocationId,
!model.locationsByDay.isEmpty,
let location = model.locationsByDay[0].first(where: { $0.id == targetLocationId })
else { return }
handledLocationId = targetLocationId
print("TigerDine opened to \(location.name)")
// Reset the path back to the root (which is here, ContentView).
path = NavigationPath()
// Do this in an async block because apparently SwiftUI won't handle these two NavigationPath changes
// consecutively. Putting the second change in an async block makes it actually update the path the
// second time.
DispatchQueue.main.async {
path.append(location)
self.targetLocationId = nil
}
}
var body: some View {
NavigationStack(path: $path) {
if !model.isLoaded {
VStack {
LoadingView(loadFailed: $loadFailed)
}
} else {
VStack() {
List {
Section(content: {
NavigationLink(destination: VisitingChefs()) {
Text("Upcoming Visiting Chefs")
}
NavigationLink(destination: FoodTruckView()) {
Text("Weekend Food Trucks")
}
})
Section(content: {
// Prevents crashing if the list is empty. Which shouldn't ever happen but still.
if !model.locationsByDay.isEmpty {
LocationList(
openLocationsFirst: $openLocationsFirst,
openLocationsOnly: $openLocationsOnly,
searchText: $searchText
)
}
}, footer: {
if let lastRefreshed = model.lastRefreshed {
VStack(alignment: .center) {
Text("Last refreshed: \(lastRefreshed.formatted())")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
})
}
.navigationDestination(for: DiningLocation.self) { location in
DetailView(locationId: location.id)
}
.onChange(of: targetLocationId) {
handleOpenDeepLink()
}
.onChange(of: model.isLoaded) {
handleOpenDeepLink()
}
}
.navigationTitle("TigerDine")
.searchable(text: $searchText, prompt: "Search")
.refreshable {
await getDiningData(bustCache: true)
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
NavigationLink(destination: VisitingChefPush()) {
Image(systemName: "bell.badge")
}
Menu {
Button(action: {
Task {
await getDiningData(bustCache: true)
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
#if DEBUG
Button(action: {
model.lastRefreshed = Date(timeIntervalSince1970: 0.0)
}) {
Label("Invalidate Cache", systemImage: "ant")
}
#endif
Divider()
NavigationLink(destination: AboutView()) {
Image(systemName: "info.circle")
Text("About")
}
Button(action: {
showingFeedbackSheet = true
}) {
Label("Feedback", systemImage: "paperplane")
}
Button(action: {
showingDonationSheet = true
}) {
Label("Donate", systemImage: "heart")
}
} label: {
Image(systemName: "slider.horizontal.3")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Menu {
Toggle(isOn: $openLocationsOnly) {
Label("Hide Closed Locations", systemImage: "eye.slash")
}
Toggle(isOn: $openLocationsFirst) {
Label("Open Locations First", systemImage: "arrow.up.arrow.down")
}
} label: {
Image(systemName: "line.3.horizontal.decrease")
}
if #unavailable(iOS 26.0) {
Spacer()
}
}
if #available(iOS 26.0, *) {
ToolbarSpacer(.flexible, placement: .bottomBar)
DefaultToolbarItem(kind: .search, placement: .bottomBar)
}
}
}
}
.task {
await getDiningData()
await updateOpenStatuses()
}
.sheet(isPresented: $showingDonationSheet) {
DonationView()
}
.sheet(isPresented: $showingFeedbackSheet) {
FeedbackView()
}
}
}
#Preview {
@Previewable @State var targetLocationId: Int?
@Previewable @State var handledLocationId: Int?
ContentView(targetLocationId: $targetLocationId, handledLocationId: $handledLocationId)
}