diff --git a/TigerDine/Components/SharedComponents.swift b/Shared/SharedComponents.swift similarity index 100% rename from TigerDine/Components/SharedComponents.swift rename to Shared/SharedComponents.swift diff --git a/TigerDine/Data/Types/TigerCenterTypes.swift b/Shared/Types/TigerCenterTypes.swift similarity index 92% rename from TigerDine/Data/Types/TigerCenterTypes.swift rename to Shared/Types/TigerCenterTypes.swift index e6e3a92..c4410b5 100644 --- a/TigerDine/Data/Types/TigerCenterTypes.swift +++ b/Shared/Types/TigerCenterTypes.swift @@ -49,7 +49,7 @@ struct DiningLocationsParser: Decodable { } /// Enum to represent the four possible states a given location can be in. -enum OpenStatus { +enum OpenStatus: Codable { case open case closed case openingSoon @@ -57,13 +57,13 @@ enum OpenStatus { } /// An individual open period for a location. -struct DiningTimes: Equatable, Hashable { +struct DiningTimes: Equatable, Hashable, Codable { var openTime: Date var closeTime: Date } /// Enum to represent the five possible states a visiting chef can be in. -enum VisitingChefStatus { +enum VisitingChefStatus: Codable { case hereNow case gone case arrivingLater @@ -72,7 +72,7 @@ enum VisitingChefStatus { } /// A visiting chef present at a location. -struct VisitingChef: Equatable, Hashable { +struct VisitingChef: Equatable, Hashable, Codable { let name: String let description: String var openTime: Date @@ -81,19 +81,19 @@ struct VisitingChef: Equatable, Hashable { } /// A daily special at a location. -struct DailySpecial: Equatable, Hashable { +struct DailySpecial: Equatable, Hashable, Codable { let name: String let type: String } /// The IDs required to get the menu for a location from FD MealPlanner. Only present if the location appears in the map. -struct FDMPIds: Hashable { +struct FDMPIds: Hashable, Codable { let locationId: Int let accountId: Int } /// The basic information about a dining location needed to display it in the app after parsing is finished. -struct DiningLocation: Identifiable, Hashable { +struct DiningLocation: Identifiable, Hashable, Codable { let id: Int let mdoId: Int let fdmpIds: FDMPIds? diff --git a/TigerDine.xcodeproj/project.pbxproj b/TigerDine.xcodeproj/project.pbxproj index ccb51d1..e40f0d8 100644 --- a/TigerDine.xcodeproj/project.pbxproj +++ b/TigerDine.xcodeproj/project.pbxproj @@ -8,13 +8,58 @@ /* Begin PBXBuildFile section */ 371FE8FE2E937040005A6BBD /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 371FE8FD2E937040005A6BBD /* SwiftSoup */; }; + 374CDA5B2F10A19500D8C50A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374CDA5A2F10A19500D8C50A /* WidgetKit.framework */; }; + 374CDA5D2F10A19500D8C50A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374CDA5C2F10A19500D8C50A /* SwiftUI.framework */; }; + 374CDA682F10A19600D8C50A /* TigerDineWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 374CDA582F10A19500D8C50A /* TigerDineWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 374CDA662F10A19600D8C50A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 376AE0532E6495EB00AB698B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 374CDA572F10A19500D8C50A; + remoteInfo = OpenWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 374CDA692F10A19600D8C50A /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 374CDA682F10A19600D8C50A /* TigerDineWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 374CDA582F10A19500D8C50A /* TigerDineWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TigerDineWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 374CDA5A2F10A19500D8C50A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 374CDA5C2F10A19500D8C50A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 376AE05B2E6495EB00AB698B /* TigerDine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TigerDine.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 374CDA6D2F10A19600D8C50A /* Exceptions for "TigerDineWidgets" folder in "TigerDineWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 374CDA572F10A19500D8C50A /* TigerDineWidgets */; + }; + 374CDA742F10B24F00D8C50A /* Exceptions for "Shared" folder in "TigerDineWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + SharedComponents.swift, + Types/TigerCenterTypes.swift, + ); + target = 374CDA572F10A19500D8C50A /* TigerDineWidgets */; + }; 37C76ABF2F0F3067009E7074 /* Exceptions for "TigerDine" folder in "TigerDine" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -25,6 +70,22 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 374CDA5E2F10A19500D8C50A /* TigerDineWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 374CDA6D2F10A19600D8C50A /* Exceptions for "TigerDineWidgets" folder in "TigerDineWidgets" target */, + ); + path = TigerDineWidgets; + sourceTree = ""; + }; + 374CDA722F10B22D00D8C50A /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 374CDA742F10B24F00D8C50A /* Exceptions for "Shared" folder in "TigerDineWidgets" target */, + ); + path = Shared; + sourceTree = ""; + }; 376AE05D2E6495EB00AB698B /* TigerDine */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -36,6 +97,15 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 374CDA552F10A19500D8C50A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 374CDA5D2F10A19500D8C50A /* SwiftUI.framework in Frameworks */, + 374CDA5B2F10A19500D8C50A /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 376AE0582E6495EB00AB698B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -47,10 +117,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 374CDA592F10A19500D8C50A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 374CDA5A2F10A19500D8C50A /* WidgetKit.framework */, + 374CDA5C2F10A19500D8C50A /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 376AE0522E6495EB00AB698B = { isa = PBXGroup; children = ( + 374CDA722F10B22D00D8C50A /* Shared */, 376AE05D2E6495EB00AB698B /* TigerDine */, + 374CDA5E2F10A19500D8C50A /* TigerDineWidgets */, + 374CDA592F10A19500D8C50A /* Frameworks */, 376AE05C2E6495EB00AB698B /* Products */, ); sourceTree = ""; @@ -59,6 +141,7 @@ isa = PBXGroup; children = ( 376AE05B2E6495EB00AB698B /* TigerDine.app */, + 374CDA582F10A19500D8C50A /* TigerDineWidgets.appex */, ); name = Products; sourceTree = ""; @@ -66,6 +149,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 374CDA572F10A19500D8C50A /* TigerDineWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 374CDA6C2F10A19600D8C50A /* Build configuration list for PBXNativeTarget "TigerDineWidgets" */; + buildPhases = ( + 374CDA542F10A19500D8C50A /* Sources */, + 374CDA552F10A19500D8C50A /* Frameworks */, + 374CDA562F10A19500D8C50A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 374CDA5E2F10A19500D8C50A /* TigerDineWidgets */, + ); + name = TigerDineWidgets; + packageProductDependencies = ( + ); + productName = OpenWidgetExtension; + productReference = 374CDA582F10A19500D8C50A /* TigerDineWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; 376AE05A2E6495EB00AB698B /* TigerDine */ = { isa = PBXNativeTarget; buildConfigurationList = 376AE0662E6495EC00AB698B /* Build configuration list for PBXNativeTarget "TigerDine" */; @@ -73,12 +178,15 @@ 376AE0572E6495EB00AB698B /* Sources */, 376AE0582E6495EB00AB698B /* Frameworks */, 376AE0592E6495EB00AB698B /* Resources */, + 374CDA692F10A19600D8C50A /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 374CDA672F10A19600D8C50A /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 374CDA722F10B22D00D8C50A /* Shared */, 376AE05D2E6495EB00AB698B /* TigerDine */, ); name = TigerDine; @@ -96,9 +204,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2600; TargetAttributes = { + 374CDA572F10A19500D8C50A = { + CreatedOnToolsVersion = 26.2; + }; 376AE05A2E6495EB00AB698B = { CreatedOnToolsVersion = 16.4; }; @@ -122,11 +233,19 @@ projectRoot = ""; targets = ( 376AE05A2E6495EB00AB698B /* TigerDine */, + 374CDA572F10A19500D8C50A /* TigerDineWidgets */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 374CDA562F10A19500D8C50A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 376AE0592E6495EB00AB698B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -137,6 +256,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 374CDA542F10A19500D8C50A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 376AE0572E6495EB00AB698B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -146,7 +272,81 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 374CDA672F10A19600D8C50A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 374CDA572F10A19500D8C50A /* TigerDineWidgets */; + targetProxy = 374CDA662F10A19600D8C50A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 374CDA6A2F10A19600D8C50A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = TigerDineWidgets/TigerDineWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + DEVELOPMENT_TEAM = 5GF7GKNTK4; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TigerDineWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TigerDineWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining.Widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 374CDA6B2F10A19600D8C50A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = TigerDineWidgets/TigerDineWidgets.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 26; + DEVELOPMENT_TEAM = 5GF7GKNTK4; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TigerDineWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TigerDineWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.2.0; + PRODUCT_BUNDLE_IDENTIFIER = "dev.ninjacheetah.RIT-Dining.Widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 376AE0642E6495EC00AB698B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -278,7 +478,7 @@ CODE_SIGN_ENTITLEMENTS = TigerDine/TigerDine.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -315,7 +515,7 @@ CODE_SIGN_ENTITLEMENTS = TigerDine/TigerDine.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = 5GF7GKNTK4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -347,6 +547,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 374CDA6C2F10A19600D8C50A /* Build configuration list for PBXNativeTarget "TigerDineWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 374CDA6A2F10A19600D8C50A /* Debug */, + 374CDA6B2F10A19600D8C50A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 376AE0562E6495EB00AB698B /* Build configuration list for PBXProject "TigerDine" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TigerDine.xcodeproj/xcshareddata/xcschemes/OpenWidgetExtension.xcscheme b/TigerDine.xcodeproj/xcshareddata/xcschemes/OpenWidgetExtension.xcscheme new file mode 100644 index 0000000..c9c263a --- /dev/null +++ b/TigerDine.xcodeproj/xcshareddata/xcschemes/OpenWidgetExtension.xcscheme @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TigerDine.xcodeproj/xcshareddata/xcschemes/TigerDine.xcscheme b/TigerDine.xcodeproj/xcshareddata/xcschemes/TigerDine.xcscheme new file mode 100644 index 0000000..cc435f3 --- /dev/null +++ b/TigerDine.xcodeproj/xcshareddata/xcschemes/TigerDine.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TigerDine/ContentView.swift b/TigerDine/ContentView.swift index 5d714b2..fa5b0f0 100644 --- a/TigerDine/ContentView.swift +++ b/TigerDine/ContentView.swift @@ -11,8 +11,9 @@ 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 model = DiningModel() @State private var isLoading: Bool = true @State private var loadFailed: Bool = false @State private var showingDonationSheet: Bool = false @@ -26,10 +27,14 @@ struct ContentView: View { } // Small wrapper around the method on the model so that errors can be handled by showing the uh error screen. - private func getDiningData() async { + private func getDiningData(bustCache: Bool = false) async { do { - try await model.getHoursByDay() - await model.scheduleAllPushes() + if bustCache { + try await model.getHoursByDay() + } + else { + try await model.getHoursByDayCached() + } isLoading = false } catch { isLoading = true @@ -68,7 +73,7 @@ struct ContentView: View { Button(action: { loadFailed = false Task { - await getDiningData() + await getDiningData(bustCache: true) } }) { Label("Refresh", systemImage: "arrow.clockwise") @@ -102,12 +107,14 @@ struct ContentView: View { } }) Section(content: { - LocationList( - diningLocations: $model.locationsByDay[0], - openLocationsFirst: $openLocationsFirst, - openLocationsOnly: $openLocationsOnly, - searchText: $searchText - ) + // 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) { @@ -122,7 +129,7 @@ struct ContentView: View { .navigationTitle("TigerDine") .searchable(text: $searchText, prompt: "Search") .refreshable { - await getDiningData() + await getDiningData(bustCache: true) } .toolbar { ToolbarItemGroup(placement: .primaryAction) { @@ -132,7 +139,7 @@ struct ContentView: View { Menu { Button(action: { Task { - await getDiningData() + await getDiningData(bustCache: true) } }) { Label("Refresh", systemImage: "arrow.clockwise") @@ -174,7 +181,6 @@ struct ContentView: View { } } } - .environment(model) .task { await getDiningData() await updateOpenStatuses() diff --git a/TigerDine/Data/BackgroundRefresh.swift b/TigerDine/Data/BackgroundRefresh.swift new file mode 100644 index 0000000..2815ceb --- /dev/null +++ b/TigerDine/Data/BackgroundRefresh.swift @@ -0,0 +1,26 @@ +// +// BackgroundRefresh.swift +// TigerDine +// +// Created by Campbell on 1/9/26. +// + +import SwiftUI +import BackgroundTasks + +/// This is the global function used to tell iOS that we want to schedule a new instance of the background refresh task. It's used both in the main app to automatically reschedule a task when one completes, and also within the dining model to schedule a task when a refresh finishes. +func scheduleNextRefresh() { + let request = BGAppRefreshTaskRequest( + identifier: "dev.ninjacheetah.RIT-Dining.refresh" + ) + + // Refresh NO SOONER than 6 hours from now. That's not super important since the task will exit pretty much immediately + // if the cache is still fresh, but we really don't need to try more frequently than this so don't bother. + request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 60 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("failed to schedule background refresh: ", error) + } +} diff --git a/TigerDine/Data/DiningModel.swift b/TigerDine/Data/DiningModel.swift index 482454f..b0d11d5 100644 --- a/TigerDine/Data/DiningModel.swift +++ b/TigerDine/Data/DiningModel.swift @@ -11,23 +11,38 @@ import SwiftUI class DiningModel { var locationsByDay = [[DiningLocation]]() var daysRepresented = [Date]() - var lastRefreshed: Date? + var lastRefreshed: Date? { + get { + let sharedDefaults = UserDefaults(suiteName: "group.dev.ninjacheetah.RIT-Dining") + // If this fails, we should default to an interval of 0. 1970 is obviously going to register as stale cache and will + // trigger a reload. + return Date(timeIntervalSince1970: sharedDefaults?.double(forKey: "lastRefreshed") ?? 0.0) + } + set { + let sharedDefaults = UserDefaults(suiteName: "group.dev.ninjacheetah.RIT-Dining") + sharedDefaults?.set(newValue?.timeIntervalSince1970, forKey: "lastRefreshed") + } + } // External models that should be nested under this one. var favorites = Favorites() var notifyingChefs = NotifyingChefs() var visitingChefPushes = VisitingChefPushesModel() - /// 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 getDaysRepresented() async { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let week: [Date] = (0..<7).compactMap { offset in calendar.date(byAdding: .day, value: offset, to: today) } daysRepresented = week + } + + /// 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 { + await getDaysRepresented() var newLocationsByDay = [[DiningLocation]]() - for day in week { + for day in daysRepresented { let dateString = day.formatted(.iso8601 .year().month().day() .dateSeparator(.dash)) @@ -43,8 +58,48 @@ class DiningModel { throw(error) } } + + // Encode all the locations as JSON. + let encoder = JSONEncoder() + let encodedLocationsByDay = try encoder.encode(newLocationsByDay) + + // Write the data out so it's cached for later. + let sharedDefaults = UserDefaults(suiteName: "group.dev.ninjacheetah.RIT-Dining") + sharedDefaults?.set(encodedLocationsByDay, forKey: "cachedLocationsByDay") + + // Once we're done caching, update the UI. locationsByDay = newLocationsByDay lastRefreshed = Date() + + // And then schedule push notifications. + await scheduleAllPushes() + + // And finally schedule a background refresh 6 hours from now. + scheduleNextRefresh() + } + + /// 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. + func getHoursByDayCached() async throws { + let now = Date() + // 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) { + // Last refresh happened today, so the cache is fresh and we should load that. + await getDaysRepresented() + let decoder = JSONDecoder() + let cachedLocationsByDay = try decoder.decode([[DiningLocation]].self, from: (UserDefaults(suiteName: "group.dev.ninjacheetah.RIT-Dining")!.data(forKey: "cachedLocationsByDay")!)) + print(cachedLocationsByDay) + + // Load cache, update open status, do a notification cleanup, and return. We only need to clean up because loading + // cache means that there can't be any new notifications to schedule since the last real data refresh. + locationsByDay = cachedLocationsByDay + updateOpenStatuses() + await cleanupPushes() + return + } + // Otherwise, the cache is stale and we can fall out to the call to update it. + } + try await getHoursByDay() } /// Iterates through all of the locations and updates their open status indicator based on the current time. Does a replace to make sure that it updates any views observing this model. diff --git a/TigerDine/Info.plist b/TigerDine/Info.plist index 0c67376..a075655 100644 --- a/TigerDine/Info.plist +++ b/TigerDine/Info.plist @@ -1,5 +1,14 @@ - + + BGTaskSchedulerPermittedIdentifiers + + dev.ninjacheetah.RIT-Dining.refresh + + UIBackgroundModes + + fetch + + diff --git a/TigerDine/TigerDine.entitlements b/TigerDine/TigerDine.entitlements index 0c67376..fd5450f 100644 --- a/TigerDine/TigerDine.entitlements +++ b/TigerDine/TigerDine.entitlements @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.dev.ninjacheetah.RIT-Dining + + diff --git a/TigerDine/TigerDineApp.swift b/TigerDine/TigerDineApp.swift index 31d7916..218b39f 100644 --- a/TigerDine/TigerDineApp.swift +++ b/TigerDine/TigerDineApp.swift @@ -5,13 +5,34 @@ // Created by Campbell on 8/31/25. // +import BackgroundTasks import SwiftUI +import WidgetKit @main 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() + + /// 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 { + do { + try await model.getHoursByDayCached() + WidgetCenter.shared.reloadAllTimelines() + } catch { + print("background refresh failed: ", error) + } + + scheduleNextRefresh() + } + var body: some Scene { WindowGroup { ContentView() + .environment(model) + } + .backgroundTask(.appRefresh("dev.ninjacheetah.RIT-Dining.refresh")) { + await handleAppRefresh() } } } diff --git a/TigerDine/Views/LocationList.swift b/TigerDine/Views/LocationList.swift index 5da8770..25f8f05 100644 --- a/TigerDine/Views/LocationList.swift +++ b/TigerDine/Views/LocationList.swift @@ -10,7 +10,6 @@ import SwiftUI // This view handles the actual location list, because having it inside ContentView was too complex (both visually and for the // type checker too, apparently). struct LocationList: View { - @Binding var diningLocations: [DiningLocation] @Binding var openLocationsFirst: Bool @Binding var openLocationsOnly: Bool @Binding var searchText: String @@ -20,7 +19,7 @@ struct LocationList: View { // The dining locations need to be sorted before being displayed. Favorites should always be shown first, followed by non-favorites. // Afterwards, filters the sorted list based on any current search text and the "open locations only" filtering option. private var filteredLocations: [DiningLocation] { - var newLocations = diningLocations + var newLocations = model.locationsByDay[0] // Because "The Commons" should be C for "Commons" and not T for "The". func removeThe(_ name: String) -> String { let lowercased = name.lowercased() diff --git a/TigerDineWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/TigerDineWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..7010129 --- /dev/null +++ b/TigerDineWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.412", + "red" : "0.969" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TigerDineWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/TigerDineWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/TigerDineWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TigerDineWidgets/Assets.xcassets/Contents.json b/TigerDineWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TigerDineWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TigerDineWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/TigerDineWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/TigerDineWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TigerDineWidgets/Components/HoursGague.swift b/TigerDineWidgets/Components/HoursGague.swift new file mode 100644 index 0000000..89223fa --- /dev/null +++ b/TigerDineWidgets/Components/HoursGague.swift @@ -0,0 +1,86 @@ +// +// HoursGague.swift +// TigerDineWidgets +// +// Created by Campbell on 1/8/26. +// + +import SwiftUI + +struct OpeningHoursGauge: View { + let openTime: Date? + let closeTime: Date? + let now: Date + + private let dayDuration: TimeInterval = 86_400 + + private var barFillColor: Color { + let calendar = Calendar.current + + if let openTime = openTime, let closeTime = closeTime { + if now >= openTime && now <= closeTime { + if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! { + return Color.green + } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! { + return Color.orange + } else { + return Color.green + } + } else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now { + return Color.orange + } else { + return Color.red + } + } else { + return Color.red + } + } + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let barHeight: CGFloat = 16 + + let nowX = position(for: now, width: width) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.gray.opacity(0.25)) + .frame(height: barHeight) + + // We can skip drawing this entire capsule if the location is never open, since there would be no opening period + // to draw. + if let openTime = openTime, let closeTime = closeTime { + let openX = position(for: openTime, width: width) + let closeX = position(for: closeTime, width: width) + + Capsule() + .fill( + LinearGradient( + colors: [barFillColor.opacity(0.7), barFillColor], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(0, closeX - openX), height: barHeight) + .offset(x: openX) + } + + Circle() + .fill(Color.white) + .frame(width: 18, height: 18) + .shadow(radius: 1) + .offset(x: nowX - 5) + } + .frame(height: 20) + } + .frame(height: 20) + } + + private func position(for date: Date, width: CGFloat) -> CGFloat { + let startOfDay = Calendar.current.startOfDay(for: date) + let seconds = date.timeIntervalSince(startOfDay) + let normalized = min(max(seconds / dayDuration, 0), 1) + return normalized * width + } +} diff --git a/TigerDineWidgets/Components/HoursWidgetSelection.swift b/TigerDineWidgets/Components/HoursWidgetSelection.swift new file mode 100644 index 0000000..aec18de --- /dev/null +++ b/TigerDineWidgets/Components/HoursWidgetSelection.swift @@ -0,0 +1,73 @@ +// +// HoursWidgetSelection.swift +// TigerDine +// +// Created by Campbell on 1/9/26. +// + +import AppIntents + +struct DiningLocationEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Location" + ) + + let id: Int + let name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + static var defaultQuery = DiningLocationQuery() +} + +struct DiningLocationQuery: EntityQuery { + func entities(for identifiers: [Int]) async throws -> [DiningLocationEntity] { + allEntities.filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [DiningLocationEntity] { + allEntities + } + + private var allEntities: [DiningLocationEntity] { + guard + let data = UserDefaults( + suiteName: "group.dev.ninjacheetah.RIT-Dining" + )?.data(forKey: "cachedLocationsByDay"), + let decoded = try? JSONDecoder().decode([[DiningLocation]].self, from: data) + else { return [] } + + let todaysLocations = decoded.first ?? [] + + let locations = todaysLocations.map { + DiningLocationEntity(id: $0.id, name: $0.name) + } + + // These are being sorted the same way the locations are in the app, alphabetical dropping a leading "the" so that they + // appear in an order that makes sense. + return locations.sorted { + sortableLocationName($0.name) + .localizedCaseInsensitiveCompare( + sortableLocationName($1.name) + ) == .orderedAscending + } + } +} + +struct LocationHoursIntent: AppIntent, WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Location" + + @Parameter(title: "Location") + var location: DiningLocationEntity? +} + +// I should probably move this to somewhere shared in the future since this same logic *is* used in LocationList. +private func sortableLocationName(_ name: String) -> String { + let lowercased = name.lowercased() + if lowercased.hasPrefix("the ") { + return String(name.dropFirst(4)) + } + return name +} diff --git a/TigerDineWidgets/Info.plist b/TigerDineWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/TigerDineWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/TigerDineWidgets/TigerDineWidgets.entitlements b/TigerDineWidgets/TigerDineWidgets.entitlements new file mode 100644 index 0000000..fd5450f --- /dev/null +++ b/TigerDineWidgets/TigerDineWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ninjacheetah.RIT-Dining + + + diff --git a/TigerDineWidgets/TigerDineWidgetsBundle.swift b/TigerDineWidgets/TigerDineWidgetsBundle.swift new file mode 100644 index 0000000..8590eaf --- /dev/null +++ b/TigerDineWidgets/TigerDineWidgetsBundle.swift @@ -0,0 +1,16 @@ +// +// TigerDineWidgetsBundle.swift +// TigerDineWidgets +// +// Created by Campbell on 1/8/26. +// + +import WidgetKit +import SwiftUI + +@main +struct TigerDineWidgetsBundle: WidgetBundle { + var body: some Widget { + HoursWidget() + } +} diff --git a/TigerDineWidgets/Widgets/HoursWidget.swift b/TigerDineWidgets/Widgets/HoursWidget.swift new file mode 100644 index 0000000..1cd006a --- /dev/null +++ b/TigerDineWidgets/Widgets/HoursWidget.swift @@ -0,0 +1,206 @@ +// +// HoursWidget.swift +// TigerDineWidgets +// +// Created by Campbell on 1/8/26. +// + +import WidgetKit +import SwiftUI + +// This timeline provider is currently held together by duct tape. But hey, that's what beta testing is for. +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> OpenEntry { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday)! + + return OpenEntry( + date: Date(), + name: "Select a Location", + openTime: startOfToday, + closeTime: startOfTomorrow + ) + } + + func snapshot( + for configuration: LocationHoursIntent, + in context: Context + ) async -> OpenEntry { + loadEntry(for: configuration) ?? placeholder(in: context) + } + + func timeline( + for configuration: LocationHoursIntent, + in context: Context + ) async -> Timeline { + + guard let baseEntry = loadEntry(for: configuration) else { + return Timeline( + entries: [placeholder(in: context)], + policy: .atEnd + ) + } + + let updateDates = buildUpdateSchedule( + now: Date(), + open: baseEntry.openTime, + close: baseEntry.closeTime + ) + + let entries = updateDates.map { + OpenEntry( + date: $0, + name: baseEntry.name, + openTime: baseEntry.openTime, + closeTime: baseEntry.closeTime + ) + } + + return Timeline(entries: entries, policy: .atEnd) + } + + func loadEntry(for configuration: LocationHoursIntent) -> OpenEntry? { + guard let selectedLocation = configuration.location else { + return nil + } + + guard + let data = UserDefaults(suiteName: "group.dev.ninjacheetah.RIT-Dining")?.data(forKey: "cachedLocationsByDay"), + let decoded = try? JSONDecoder().decode([[DiningLocation]].self, from: data), + let todayLocations = decoded.first, + let location = todayLocations.first(where: { + $0.id == selectedLocation.id + }) + else { + return nil + } + + return OpenEntry( + date: Date(), + name: location.name, + openTime: location.diningTimes?.first?.openTime, + closeTime: location.diningTimes?.first?.closeTime + ) + } + + func buildUpdateSchedule( + now: Date, + open: Date?, + close: Date? + ) -> [Date] { + + var dates: Set = [] + + dates.insert(now) + + if let open = open, let close = close { + dates.insert(open) + dates.insert(close) + } + + let interval: TimeInterval = 5 * 60 + let end = Calendar.current.date(byAdding: .hour, value: 24, to: now)! + + var t = now + while t < end { + dates.insert(t) + t = t.addingTimeInterval(interval) + } + + return dates + .filter { $0 >= now } + .sorted() + } +} + +struct OpenEntry: TimelineEntry { + let date: Date + let name: String + let openTime: Date? + let closeTime: Date? +} + +struct OpenWidgetEntryView : View { + var entry: Provider.Entry + + private let calendar = Calendar.current + + var body: some View { + VStack(alignment: .leading) { + Text(entry.name) + .font(.title2) + .fontWeight(.bold) + + // Should maybe try to unify this with the almost-identical UI code in DetailView. + if let openTime = entry.openTime, let closeTime = entry.closeTime { + if entry.date >= openTime && entry.date <= closeTime { + if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! { + Text("Open") + .font(.title3) + .foregroundStyle(.green) + } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: entry.date)! { + Text("Closing Soon") + .font(.title3) + .foregroundStyle(.orange) + } else { + Text("Open") + .font(.title3) + .foregroundStyle(.green) + } + } else if openTime <= calendar.date(byAdding: .minute, value: 30, to: entry.date)! && closeTime > entry.date { + Text("Opening Soon") + .font(.title3) + .foregroundStyle(.orange) + } else { + Text("Closed") + .font(.title3) + .foregroundStyle(.red) + } + + Text("\(dateDisplay.string(from: openTime)) - \(dateDisplay.string(from: closeTime))") + .foregroundStyle(.secondary) + } else { + Text("Closed") + .font(.title3) + .foregroundStyle(.red) + + Text("Not Open Today") + .foregroundStyle(.secondary) + } + + Spacer() + + OpeningHoursGauge( + openTime: entry.openTime, + closeTime: entry.closeTime, + now: entry.date + ) + } + } +} + +struct HoursWidget: Widget { + let kind: String = "HoursWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: LocationHoursIntent.self, + provider: Provider() + ) { entry in + OpenWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Location Hours") + .description("See today's hours for a chosen location.") + .supportedFamilies([.systemSmall]) + } +} + +#Preview(as: .systemSmall) { + HoursWidget() +} timeline: { + OpenEntry(date: .now, name: "Beanz", openTime: Date(timeIntervalSince1970: 1767963600), closeTime: Date(timeIntervalSince1970: 1767988800)) + OpenEntry(date: Date(timeIntervalSince1970: 1767978000), name: "Beanz", openTime: Date(timeIntervalSince1970: 1767963600), closeTime: Date(timeIntervalSince1970: 1767988800)) +}