RIT-Dining/TigerDine/ContentView.swift
NinjaCheetah 26e419a41b
Added caching, background refresh, and first widget
- The main dining location information is now cached on download.
   - The freshness of the cache is checked whenever it's loaded, and if the last refreshed date is not today's date then it's dropped and the app refreshes from the API normally.
   - This reduces load times if you open the app multiple times a day. The data won't change during the day, so you can load it the first time and then use the cache the rest of the time.
   - Refreshing via pull to refresh or the refresh button still refreshes the cache from the server.
- Added a background refresh task.
   - TigerDine now registered a background fetch task with the device that will update the location information up to a maximum of 4 times per day. The cache is checked first, so a new request will only be made if the cache is stale.
   - This allows for automatic notification scheduling at times other than when the app is launched. As long as background tasks can run, notifications will be automatically scheduled when necessary.
   - Depending on the timing, this may allow you to never see any load times in TigerDine, since the cache might already be up to date before you use the app for the first time in a day.
- Started adding widgets!
   - TigerDine now offers an hours widget that lets you put the hours for a specified location on your home screen, along with a visual indicator of when that location is open today.
   - The widget automatically feeds off of TigerDine's cache, so hey, no extra network requests required!
   - This widget currently DOES NOT support multi-opening locations like Brick City Cafe or Gracie's. This is still a work in progress.
2026-01-09 19:19:04 -05:00

197 lines
7.9 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
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var showingDonationSheet: Bool = false
@State private var rotationDegrees: Double = 0
@State private var searchText: String = ""
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
// 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()
}
isLoading = false
} catch {
isLoading = true
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()
}
}
}
}
var body: some View {
NavigationStack() {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching dining data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button(action: {
loadFailed = false
Task {
await getDiningData(bustCache: true)
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.padding(.top, 10)
} else {
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 {
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)
}
}
})
}
}
.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")
}
Divider()
NavigationLink(destination: AboutView()) {
Image(systemName: "info.circle")
Text("About")
}
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()
}
}
}
#Preview {
ContentView()
}