mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-03 12:45:28 -05:00
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:
@@ -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: "")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
64
TigerDine/Views/Fragments/LoadingView.swift
Normal file
64
TigerDine/Views/Fragments/LoadingView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")!
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user