mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-04 21:25:27 -05:00
Added caching, background refresh, and first widget
- The main dining location information is now cached on download. - The freshness of the cache is checked whenever it's loaded, and if the last refreshed date is not today's date then it's dropped and the app refreshes from the API normally. - This reduces load times if you open the app multiple times a day. The data won't change during the day, so you can load it the first time and then use the cache the rest of the time. - Refreshing via pull to refresh or the refresh button still refreshes the cache from the server. - Added a background refresh task. - TigerDine now registered a background fetch task with the device that will update the location information up to a maximum of 4 times per day. The cache is checked first, so a new request will only be made if the cache is stale. - This allows for automatic notification scheduling at times other than when the app is launched. As long as background tasks can run, notifications will be automatically scheduled when necessary. - Depending on the timing, this may allow you to never see any load times in TigerDine, since the cache might already be up to date before you use the app for the first time in a day. - Started adding widgets! - TigerDine now offers an hours widget that lets you put the hours for a specified location on your home screen, along with a visual indicator of when that location is open today. - The widget automatically feeds off of TigerDine's cache, so hey, no extra network requests required! - This widget currently DOES NOT support multi-opening locations like Brick City Cafe or Gracie's. This is still a work in progress.
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// SharedComponents.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/8/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
|
||||
// Gross disgusting UIKit code :(
|
||||
// There isn't a direct way to use integrated Safari from SwiftUI, except maybe in iOS 26? I'm not targeting that though so I must fall
|
||||
// back on UIKit stuff.
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
SFSafariViewController(url: url)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
||||
}
|
||||
|
||||
func getTCAPIFriendlyDateString(date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
func getFDMPAPIFriendlyDateString(date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy/MM/dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
// The common date formatter that I'm using everywhere that open periods are shown within the app.
|
||||
let dateDisplay: DateFormatter = {
|
||||
let display = DateFormatter()
|
||||
display.timeZone = TimeZone(identifier: "America/New_York")
|
||||
display.dateStyle = .none
|
||||
display.timeStyle = .short
|
||||
return display
|
||||
}()
|
||||
|
||||
let visitingChefDateDisplay: DateFormatter = {
|
||||
let display = DateFormatter()
|
||||
display.dateFormat = "EEEE, MMM d"
|
||||
display.locale = Locale(identifier: "en_US_POSIX")
|
||||
return display
|
||||
}()
|
||||
|
||||
let weekdayFromDate: DateFormatter = {
|
||||
let weekdayFormatter = DateFormatter()
|
||||
weekdayFormatter.dateFormat = "EEEE"
|
||||
return weekdayFormatter
|
||||
}()
|
||||
|
||||
// Custom view extension that just applies modifiers in a block to the object it's applied to. Mostly useful for splitting up conditional
|
||||
// modifiers that should only be applied for certain OS versions. (A returning feature from RNGTool!)
|
||||
extension View {
|
||||
func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
26
TigerDine/Data/BackgroundRefresh.swift
Normal file
26
TigerDine/Data/BackgroundRefresh.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
//
|
||||
// TigerCenterTypes.swift
|
||||
// TigerDine
|
||||
//
|
||||
// Created by Campbell on 9/2/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Struct to parse the response data from the TigerCenter API when getting the information for a dining location.
|
||||
struct DiningLocationParser: Decodable {
|
||||
/// An individual "event", which is just an open period for the location.
|
||||
struct Event: Decodable {
|
||||
/// Hour exceptions for the given event.
|
||||
struct HoursException: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let open: Bool
|
||||
}
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let daysOfWeek: [String]
|
||||
let exceptions: [HoursException]?
|
||||
}
|
||||
/// An individual "menu", which can be either a daily special item or a visitng chef. Description needs to be optional because visiting chefs have descriptions but specials do not.
|
||||
struct Menu: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let category: String
|
||||
}
|
||||
/// Other basic information to read from a location's JSON that we'll need later.
|
||||
let id: Int
|
||||
let mdoId: Int
|
||||
let name: String
|
||||
let summary: String
|
||||
let description: String
|
||||
let mapsUrl: String
|
||||
let events: [Event]
|
||||
let menus: [Menu]
|
||||
}
|
||||
|
||||
/// Struct that probably doesn't need to exist but this made parsing the list of location responses easy.
|
||||
struct DiningLocationsParser: Decodable {
|
||||
let locations: [DiningLocationParser]
|
||||
}
|
||||
|
||||
/// Enum to represent the four possible states a given location can be in.
|
||||
enum OpenStatus {
|
||||
case open
|
||||
case closed
|
||||
case openingSoon
|
||||
case closingSoon
|
||||
}
|
||||
|
||||
/// An individual open period for a location.
|
||||
struct DiningTimes: Equatable, Hashable {
|
||||
var openTime: Date
|
||||
var closeTime: Date
|
||||
}
|
||||
|
||||
/// Enum to represent the five possible states a visiting chef can be in.
|
||||
enum VisitingChefStatus {
|
||||
case hereNow
|
||||
case gone
|
||||
case arrivingLater
|
||||
case arrivingSoon
|
||||
case leavingSoon
|
||||
}
|
||||
|
||||
/// A visiting chef present at a location.
|
||||
struct VisitingChef: Equatable, Hashable {
|
||||
let name: String
|
||||
let description: String
|
||||
var openTime: Date
|
||||
var closeTime: Date
|
||||
var status: VisitingChefStatus
|
||||
}
|
||||
|
||||
/// A daily special at a location.
|
||||
struct DailySpecial: Equatable, Hashable {
|
||||
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 {
|
||||
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 {
|
||||
let id: Int
|
||||
let mdoId: Int
|
||||
let fdmpIds: FDMPIds?
|
||||
let name: String
|
||||
let summary: String
|
||||
let desc: String
|
||||
let mapsUrl: String
|
||||
let date: Date
|
||||
let diningTimes: [DiningTimes]?
|
||||
var open: OpenStatus
|
||||
var visitingChefs: [VisitingChef]?
|
||||
let dailySpecials: [DailySpecial]?
|
||||
}
|
||||
|
||||
/// Parser to read the occupancy data for a location.
|
||||
struct DiningOccupancyParser: Decodable {
|
||||
/// Represents a per-hour occupancy rating.
|
||||
struct HourlyOccupancy: Decodable {
|
||||
let hour: Int
|
||||
let today: Int
|
||||
let today_max: Int
|
||||
let one_week_ago: Int
|
||||
let one_week_ago_max: Int
|
||||
let average: Int
|
||||
}
|
||||
let count: Int
|
||||
let location: String
|
||||
let building: String
|
||||
let mdo_id: Int
|
||||
let max_occ: Int
|
||||
let open_status: String
|
||||
let intra_loc_hours: [HourlyOccupancy]
|
||||
}
|
||||
|
||||
/// Struct used to represent a day and its hours as strings. Type used for the hours of today and the next 6 days used in DetailView.
|
||||
struct WeeklyHours: Hashable {
|
||||
let day: String
|
||||
let date: Date
|
||||
let timeStrings: [String]
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>dev.ninjacheetah.RIT-Dining.refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.ninjacheetah.RIT-Dining</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user