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. /// Parses the JSON responses from the TigerCenter API into the format used throughout TigerDine.
func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation { 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. // The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
let desc = location.description.replacingOccurrences(of: "<br />", with: "") let desc = location.description.replacingOccurrences(of: "<br />", with: "")

View File

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

View File

@@ -14,17 +14,13 @@ struct ContentView: View {
@Environment(DiningModel.self) var model @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 loadFailed: Bool = false
@State private var showingDonationSheet: Bool = false @State private var showingDonationSheet: Bool = false
@State private var rotationDegrees: Double = 0
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var path = NavigationPath()
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. // 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 { private func getDiningData(bustCache: Bool = false) async {
@@ -35,9 +31,7 @@ struct ContentView: View {
else { else {
try await model.getHoursByDayCached() try await model.getHoursByDayCached()
} }
isLoading = false
} catch { } catch {
isLoading = true
loadFailed = 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 { var body: some View {
NavigationStack() { NavigationStack(path: $path) {
if isLoading { if !model.isLoaded {
VStack { VStack {
if loadFailed { LoadingView(loadFailed: $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 { } else {
VStack() { VStack() {
List { 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") .navigationTitle("TigerDine")
.searchable(text: $searchText, prompt: "Search") .searchable(text: $searchText, prompt: "Search")
@@ -144,7 +137,13 @@ struct ContentView: View {
}) { }) {
Label("Refresh", systemImage: "arrow.clockwise") Label("Refresh", systemImage: "arrow.clockwise")
} }
#if DEBUG
Button(action: {
model.lastRefreshed = Date(timeIntervalSince1970: 0.0)
}) {
Label("Invalidate Cache", systemImage: "ant")
}
#endif
Divider() Divider()
NavigationLink(destination: AboutView()) { NavigationLink(destination: AboutView()) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
@@ -192,5 +191,8 @@ struct ContentView: View {
} }
#Preview { #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 favorites = Favorites()
var notifyingChefs = NotifyingChefs() var notifyingChefs = NotifyingChefs()
var visitingChefPushes = VisitingChefPushesModel() var visitingChefPushes = VisitingChefPushesModel()
// Loading state to access in the UI.
var isLoaded = false
func getDaysRepresented() async { func getDaysRepresented() async {
let calendar = Calendar.current 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). /// 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 { func getHoursByDay() async throws {
print("loading from network")
await getDaysRepresented() await getDaysRepresented()
var newLocationsByDay = [[DiningLocation]]() var newLocationsByDay = [[DiningLocation]]()
for day in daysRepresented { for day in daysRepresented {
@@ -78,8 +81,11 @@ class DiningModel {
// Then refresh widget timelines with the new data. // Then refresh widget timelines with the new data.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
// And finally schedule a background refresh 6 hours from now. // Then schedule a background refresh 6 hours from now.
scheduleNextRefresh() 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. /// 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 we can't access the lastRefreshed key, then there is likely no cache.
if let lastRefreshed = lastRefreshed { if let lastRefreshed = lastRefreshed {
if Calendar.current.startOfDay(for: now) == Calendar.current.startOfDay(for: 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. // Last refresh happened today, so the cache is fresh and we should load that.
await getDaysRepresented() await getDaysRepresented()
let decoder = JSONDecoder() let decoder = JSONDecoder()
@@ -98,8 +105,11 @@ class DiningModel {
locationsByDay = cachedLocationsByDay locationsByDay = cachedLocationsByDay
updateOpenStatuses() updateOpenStatuses()
await cleanupPushes() await cleanupPushes()
isLoaded = true
return return
} }
print("cache miss")
// Otherwise, the cache is stale and we can fall out to the call to update it. // Otherwise, the cache is stale and we can fall out to the call to update it.
} }
try await getHoursByDay() try await getHoursByDay()

View File

@@ -6,6 +6,19 @@
<array> <array>
<string>dev.ninjacheetah.RIT-Dining.refresh</string> <string>dev.ninjacheetah.RIT-Dining.refresh</string>
</array> </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> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>

View File

@@ -13,9 +13,11 @@ import WidgetKit
struct TigerDineApp: App { struct TigerDineApp: App {
// The model needs to be instantiated here so that it's also available in the context of the refresh background task. // 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 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. /// 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 { do {
try await model.getHoursByDayCached() try await model.getHoursByDayCached()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
@@ -26,10 +28,29 @@ struct TigerDineApp: App {
scheduleNextRefresh() 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 { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView(targetLocationId: $targetLocationId, handledLocationId: $handledLocationId)
.environment(model) .environment(model)
.onOpenURL { url in
targetLocationId = parseOpenedURL(url: url)
handledLocationId = nil
}
} }
.backgroundTask(.appRefresh("dev.ninjacheetah.RIT-Dining.refresh")) { .backgroundTask(.appRefresh("dev.ninjacheetah.RIT-Dining.refresh")) {
await handleAppRefresh() await handleAppRefresh()

View File

@@ -12,15 +12,8 @@ struct FoodTruckView: View {
@State private var foodTruckEvents: [FoodTruckEvent] = [] @State private var foodTruckEvents: [FoodTruckEvent] = []
@State private var isLoading: Bool = true @State private var isLoading: Bool = true
@State private var loadFailed: Bool = false @State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var showingSafari: Bool = false @State private var showingSafari: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
private func doFoodTruckStuff() async { private func doFoodTruckStuff() async {
switch await getFoodTruckPage() { switch await getFoodTruckPage() {
case .success(let schedule): case .success(let schedule):
@@ -35,34 +28,11 @@ struct FoodTruckView: View {
var body: some View { var body: some View {
if isLoading { if isLoading {
VStack { VStack {
if loadFailed { LoadingView(loadFailed: $loadFailed, loadingType: .truck)
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)
}
} }
.task { .task {
await doFoodTruckStuff() await doFoodTruckStuff()
} }
.padding()
} else { } else {
ScrollView { ScrollView {
VStack(alignment: .leading) { 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 { var body: some View {
ForEach(filteredLocations, id: \.self) { location in ForEach(filteredLocations, id: \.self) { location in
NavigationLink(destination: DetailView(locationId: location.id)) { NavigationLink(value: location) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Text(location.name) Text(location.name)

View File

@@ -14,18 +14,11 @@ struct MenuView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var isLoading: Bool = true @State private var isLoading: Bool = true
@State private var loadFailed: Bool = false @State private var loadFailed: Bool = false
@State private var rotationDegrees: Double = 0
@State private var selectedMealPeriod: Int = 0 @State private var selectedMealPeriod: Int = 0
@State private var openPeriods: [Int] = [] @State private var openPeriods: [Int] = []
@StateObject private var dietaryRestrictionsModel = MenuDietaryRestrictionsModel() @StateObject private var dietaryRestrictionsModel = MenuDietaryRestrictionsModel()
@State private var showingDietaryRestrictionsSheet: Bool = false @State private var showingDietaryRestrictionsSheet: Bool = false
private var animation: Animation {
.linear
.speed(0.1)
.repeatForever(autoreverses: false)
}
func getOpenPeriods() async { 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 // 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! // fetching this information more than once, but hey it works!
@@ -114,33 +107,11 @@ struct MenuView: View {
var body: some View { var body: some View {
if isLoading { if isLoading {
VStack { VStack {
if loadFailed { LoadingView(loadFailed: $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)
}
} }
.task { .task {
await getOpenPeriods() await getOpenPeriods()
} }
.padding()
} else { } else {
VStack { VStack {
if !menuItems.isEmpty { if !menuItems.isEmpty {
@@ -219,7 +190,6 @@ struct MenuView: View {
} }
} }
.onChange(of: selectedMealPeriod) { .onChange(of: selectedMealPeriod) {
rotationDegrees = 0
isLoading = true isLoading = true
Task { Task {
await getMenuForPeriod(mealPeriodId: selectedMealPeriod) await getMenuForPeriod(mealPeriodId: selectedMealPeriod)

View File

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