mirror of
https://github.com/NinjaCheetah/RIT-Dining.git
synced 2026-03-06 05:45:28 -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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
TigerDineWidgets/Assets.xcassets/Contents.json
Normal file
6
TigerDineWidgets/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
86
TigerDineWidgets/Components/HoursGague.swift
Normal file
86
TigerDineWidgets/Components/HoursGague.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
73
TigerDineWidgets/Components/HoursWidgetSelection.swift
Normal file
73
TigerDineWidgets/Components/HoursWidgetSelection.swift
Normal file
@@ -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
|
||||
}
|
||||
11
TigerDineWidgets/Info.plist
Normal file
11
TigerDineWidgets/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
TigerDineWidgets/TigerDineWidgets.entitlements
Normal file
10
TigerDineWidgets/TigerDineWidgets.entitlements
Normal file
@@ -0,0 +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>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.ninjacheetah.RIT-Dining</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
16
TigerDineWidgets/TigerDineWidgetsBundle.swift
Normal file
16
TigerDineWidgets/TigerDineWidgetsBundle.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
206
TigerDineWidgets/Widgets/HoursWidget.swift
Normal file
206
TigerDineWidgets/Widgets/HoursWidget.swift
Normal file
@@ -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<OpenEntry> {
|
||||
|
||||
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<Date> = []
|
||||
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user