Added deep links for widgets

- Widgets will now open the appropriate DetailView in the app when you tap on them.
  - ...except when they don't. This is still a little buggy. It works correctly when the app is already alive in the background but only works about 75% of the time when the app isn't running yet.
- Unified the loading view into a shared view used in all places requiring loading
This commit is contained in:
2026-01-24 15:11:45 -05:00
parent b51335768f
commit 42b3c35f68
11 changed files with 179 additions and 122 deletions

View File

@@ -54,7 +54,7 @@ func parseMultiOpenStatus(diningTimes: [DiningTimes]?, referenceTime: Date) -> O
/// Parses the JSON responses from the TigerCenter API into the format used throughout TigerDine.
func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation {
print("beginning parse for \(location.name)")
print("beginning parse for \(location.name) (id: \(location.id))")
// The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
let desc = location.description.replacingOccurrences(of: "<br />", with: "")

View File

@@ -292,7 +292,7 @@
CODE_SIGN_ENTITLEMENTS = TigerDineWidgets/TigerDineWidgets.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TigerDineWidgets/Info.plist;
@@ -325,7 +325,7 @@
CODE_SIGN_ENTITLEMENTS = TigerDineWidgets/TigerDineWidgets.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TigerDineWidgets/Info.plist;
@@ -481,7 +481,7 @@
CODE_SIGN_ENTITLEMENTS = TigerDine/TigerDine.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -518,7 +518,7 @@
CODE_SIGN_ENTITLEMENTS = TigerDine/TigerDine.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5GF7GKNTK4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;

View File

@@ -14,17 +14,13 @@ struct ContentView: View {
@Environment(DiningModel.self) var model
@State private var isLoading: Bool = true
@Binding var targetLocationId: Int?
@Binding var handledLocationId: Int?
@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)
}
@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 {
@@ -35,9 +31,7 @@ struct ContentView: View {
else {
try await model.getHoursByDayCached()
}
isLoading = false
} catch {
isLoading = true
loadFailed = true
}
}
@@ -58,43 +52,33 @@ struct ContentView: View {
}
}
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() {
if isLoading {
NavigationStack(path: $path) {
if !model.isLoaded {
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)
}
LoadingView(loadFailed: $loadFailed)
}
.padding()
} else {
VStack() {
List {
@@ -125,6 +109,15 @@ struct ContentView: View {
}
})
}
.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")
@@ -144,7 +137,13 @@ struct ContentView: View {
}) {
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")
@@ -192,5 +191,8 @@ struct ContentView: View {
}
#Preview {
ContentView()
@Previewable @State var targetLocationId: Int?
@Previewable @State var handledLocationId: Int?
ContentView(targetLocationId: $targetLocationId, handledLocationId: $handledLocationId)
}

View File

@@ -29,6 +29,8 @@ class DiningModel {
var favorites = Favorites()
var notifyingChefs = NotifyingChefs()
var visitingChefPushes = VisitingChefPushesModel()
// Loading state to access in the UI.
var isLoaded = false
func getDaysRepresented() async {
let calendar = Calendar.current
@@ -41,6 +43,7 @@ class DiningModel {
/// This is the actual method responsible for making requests to the API for the current day and next 6 days to collect all of the information that the app needs for the various view. Making it part of the model allows it to be updated from any view at any time, and prevents excess API requests (if you never refresh, the app will never need to make more than 7 calls per launch).
func getHoursByDay() async throws {
print("loading from network")
await getDaysRepresented()
var newLocationsByDay = [[DiningLocation]]()
for day in daysRepresented {
@@ -78,8 +81,11 @@ class DiningModel {
// Then refresh widget timelines with the new data.
WidgetCenter.shared.reloadAllTimelines()
// And finally schedule a background refresh 6 hours from now.
// Then schedule a background refresh 6 hours from now.
scheduleNextRefresh()
// And finally set the loaded state to true.
isLoaded = true
}
/// Wrapper function for the real getHoursByDay() that checks the last refreshed stamp and uses cached data if it's fresh or triggers a refresh if it's stale.
@@ -88,6 +94,7 @@ class DiningModel {
// If we can't access the lastRefreshed key, then there is likely no cache.
if let lastRefreshed = lastRefreshed {
if Calendar.current.startOfDay(for: now) == Calendar.current.startOfDay(for: lastRefreshed) {
print("cache hit, loading from cache")
// Last refresh happened today, so the cache is fresh and we should load that.
await getDaysRepresented()
let decoder = JSONDecoder()
@@ -98,8 +105,11 @@ class DiningModel {
locationsByDay = cachedLocationsByDay
updateOpenStatuses()
await cleanupPushes()
isLoaded = true
return
}
print("cache miss")
// Otherwise, the cache is stale and we can fall out to the call to update it.
}
try await getHoursByDay()

View File

@@ -6,6 +6,19 @@
<array>
<string>dev.ninjacheetah.RIT-Dining.refresh</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>dev.ninjacheetah.RIT-Dining</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tigerdine</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>

View File

@@ -13,9 +13,11 @@ import WidgetKit
struct TigerDineApp: App {
// The model needs to be instantiated here so that it's also available in the context of the refresh background task.
@State private var model = DiningModel()
@State private var targetLocationId: Int?
@State private var handledLocationId: Int?
/// Triggers a refresh on the model that will only make network requests if the cache is stale, and then schedules the next refresh.
func handleAppRefresh() async {
private func handleAppRefresh() async {
do {
try await model.getHoursByDayCached()
WidgetCenter.shared.reloadAllTimelines()
@@ -26,10 +28,29 @@ struct TigerDineApp: App {
scheduleNextRefresh()
}
private func parseOpenedURL(url: URL) -> Int? {
guard url.scheme == "tigerdine" else { return nil }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
if components.path == "/location" {
print("opening to a location")
if let queryItems = components.queryItems {
if queryItems.map(\.name).contains("id") {
return Int(queryItems.first(where: { $0.name == "id" })!.value!)
}
}
}
return nil
}
var body: some Scene {
WindowGroup {
ContentView()
ContentView(targetLocationId: $targetLocationId, handledLocationId: $handledLocationId)
.environment(model)
.onOpenURL { url in
targetLocationId = parseOpenedURL(url: url)
handledLocationId = nil
}
}
.backgroundTask(.appRefresh("dev.ninjacheetah.RIT-Dining.refresh")) {
await handleAppRefresh()

View File

@@ -12,15 +12,8 @@ struct FoodTruckView: View {
@State private var foodTruckEvents: [FoodTruckEvent] = []
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private func doFoodTruckStuff() async {
switch await getFoodTruckPage() {
case .success(let schedule):
@@ -35,34 +28,11 @@ struct FoodTruckView: View {
var body: some View {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching food truck data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: "truck.box")
.resizable()
.scaledToFit()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("One moment...")
.foregroundStyle(.secondary)
}
LoadingView(loadFailed: $loadFailed, loadingType: .truck)
}
.task {
await doFoodTruckStuff()
}
.padding()
} else {
ScrollView {
VStack(alignment: .leading) {

View File

@@ -0,0 +1,64 @@
//
// LoadingView.swift
// TigerDine
//
// Created by Campbell on 1/24/26.
//
import SwiftUI
enum LoadingType {
case normal
case truck
}
struct LoadingView: View {
@Binding var loadFailed: Bool
@State var loadingType: LoadingType = .normal
@State private var rotationDegrees: Double = 0
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private var loadingSymbol: String {
switch loadingType {
case .normal:
return "fork.knife.circle"
case .truck:
return "truck.box"
}
}
var body: some View {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while loading data. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: loadingSymbol)
.resizable()
.scaledToFit()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("Loading...")
.foregroundStyle(.secondary)
}
}
.padding()
}
}

View File

@@ -57,7 +57,7 @@ struct LocationList: View {
var body: some View {
ForEach(filteredLocations, id: \.self) { location in
NavigationLink(destination: DetailView(locationId: location.id)) {
NavigationLink(value: location) {
VStack(alignment: .leading) {
HStack {
Text(location.name)

View File

@@ -14,18 +14,11 @@ struct MenuView: View {
@State private var searchText: String = ""
@State private var isLoading: Bool = true
@State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var selectedMealPeriod: Int = 0
@State private var openPeriods: [Int] = []
@StateObject private var dietaryRestrictionsModel = MenuDietaryRestrictionsModel()
@State private var showingDietaryRestrictionsSheet: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
func getOpenPeriods() async {
// Only run this if we haven't already gotten the open periods. This is somewhat of a bandaid solution to the issue of
// fetching this information more than once, but hey it works!
@@ -114,33 +107,11 @@ struct MenuView: View {
var body: some View {
if isLoading {
VStack {
if loadFailed {
Image(systemName: "wifi.exclamationmark.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
Text("An error occurred while fetching the menu. Please check your network connection and try again.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Image(systemName: "fork.knife.circle")
.resizable()
.frame(width: 75, height: 75)
.foregroundStyle(.accent)
.rotationEffect(.degrees(rotationDegrees))
.onAppear {
withAnimation(animation) {
rotationDegrees = 360.0
}
}
Text("One moment...")
.foregroundStyle(.secondary)
}
LoadingView(loadFailed: $loadFailed)
}
.task {
await getOpenPeriods()
}
.padding()
} else {
VStack {
if !menuItems.isEmpty {
@@ -219,7 +190,6 @@ struct MenuView: View {
}
}
.onChange(of: selectedMealPeriod) {
rotationDegrees = 0
isLoading = true
Task {
await getMenuForPeriod(mealPeriodId: selectedMealPeriod)

View File

@@ -20,7 +20,8 @@ struct Provider: AppIntentTimelineProvider {
name: "Select a Location",
diningTimes: [
DiningTimes(openTime: startOfToday, closeTime: startOfTomorrow)
]
],
url: URL(string: "tigerdine://")!
)
}
@@ -53,7 +54,8 @@ struct Provider: AppIntentTimelineProvider {
OpenEntry(
date: $0,
name: baseEntry.name,
diningTimes: baseEntry.diningTimes
diningTimes: baseEntry.diningTimes,
url: baseEntry.url
)
}
@@ -79,7 +81,8 @@ struct Provider: AppIntentTimelineProvider {
return OpenEntry(
date: Date(),
name: location.name,
diningTimes: location.diningTimes
diningTimes: location.diningTimes,
url: URL(string: "tigerdine:///location?id=\(location.id)")!
)
}
@@ -115,6 +118,7 @@ struct OpenEntry: TimelineEntry {
let date: Date
let name: String
let diningTimes: [DiningTimes]?
let url: URL
}
struct OpenWidgetEntryView : View {
@@ -178,6 +182,7 @@ struct HoursWidget: Widget {
) { entry in
OpenWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
.widgetURL(entry.url)
}
.configurationDisplayName("Location Hours")
.description("See today's hours for a chosen location.")
@@ -196,7 +201,8 @@ struct HoursWidget: Widget {
openTime: Date(timeIntervalSince1970: 1767963600),
closeTime: Date(timeIntervalSince1970: 1767988800)
)
]
],
url: URL(string: "tigerdine:///location?id=31")!
)
OpenEntry(
date: Date(timeIntervalSince1970: 1767978000),
@@ -206,6 +212,7 @@ struct HoursWidget: Widget {
openTime: Date(timeIntervalSince1970: 1767963600),
closeTime: Date(timeIntervalSince1970: 1767988800)
)
]
],
url: URL(string: "tigerdine:///location?id=31")!
)
}