diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a90547f..711db0d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -61,9 +61,9 @@ jobs: scheme: TestApp uploadcoveragereport: name: Upload Coverage Report - needs: [buildandtest_ios, buildandtest_watchos, buildandtest_visionos, buildandtestuitests_ios] + needs: [buildandtest_ios, buildandtest_watchos, buildandtest_visionos, buildandtest_macos, buildandtestuitests_ios] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziScheduler-iOS.xcresult SpeziScheduler-watchOS.xcresult SpeziScheduler-visionOS.xcresult TestApp.xcresult + coveragereports: SpeziScheduler-iOS.xcresult SpeziScheduler-watchOS.xcresult SpeziScheduler-visionOS.xcresult SpeziScheduler-macOS.xcresult TestApp.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.spi.yml b/.spi.yml index 2e6c1cb..12acdb1 100644 --- a/.spi.yml +++ b/.spi.yml @@ -12,3 +12,4 @@ builder: - platform: ios documentation_targets: - SpeziScheduler + - SpeziSchedulerUI diff --git a/.swiftlint.yml b/.swiftlint.yml index d423942..0397242 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -367,10 +367,6 @@ only_rules: # The variable should be placed on the left, the constant on the right of a comparison operator. - yoda_condition -deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target. - iOSApplicationExtension_deployment_target: 16.0 - iOS_deployment_target: 16.0 - excluded: # paths to ignore during linting. Takes precedence over `included`. - .build - .swiftpm diff --git a/Package.swift b/Package.swift index 4cf4ba8..c05f26f 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,8 @@ let package = Package( .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.8.0"), .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.7.0"), .package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.2"), + .package(url: "https://github.com/StanfordSpezi/SpeziNotifications.git", from: "1.0.2"), + .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"), .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0-prerelease-2024-08-14"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.17.2") ] + swiftLintPackage(), @@ -51,7 +53,9 @@ let package = Package( .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "Spezi", package: "Spezi"), .product(name: "SpeziViews", package: "SpeziViews"), - .product(name: "SpeziLocalStorage", package: "SpeziStorage") + .product(name: "SpeziNotifications", package: "SpeziNotifications"), + .product(name: "SpeziLocalStorage", package: "SpeziStorage"), + .product(name: "Algorithms", package: "swift-algorithms") ], plugins: [] + swiftLintPlugin() ), @@ -109,7 +113,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] { func swiftLintPackage() -> [PackageDescription.Package.Dependency] { if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { - [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.56.2")] + [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")] } else { [] } diff --git a/Sources/SpeziScheduler/Constants.swift b/Sources/SpeziScheduler/Constants.swift deleted file mode 100644 index 4aa8d8c..0000000 --- a/Sources/SpeziScheduler/Constants.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -enum Constants { - static let taskStorageKey: String = "spezi.scheduler.tasks" -} diff --git a/Sources/SpeziScheduler/EventQuery.swift b/Sources/SpeziScheduler/EventQuery.swift index e5ca8cb..8304ff3 100644 --- a/Sources/SpeziScheduler/EventQuery.swift +++ b/Sources/SpeziScheduler/EventQuery.swift @@ -7,7 +7,6 @@ // import Combine -import OSLog import SwiftData import SwiftUI @@ -182,23 +181,3 @@ extension EventQuery: DynamicProperty { } } } - - -private let logger = Logger(subsystem: "edu.stanford.spezi.scheduler", category: "EventQuery") - - -private func measure( - clock: C = ContinuousClock(), - name: @autoclosure @escaping () -> StaticString, - _ action: () throws -> T -) rethrows -> T where C.Instant.Duration == Duration { - #if DEBUG || TEST - let start = clock.now - let result = try action() - let end = clock.now - logger.debug("Performing \(name()) took \(start.duration(to: end))") - return result - #else - try action() - #endif -} diff --git a/Sources/SpeziScheduler/Notifications/BGTaskSchedulerErrorCode+Description.swift b/Sources/SpeziScheduler/Notifications/BGTaskSchedulerErrorCode+Description.swift new file mode 100644 index 0000000..82ea88a --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/BGTaskSchedulerErrorCode+Description.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +#if canImport(BackgroundTasks) +import BackgroundTasks + + +@available(macOS, unavailable) +extension BGTaskScheduler.Error.Code: @retroactive CustomStringConvertible { + public var description: String { + switch self { + case .notPermitted: + "notPermitted" + case .tooManyPendingTaskRequests: + "tooManyPendingTaskRequests" + case .unavailable: + "unavailable" + @unknown default: + "BGTaskSchedulerErrorCode(rawValue: \(rawValue))" + } + } +} +#endif diff --git a/Sources/SpeziScheduler/Notifications/BackgroundMode.swift b/Sources/SpeziScheduler/Notifications/BackgroundMode.swift new file mode 100644 index 0000000..bc3865d --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/BackgroundMode.swift @@ -0,0 +1,24 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +@usableFromInline +struct BackgroundMode { + @usableFromInline static let processing = BackgroundMode(rawValue: "processing") + @usableFromInline static let fetch = BackgroundMode(rawValue: "fetch") + + @usableFromInline let rawValue: String + + @usableFromInline + init(rawValue: String) { + self.rawValue = rawValue + } +} + + +extension BackgroundMode: RawRepresentable, Codable, Hashable, Sendable {} diff --git a/Sources/SpeziScheduler/Notifications/LegacyTaskModel.swift b/Sources/SpeziScheduler/Notifications/LegacyTaskModel.swift new file mode 100644 index 0000000..66e019d --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/LegacyTaskModel.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import UserNotifications + + +/// Minimal model of the legacy event model to retrieve data to provide some interoperability with the legacy version. +struct LegacyEventModel { + let notification: UUID? +} + + +/// Minimal model of the legacy task model to retrieve data to provide some interoperability with the legacy version. +struct LegacyTaskModel { + let id: UUID + let notifications: Bool + let events: [LegacyEventModel] +} + + +extension LegacyEventModel: Decodable, Hashable, Sendable {} + + +extension LegacyTaskModel: Decodable, Hashable, Sendable {} + + +extension LegacyEventModel { + func cancelNotification() { + guard let notification else { + return + } + + let center = UNUserNotificationCenter.current() + center.removeDeliveredNotifications(withIdentifiers: [notification.uuidString]) + center.removePendingNotificationRequests(withIdentifiers: [notification.uuidString]) + } +} diff --git a/Sources/SpeziScheduler/Notifications/NotificationScenePhaseScheduling.swift b/Sources/SpeziScheduler/Notifications/NotificationScenePhaseScheduling.swift new file mode 100644 index 0000000..c66ca8d --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/NotificationScenePhaseScheduling.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct NotificationScenePhaseScheduling: ViewModifier { + @Environment(Scheduler.self) + private var scheduler: Scheduler? // modifier is injected by SchedulerNotifications and it doesn't have a direct scheduler dependency + @Environment(SchedulerNotifications.self) + private var schedulerNotifications + + @Environment(\.scenePhase) + private var scenePhase + + nonisolated init() {} + + func body(content: Content) -> some View { + content + .onChange(of: scenePhase, initial: true) { + guard let scheduler else { + // by the time the modifier appears, the scheduler is injected + return + } + + switch scenePhase { + case .active: + _Concurrency.Task { @MainActor in + await schedulerNotifications.checkForInitialScheduling(scheduler: scheduler) + } + case .background, .inactive: + break + @unknown default: + break + } + } + } +} diff --git a/Sources/SpeziScheduler/Notifications/NotificationThread.swift b/Sources/SpeziScheduler/Notifications/NotificationThread.swift new file mode 100644 index 0000000..146e976 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/NotificationThread.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Determine the behavior how task notifications are automatically grouped. +public enum NotificationThread { + /// All task notifications are put into the global SpeziScheduler notification thread. + case global + /// The event notification are grouped by task. + case task + /// Specify a custom thread identifier. + case custom(String) + /// No thread identifier is specified and grouping is done automatically by iOS. + case none +} + + +extension NotificationThread: Sendable, Hashable, Codable {} diff --git a/Sources/SpeziScheduler/Notifications/NotificationTime.swift b/Sources/SpeziScheduler/Notifications/NotificationTime.swift new file mode 100644 index 0000000..6cd477a --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/NotificationTime.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Hour, minute and second date components to determine the scheduled time of a notification. +public struct NotificationTime { + /// The hour component. + public let hour: Int + /// The minute component. + public let minute: Int + /// The second component + public let second: Int + + + /// Create a new notification time. + /// - Parameters: + /// - hour: The hour component. + /// - minute: The minute component. + /// - second: The second component + public init(hour: Int, minute: Int = 0, second: Int = 0) { + self.hour = hour + self.minute = minute + self.second = second + + precondition((0..<24).contains(hour), "hour must be between 0 and 23") + precondition((0..<60).contains(minute), "minute must be between 0 and 59") + precondition((0..<60).contains(second), "second must be between 0 and 59") + } +} + + +extension NotificationTime: Sendable, Codable, Hashable {} diff --git a/Sources/SpeziScheduler/Notifications/PermittedBackgroundTaskIdentifier.swift b/Sources/SpeziScheduler/Notifications/PermittedBackgroundTaskIdentifier.swift new file mode 100644 index 0000000..05d2b41 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/PermittedBackgroundTaskIdentifier.swift @@ -0,0 +1,25 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +@usableFromInline +struct PermittedBackgroundTaskIdentifier { + @usableFromInline static let speziSchedulerNotificationsScheduling = PermittedBackgroundTaskIdentifier( + rawValue: "edu.stanford.spezi.scheduler.notifications-scheduling" + ) + + @usableFromInline let rawValue: String + + @usableFromInline + init(rawValue: String) { + self.rawValue = rawValue + } +} + + +extension PermittedBackgroundTaskIdentifier: RawRepresentable, Hashable, Sendable, Codable {} diff --git a/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift b/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift new file mode 100644 index 0000000..9a91a8b --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/Schedule+Notifications.swift @@ -0,0 +1,102 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import UserNotifications + + +extension Schedule { + enum NotificationMatchingHint: Codable, Sendable, Hashable { + case components(hour: Int, minute: Int, second: Int, weekday: Int?) + case allDayNotification(weekday: Int?) + + func dateComponents(calendar: Calendar, allDayNotificationTime: NotificationTime) -> DateComponents { + switch self { + case let .components(hour, minute, second, weekday): + return DateComponents(calendar: calendar, hour: hour, minute: minute, second: second, weekday: weekday) + case let .allDayNotification(weekday): + let time = allDayNotificationTime + return DateComponents(calendar: calendar, hour: time.hour, minute: time.minute, second: time.second, weekday: weekday) + } + } + } + + static func notificationTime(for start: Date, duration: Duration, allDayNotificationTime: NotificationTime) -> Date { + if duration.isAllDay { + let time = allDayNotificationTime + guard let morning = Calendar.current.date(bySettingHour: time.hour, minute: time.minute, second: time.second, of: start) else { + preconditionFailure("Failed to set hour of start date \(start)") + } + return morning + } else { + return start + } + } + + static func notificationMatchingHint( // swiftlint:disable:this function_parameter_count function_default_parameter_at_end + forMatchingInterval interval: Int, + calendar: Calendar, + hour: Int, + minute: Int, + second: Int, + weekday: Int? = nil, + consider duration: Duration + ) -> NotificationMatchingHint? { + guard interval == 1 else { + return nil + } + + if duration.isAllDay { + return .allDayNotification(weekday: weekday) + } else { + return .components(hour: hour, minute: minute, second: second, weekday: weekday) + } + } + + func canBeScheduledAsRepeatingCalendarTrigger(allDayNotificationTime: NotificationTime, now: Date = .now) -> Bool { + guard let notificationMatchingHint, let recurrence else { + return false // needs to be repetitive and have a interval hint + } + + if now > start { + return true // if we are past the start date, it is definitely possible + } + + // otherwise, check if it still works (e.g., we have Monday, start date is Wednesday and schedule reoccurs every Friday). + let components = notificationMatchingHint.dateComponents(calendar: recurrence.calendar, allDayNotificationTime: allDayNotificationTime) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + guard let nextDate = trigger.nextTriggerDate() else { + return false + } + + let nextOccurrences = nextOccurrences(in: now..., count: 2) + guard let nextOccurrence = nextOccurrences.first, + nextOccurrences.count >= 2 else { + // we require at least two next occurrences to justify a **repeating** calendar-based trigger + return false + } + + if duration.isAllDay { + // we deliver notifications for all day occurrences at a different time + + let time = allDayNotificationTime + guard let modifiedOccurrence = Calendar.current.date( + bySettingHour: time.hour, + minute: time.minute, + second: time.second, + of: nextOccurrence.start + ) else { + preconditionFailure("Failed to set notification time for date \(nextOccurrence.start)") + } + + return nextDate == modifiedOccurrence + } else { + return nextDate == nextOccurrence.start + } + } +} diff --git a/Sources/SpeziScheduler/Notifications/SchedulerNotifications+Strings.swift b/Sources/SpeziScheduler/Notifications/SchedulerNotifications+Strings.swift new file mode 100644 index 0000000..040b073 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/SchedulerNotifications+Strings.swift @@ -0,0 +1,70 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +extension SchedulerNotifications { + static nonisolated let earliestScheduleRefreshDateStorageKey = "edu.stanford.spezi.scheduler.earliestScheduleRefreshDate" + static nonisolated let authorizationDisallowedLastSchedulingStorageKey = "edu.stanford.spezi.scheduler.authorizationDisallowedLastScheduling" + // swiftlint:disable:previous identifier_name + + /// Access the task id from the `userInfo` of a notification. + /// + /// The ``Task/id`` is stored in the [`userInfo`](https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/userinfo) + /// property of a notification. This string identifier is used as the key. + /// + /// ```swift + /// let content = content.userInfo[SchedulerNotifications.notificationTaskIdKey] + /// ``` + public static nonisolated let notificationTaskIdKey = "\(baseNotificationId).taskId" + + /// The reverse dns notation use as a prefix for all notifications scheduled by SpeziScheduler. + static nonisolated let baseNotificationId = "edu.stanford.spezi.scheduler.notification" + + /// /// The reverse dns notation use as a prefix for all task-level scheduled notifications (calendar trigger). + static nonisolated let baseTaskNotificationId = "\(baseNotificationId).task" + + /// /// The reverse dns notation use as a prefix for all event-level scheduled notifications (interval trigger). + static nonisolated let baseEventNotificationId = "\(baseNotificationId).event" + + /// Retrieve the category identifier for a notification for a task, derived from its task category. + /// + /// This method derive the notification category from the task category. If a task has a task category set, it will be used to set the + /// [`categoryIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/categoryidentifier) of the + /// notification content. + /// By matching against the notification category, you can [Customize the Appearance of Notifications](https://developer.apple.com/documentation/usernotificationsui/customizing-the-appearance-of-notifications) + /// or [Handle user-selected actions](https://developer.apple.com/documentation/usernotifications/handling-notifications-and-notification-related-actions#Handle-user-selected-actions). + /// + /// - Parameter category: The task category to generate the category identifier for. + /// - Returns: The category identifier supplied in the notification content. + public static nonisolated func notificationCategory(for category: Task.Category) -> String { + "\(baseNotificationId).category.\(category.rawValue)" + } + + /// The notification thread identifier for a given task. + /// + /// If notifications are grouped by task, this method can be used to derive the thread identifier from the task ``Task/id``. + /// - Parameter taskId: The task identifier. + /// - Returns: The notification thread identifier for a task. + public static nonisolated func notificationThreadIdentifier(for taskId: String) -> String { + "\(notificationTaskIdKey).\(taskId)" + } + + /// The notification request identifier for a given event. + /// - Parameter event: The event. + /// - Returns: Returns the identifier for the notification request when creating a request for the specified event. + public static nonisolated func notificationId(for event: Event) -> String { + "\(baseEventNotificationId).\(event.task.id).\(event.occurrence.start.timeIntervalSinceReferenceDate)" + } + + /// The notification request identifier for a given task if its scheduled using a repeating calendar trigger. + /// - Parameter task: The task. + /// - Returns: Returns the identifier for the notification request when scheduling using a repeating calendar trigger. + public static nonisolated func notificationId(for task: Task) -> String { + "\(baseTaskNotificationId).\(task.id)" + } +} diff --git a/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift b/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift new file mode 100644 index 0000000..15c3e59 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/SchedulerNotifications.swift @@ -0,0 +1,773 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Algorithms +#if canImport(BackgroundTasks) // not available on watchOS +import BackgroundTasks +#endif +import Foundation +import Spezi +import SpeziFoundation +import SpeziLocalStorage +import SpeziNotifications +import SwiftData +import UserNotifications +import struct SwiftUI.AppStorage + + +/// Manage notifications for the Scheduler. +/// +/// Notifications can be automatically scheduled for Tasks that are scheduled using the ``Scheduler`` module. You configure a Task for automatic notification scheduling by +/// setting the ``Task/scheduleNotifications`` property. +/// +/// - Note: The `SchedulerNotifications` module is automatically configured by the `Scheduler` module using default configuration options. If you want to +/// custom the configuration, just provide the configured module in your `configuration` section of your +/// [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). +/// +/// ### Automatic Scheduling +/// +/// The ``Schedule`` of a ``Task`` supports specifying complex recurrence rules to describe how the ``Event``s of a `Task` recur. +/// These can not always be mapped to repeating notification triggers. Therefore, events need to be scheduled individually requiring much more notification requests. +/// Apple limits the total pending notification requests to `64` per application. SpeziScheduler, by default, doesn't schedule more than `30` local notifications at a time for events +/// that occur within the next 4 weeks. +/// +/// - Important: Make sure to add the [Background Modes](https://developer.apple.com/documentation/xcode/configuring-background-execution-modes) +/// capability and enable the **Background fetch** option. SpeziScheduler automatically schedules background tasks to update the scheduled notifications. +/// Background tasks are currently not supported on watchOS. Background tasks are generally not supported on macOS. +/// +/// ### Time Sensitive Notifications +/// All notifications for events that do not have an ``Schedule/Duration-swift.enum/allDay`` duration, are automatically scheduled as [time-sensitive](https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive) +/// notifications. +/// +/// - Important: Make sure to add the "Time Sensitive Notifications" entitlement to your application to support delivering time-sensitive notifications. +/// +/// ### Notification Authorization +/// +/// In order for a user to receive notifications, you have to [request authorization](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications#Explicitly-request-authorization-in-context) +/// from the user to deliver notifications. +/// +/// By default, SpeziScheduler will try to request [provisional notification authorization](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications#Use-provisional-authorization-to-send-trial-notifications). +/// Provisional authorization doesn't require explicit user authorization, however limits notification to be delivered quietly to the notification center only. +/// To disable this behavior use the ``automaticallyRequestProvisionalAuthorization`` option. +/// +/// - Important: To ensure that notifications are delivered as alerts and can play sound, request explicit authorization from the user. Once the user received their first provisional notification +/// and taped the "Keep" button, notifications will always be delivered quietly but the authorization status will change to `authorized`, making it impossible to request notification authorization +/// for alert-based notification again. +/// +/// There are cases where we cannot reliably detect when to re-schedule notifications. For example a user might turn off notifications in the settings app and turn them back on without ever +/// opening the application. In these cases, we never have the opportunity to schedule or update notifications if the users doesn't open up the application again. +/// +/// - Important: If you disable ``automaticallyRequestProvisionalAuthorization``, make sure to call ``Scheduler/manuallyScheduleNotificationRefresh()`` once +/// you requested notification authorization from the user. Otherwise, SpeziScheduler won't schedule notifications properly. +/// +/// ## Topics +/// +/// ### Configuration +/// - ``init()`` +/// - ``init(notificationLimit:schedulingInterval:allDayNotificationTime:notificationPresentation:automaticallyRequestProvisionalAuthorization:)`` +/// +/// ### Properties +/// - ``notificationLimit`` +/// - ``schedulingInterval`` +/// - ``allDayNotificationTime`` +/// - ``notificationPresentation`` +/// - ``automaticallyRequestProvisionalAuthorization`` +/// +/// ### Notification Identifiers +/// - ``notificationId(for:)-33tri`` +/// - ``notificationId(for:)-8cchs`` +/// - ``notificationCategory(for:)`` +/// - ``notificationThreadIdentifier(for:)`` +/// - ``notificationTaskIdKey`` +@MainActor +public final class SchedulerNotifications { + @Application(\.logger) + private var logger + + @Application(\.notificationSettings) + private var notificationSettings + @Application(\.requestNotificationAuthorization) + private var requestNotificationAuthorization + + @Dependency(Notifications.self) + private var notifications + @Dependency(LocalStorage.self) + private var localStorage + + @StandardActor private var standard: any Standard + + /// The limit of notification requests that should be pre-scheduled at a time. + /// + /// This options limits the maximum amount of local notifications request that SpeziScheduler schedules. + /// + /// - Note: Default is `30`. + public nonisolated let notificationLimit: Int + /// The time period for which we should schedule events in advance. + /// + /// - Note: Default is `4` weeks. + public nonisolated let schedulingInterval: TimeInterval + + /// The time at which we schedule notifications for all day events. + /// + /// - Note: Default is 9 AM. + public nonisolated let allDayNotificationTime: NotificationTime + + /// Defines the presentation of scheduler notifications if they are delivered when the app is in foreground. + public nonisolated let notificationPresentation: UNNotificationPresentationOptions + + /// Automatically request provisional notification authorization if notification authorization isn't determined yet. + /// + /// If the module attempts to schedule notifications for its task and detects that notification authorization isn't determined yet, it automatically + /// requests [provisional notification authorization](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications#Use-provisional-authorization-to-send-trial-notifications). + public nonisolated let automaticallyRequestProvisionalAuthorization: Bool // swiftlint:disable:this identifier_name + + + /// Make sure we aren't running multiple notification scheduling at the same time. + private let scheduleNotificationAccess = AsyncSemaphore() + /// Small flag that helps us to debounce multiple calls to schedule notifications. + /// + /// This flag is set once the scheduling notifications task is queued and reset once it starts running. + /// As we are running on the same actor, we know that if the flag is true, we do not need to start another task as we are still in the same call stack + /// and the task that is about to run will still see our changes. + private var queuedForNextTick = false + + private var backgroundTaskRegistered = false + + /// Store the earliest refresh date of the background task. + /// + /// In the case that background tasks are not enabled, we still want to schedule notifications on a best-effort approach. + @AppStorage(SchedulerNotifications.earliestScheduleRefreshDateStorageKey) + private var earliestScheduleRefreshDate: Date? + @AppStorage(SchedulerNotifications.authorizationDisallowedLastSchedulingStorageKey) + private var authorizationDisallowedLastScheduling = false + + @Modifier private var scenePhaseRefresh = NotificationScenePhaseScheduling() + + /// Default configuration. + public required convenience nonisolated init() { + self.init(notificationLimit: 30) + } + + /// Configure the scheduler notifications module. + /// - Parameters: + /// - notificationLimit: The limit of notification requests that should be pre-scheduled at a time. + /// - schedulingInterval: The time period for which we should schedule events in advance. + /// The interval must be greater than one week. + /// - allDayNotificationTime: The time at which we schedule notifications for all day events. + /// - notificationPresentation: Defines the presentation of scheduler notifications if they are delivered when the app is in foreground. + /// - automaticallyRequestProvisionalAuthorization: Automatically request provisional notification authorization if notification authorization isn't determined yet. + public nonisolated init( + notificationLimit: Int = 30, + schedulingInterval: Duration = .weeks(4), + allDayNotificationTime: NotificationTime = NotificationTime(hour: 9), + notificationPresentation: UNNotificationPresentationOptions = [.list, .badge, .banner, .sound], + automaticallyRequestProvisionalAuthorization: Bool = true // swiftlint:disable:this identifier_name + ) { + self.notificationLimit = notificationLimit + self.schedulingInterval = Double(schedulingInterval.components.seconds) + self.allDayNotificationTime = allDayNotificationTime + self.notificationPresentation = notificationPresentation + self.automaticallyRequestProvisionalAuthorization = automaticallyRequestProvisionalAuthorization + + precondition(schedulingInterval >= .weeks(1), "The scheduling interval must be at least 1 week.") + } + + /// Configures the module. + @_documentation(visibility: internal) + public func configure() { + purgeLegacyEventNotifications() + } + + func scheduleNotificationsUpdate(using scheduler: Scheduler) { + guard !queuedForNextTick else { + return + } + + queuedForNextTick = true + _Concurrency.Task { @MainActor in + queuedForNextTick = false + await _scheduleNotificationsUpdate(using: scheduler) + } + } + + func registerProcessingTask(using scheduler: Scheduler) { + if Self.backgroundFetchEnabled { + #if os(macOS) || os(watchOS) + preconditionFailure("BackgroundFetch was enabled even though it isn't supported on this platform.") + #else + backgroundTaskRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: PermittedBackgroundTaskIdentifier.speziSchedulerNotificationsScheduling.rawValue, + using: .main + ) { [weak scheduler, weak self] task in + guard let self, + let scheduler, + let backgroundTask = task as? BGAppRefreshTask else { + return + } + MainActor.assumeIsolated { + handleNotificationsRefresh(for: backgroundTask, using: scheduler) + } + } + #endif + } else { + #if os(macOS) + logger.debug("Background fetch is not supported. Skipping registering background task for notification scheduling.") + #elseif os(watchOS) + logger.debug("Background fetch is currently not supported. Skipping registering background task for notification scheduling.") + #else + logger.debug("Background fetch is not enabled. Skipping registering background task for notification scheduling.") + #endif + } + + _Concurrency.Task { @MainActor in + await checkForInitialScheduling(scheduler: scheduler) + } + } + + func checkForInitialScheduling(scheduler: Scheduler) async { + var scheduleNotificationUpdate = false + + if authorizationDisallowedLastScheduling { + let status = await notificationSettings().authorizationStatus + let nowAllowed = switch status { + case .notDetermined, .denied: + false + case .authorized, .provisional, .ephemeral: + true + @unknown default: + false + } + + if nowAllowed { + logger.debug("Notification Authorization now allows scheduling. Scheduling notifications...") + scheduleNotificationUpdate = true + } + } + + // fallback plan if we do not have background fetch enabled + if !backgroundTaskRegistered, let earliestScheduleRefreshDate, earliestScheduleRefreshDate > .now { + logger.debug("Background task failed to register and we passed earliest refresh date. Manually scheduling...") + scheduleNotificationUpdate = true + } + + if scheduleNotificationUpdate { + scheduleNotificationsUpdate(using: scheduler) + } + } + + private func _scheduleNotificationsUpdate(using scheduler: Scheduler) async { + do { + try await scheduleNotificationAccess.waitCheckingCancellation() + } catch { + return // cancellation + } + + defer { + scheduleNotificationAccess.signal() + } + + let task = _Concurrency.Task { @MainActor in + try await self.updateNotifications(using: scheduler) + } + + #if !os(macOS) && !os(watchOS) + let identifier = _Application.shared.beginBackgroundTask(withName: "Scheduler Notifications") { + task.cancel() + } + + defer { + _Application.shared.endBackgroundTask(identifier) + } + #endif + + do { + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } catch _ as CancellationError { + } catch { + logger.error("Failed to schedule notifications for tasks: \(error)") + } + } +} + + +extension SchedulerNotifications: Module, DefaultInitializable, EnvironmentAccessible {} + + +// MARK: - Notification Scheduling + +extension SchedulerNotifications { + // swiftlint:disable:next function_body_length cyclomatic_complexity + private func updateNotifications(using scheduler: borrowing Scheduler) async throws { + let now = Date.now // ensure consistency in queries + + let hasTasksWithNotificationsAtAll = try measure(name: "hasTasksWithNotifications") { + try scheduler.hasTasksWithNotifications(for: now...) + } + + + guard hasTasksWithNotificationsAtAll else { + // this check is important. We know that not a single task (from now on) has notifications enabled. + // Therefore, we do not need to schedule a background task to refresh notifications + + // ensure task is cancelled +#if !os(macOS) && !os(watchOS) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: PermittedBackgroundTaskIdentifier.speziSchedulerNotificationsScheduling.rawValue) +#endif + earliestScheduleRefreshDate = nil + + // however, ensure that we cancel previously scheduled notifications (e.g., if we refresh due to a change) + await ensureAllSchedulerNotificationsCancelled() + return + } + + /// We have two general strategies to schedule notifications: + /// * **Task-Level**: If the schedule can be expressed using a simple `DateComponents` match (see `Schedule/canBeScheduledAsCalendarTrigger(now:)`, we use a + /// repeating `UNCalendarNotificationTrigger` to schedule all future events of a task. + /// * **Event-Label**: If we cannot express the recurrence of a task using a `UNCalendarNotificationTrigger` we schedule each event separately using a + /// `UNTimeIntervalNotificationTrigger` + + /// We can only schedule a limited amount of notifications at the same time. + /// Therefore, we do the following to limit the number of `UNTimeIntervalNotificationTrigger`-based notifications. + /// 1) We only consider the events within the `schedulingInterval`. + /// 2) Sort all events by their occurrence. + /// 3) Take the first N events and schedule their notifications (with n being the `schedulerLimit`). + /// 4) Update the schedule after 1 week or earlier if the last scheduled event has an earlier occurrence. + + var otherNotificationsCount = 0 + let pendingNotifications = await groupedPendingSchedulerNotifications(otherNotificationsCount: &otherNotificationsCount) + + // the amount of "slots" which would be currently available to other modules to schedule notifications. + let remainingNotificationSlots = Notifications.pendingNotificationsLimit - otherNotificationsCount - notificationLimit + + // if remainingNotificationSlots is negative, we need to lower our limit, because there is simply not enough space for us + let currentSchedulerLimit = min(notificationLimit, notificationLimit + remainingNotificationSlots) + + let range = now.. UNMutableNotificationContent, + trigger: @autoclosure () -> UNNotificationTrigger, + pending: [String: UNNotificationRequest] + ) -> Bool { + guard let existingRequest = pending[identifier] else { + return true + } + + if existingRequest.content == content() && existingRequest.trigger == trigger() { + return false + } else { + // notification exists, but is outdated, so remove it and redo it + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [existingRequest.identifier]) + return true + } + } + + private func ensureAllSchedulerNotificationsCancelled() async { + let pendingNotifications = await notifications.pendingNotificationRequests() + .filter { request in + request.identifier.starts(with: Self.baseNotificationId) + } + .map { $0.identifier } + + if !pendingNotifications.isEmpty { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: pendingNotifications) + } + } + + private func groupedPendingSchedulerNotifications(otherNotificationsCount: inout Int) async -> [String: UNNotificationRequest] { + var otherNotifications = 0 + let result: [String: UNNotificationRequest] = await notifications.pendingNotificationRequests().reduce(into: [:]) { partialResult, request in + if request.identifier.starts(with: Self.baseNotificationId) { + partialResult[request.identifier] = request + } else { + otherNotifications += 1 + } + } + + otherNotificationsCount = otherNotifications + return result + } + + private func taskLevelScheduling( + tasks: ArraySlice, + pending pendingNotifications: [String: UNNotificationRequest], + using scheduler: Scheduler + ) async throws { + var scheduledNotifications = 0 + + for task in tasks { + try _Concurrency.Task.checkCancellation() + + guard let notificationMatchingHint = task.schedule.notificationMatchingHint, + let calendar = task.schedule.recurrence?.calendar else { + continue // shouldn't happen, otherwise, wouldn't be here + } + + let components = notificationMatchingHint.dateComponents(calendar: calendar, allDayNotificationTime: allDayNotificationTime) + + lazy var content = { + let content = task.notificationContent() + if let standard = standard as? SchedulerNotificationsConstraint { + standard.notificationContent(for: task, content: content) + } + return content + }() + lazy var trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + + let id = Self.notificationId(for: task) + guard shouldScheduleNotification(for: id, with: content, trigger: trigger, pending: pendingNotifications) else { + continue + } + + let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) + + // Notification scheduling only fails if there is something statically wrong (e.g., content issues or configuration issues) + // If one fails, probably all fail. So just abort. + // See https://developer.apple.com/documentation/usernotifications/unerror + try await notifications.add(request: request) + + scheduledNotifications += 1 + } + + if scheduledNotifications > 0 { + logger.debug("Scheduled \(scheduledNotifications) task-level notifications.") + } + } + + private func eventLevelScheduling( + for range: Range, + events: ArraySlice, + pending pendingNotifications: [String: UNNotificationRequest], + using scheduler: Scheduler + ) async throws { + var scheduledNotifications = 0 + + for event in events { + try _Concurrency.Task.checkCancellation() + + lazy var content = { + let content = event.task.notificationContent() + if let standard = standard as? SchedulerNotificationsConstraint { + standard.notificationContent(for: event.task, content: content) + } + return content + }() + + let notificationTime = Schedule.notificationTime( + for: event.occurrence.start, + duration: event.occurrence.schedule.duration, + allDayNotificationTime: allDayNotificationTime + ) + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: notificationTime.timeIntervalSinceNow, repeats: false) + + let id = Self.notificationId(for: event) + guard shouldScheduleNotification(for: id, with: content, trigger: trigger, pending: pendingNotifications) else { + continue + } + + let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) + try await notifications.add(request: request) + + scheduledNotifications += 1 + } + + if scheduledNotifications > 0 { + logger.debug("Scheduled \(scheduledNotifications) event-level notifications.") + } + } +} + + +// MARK: - NotificationHandler + +extension SchedulerNotifications: NotificationHandler { + public func receiveIncomingNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions? { + guard notification.request.identifier.starts(with: Self.baseNotificationId) else { + return nil // we are not responsible + } + + return notificationPresentation + } +} + + +// MARK: - Background Tasks + +extension SchedulerNotifications { + @usableFromInline static var uiBackgroundModes: Set { + let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] + return modes.map { modes in + modes.reduce(into: Set()) { partialResult, rawValue in + partialResult.insert(BackgroundMode(rawValue: rawValue)) + } + } ?? [] + } + + @inlinable static var backgroundFetchEnabled: Bool { +#if os(macOS) || os(watchOS) + false +#else + uiBackgroundModes.contains(.fetch) +#endif + } + + private func scheduleNotificationsRefresh(nextThreshold: Date? = nil) { + let nextWeek: Date = .nextWeek + + let earliestBeginDate = if let nextThreshold { + min(nextWeek, nextThreshold) + } else { + nextWeek + } + + + earliestScheduleRefreshDate = earliestBeginDate + + if backgroundTaskRegistered { + #if os(macOS) || os(watchOS) + preconditionFailure("Background Task was set to be registered, but isn't available on this platform.") + #else + let request = BGAppRefreshTaskRequest(identifier: PermittedBackgroundTaskIdentifier.speziSchedulerNotificationsScheduling.rawValue) + request.earliestBeginDate = earliestBeginDate + + do { + logger.debug("Scheduling background task with earliest begin date \(earliestBeginDate)...") + try BGTaskScheduler.shared.submit(request) + } catch let error as BGTaskScheduler.Error { +#if targetEnvironment(simulator) + if case .unavailable = error.code { + logger.warning( + """ + Failed to schedule notifications processing task for SpeziScheduler: \ + Background tasks are not available on simulator devices! + """ + ) + return + } +#endif + logger.error("Failed to schedule notifications processing task for SpeziScheduler: \(error.code)") + } catch { + logger.error("Failed to schedule notifications processing task for SpeziScheduler: \(error)") + } + #endif + } else { + logger.debug("Setting earliest schedule refresh to \(earliestBeginDate). Will attempt to update schedule on next app launch.") + } + } + +#if !os(watchOS) + /// Call to handle execution of the background processing task that updates scheduled notifications. + /// - Parameters: + /// - processingTask: + /// - scheduler: The scheduler to retrieve the events from. + @available(macOS, unavailable) + private func handleNotificationsRefresh(for processingTask: BGAppRefreshTask, using scheduler: Scheduler) { + let task = _Concurrency.Task { @MainActor in + do { + try await scheduleNotificationAccess.waitCheckingCancellation() + } catch { + return // cancellation + } + + defer { + scheduleNotificationAccess.signal() + } + + do { + try await updateNotifications(using: scheduler) + processingTask.setTaskCompleted(success: true) + } catch { + logger.error("Failed to update notifications: \(error)") + processingTask.setTaskCompleted(success: false) + } + } + + processingTask.expirationHandler = { + task.cancel() + } + } +#endif +} + +// MARK: - Legacy Notifications + +extension SchedulerNotifications { + /// Cancel scheduled and delivered notifications of the legacy SpeziScheduler 1.0 + fileprivate func purgeLegacyEventNotifications() { + let legacyStorageKey = "spezi.scheduler.tasks" // the legacy scheduler 1.0 used to store tasks at this location. + + let legacyTasks: [LegacyTaskModel] + do { + legacyTasks = try localStorage.read(storageKey: legacyStorageKey) + } catch { + let nsError = error as NSError + if nsError.domain == CocoaError.errorDomain + && (nsError.code == CocoaError.fileReadNoSuchFile.rawValue || nsError.code == CocoaError.fileNoSuchFile.rawValue ) { + return + } + logger.warning("Failed to read legacy task storage entries: \(error)") + return + } + + for task in legacyTasks { + for event in task.events { + event.cancelNotification() + } + } + + // We don't support migration, so just remove it. + do { + try localStorage.delete(storageKey: legacyStorageKey) + } catch { + logger.warning("Failed to remove legacy scheduler task storage: \(error)") + } + } +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziScheduler/Notifications/SchedulerNotificationsConstraint.swift b/Sources/SpeziScheduler/Notifications/SchedulerNotificationsConstraint.swift new file mode 100644 index 0000000..bc67d23 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/SchedulerNotificationsConstraint.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import UserNotifications + + +/// Customize the notification content of SpeziScheduler notifications. +/// +/// Below is an implementation that adds a subtitle to every notification. +/// ```swift +/// actor MyActor: Standard, SchedulerNotificationsConstraint { +/// @MainActor +/// func notificationContent(for task: borrowing Task, content: borrowing UNMutableNotificationContent) { +/// content.subtitle = "Complete the Questionnaire" +/// } +/// } +/// ``` +public protocol SchedulerNotificationsConstraint: Standard { + /// Customize the notification content of a notification for the event of a task. + /// - Parameters: + /// - task: The task for which we generate the notification for. + /// - content: The default notification content generated by ``SchedulerNotifications`` that can be customized. + @MainActor + func notificationContent(for task: borrowing Task, content: borrowing UNMutableNotificationContent) +} diff --git a/Sources/SpeziScheduler/Notifications/Task+Notifications.swift b/Sources/SpeziScheduler/Notifications/Task+Notifications.swift new file mode 100644 index 0000000..08aa1e0 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/Task+Notifications.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import UserNotifications + + +extension Task { + /// Determine if any notification-related properties changed that require updating the notifications schedule. + /// - Parameters: + /// - previous: The previous task version. + /// - updated: The updated task version. + /// - Returns: Returns `true` if the notification schedule needs to be updated. + static func requiresNotificationRescheduling(previous: Task, updated: Task) -> Bool { + previous.scheduleNotifications != updated.scheduleNotifications + || previous.schedule.notificationMatchingHint != updated.schedule.notificationMatchingHint + } + + + func notificationContent() -> sending UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.title = String(localized: title, locale: .autoupdatingCurrent) + content.body = String(localized: instructions) + + if let category { + content.categoryIdentifier = SchedulerNotifications.notificationCategory(for: category) + } + + if !schedule.duration.isAllDay { + content.interruptionLevel = .timeSensitive + } + + content.sound = .default // will be automatically ignored if sound is not enabled + + content.userInfo[SchedulerNotifications.notificationTaskIdKey] = id + + switch notificationThread { + case .global: + content.threadIdentifier = SchedulerNotifications.baseNotificationId + case .task: + content.threadIdentifier = SchedulerNotifications.notificationThreadIdentifier(for: id) + case let .custom(identifier): + content.threadIdentifier = identifier + case .none: + break + } + + return content + } +} diff --git a/Sources/SpeziScheduler/Notifications/TaskNextOccurrenceCache.swift b/Sources/SpeziScheduler/Notifications/TaskNextOccurrenceCache.swift new file mode 100644 index 0000000..7fe1937 --- /dev/null +++ b/Sources/SpeziScheduler/Notifications/TaskNextOccurrenceCache.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +struct TaskNextOccurrenceCache { + struct Entry { + let occurrence: Occurrence? + } + + private let range: PartialRangeFrom + private var cache: [String: Entry] = [:] + + init(in range: PartialRangeFrom) { + self.range = range + } + + subscript(_ task: Task) -> Occurrence? { + mutating get { + if let entry = cache[task.id] { + return entry.occurrence + } + + let occurrence = task.schedule.nextOccurrence(in: range) + cache[task.id] = Entry(occurrence: occurrence) + return occurrence + } + } +} diff --git a/Sources/SpeziScheduler/Schedule/Date+Extensions.swift b/Sources/SpeziScheduler/Schedule/Date+Extensions.swift index c7ac9b1..a96f893 100644 --- a/Sources/SpeziScheduler/Schedule/Date+Extensions.swift +++ b/Sources/SpeziScheduler/Schedule/Date+Extensions.swift @@ -30,4 +30,12 @@ extension Date { } return tomorrow } + + /// The start of day in one week (7 days). + public static var nextWeek: Date { + guard let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: .today) else { + preconditionFailure("Failed to construct tomorrow from base \(Date.today).") + } + return nextWeek + } } diff --git a/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift b/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift new file mode 100644 index 0000000..0e76503 --- /dev/null +++ b/Sources/SpeziScheduler/Schedule/Duration+Extensions.swift @@ -0,0 +1,102 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +extension Duration { + /// A duration given a number of minutes. + /// + /// ```swift + /// let duration: Duration = .minutes(27) + /// ``` + /// - Returns: A `Duration` representing a given number of minutes. + @inlinable + public static func minutes(_ minutes: some BinaryInteger) -> Duration { + .seconds(minutes * 60) + } + + /// A duration given a number of minutes. + /// + /// Creates a new duration given a number of minutes by converting into the closest second scale value. + /// + /// ```swift + /// let duration: Duration = .minutes(27.5) + /// ``` + /// - Returns: A `Duration` representing a given number of minutes. + @inlinable + public static func minutes(_ minutes: Double) -> Duration { + .seconds(minutes * 60) + } + + /// A duration given a number of hours. + /// + /// ```swift + /// let duration: Duration = .hours(4) + /// ``` + /// - Returns: A `Duration` representing a given number of hours. + @inlinable + public static func hours(_ hours: some BinaryInteger) -> Duration { + .seconds(hours * 60 * 60) + } + + /// A duration given a number of hours. + /// + /// Creates a new duration given a number of hours by converting into the closest second scale value. + /// + /// ```swift + /// let duration: Duration = .hours(4.5) + /// ``` + /// - Returns: A `Duration` representing a given number of hours. + @inlinable + public static func hours(_ hours: Double) -> Duration { + .seconds(hours * 60 * 60) + } + + /// A duration given a number of days. + /// + /// ```swift + /// let duration: Duration = .days(2) + /// ``` + /// - Returns: A `Duration` representing a given number of days. + @inlinable + public static func days(_ days: some BinaryInteger) -> Duration { + .seconds(days * 60 * 60 * 24) + } + + /// A duration given a number of days. + /// + /// ```swift + /// let duration: Duration = .days(2.5) + /// ``` + /// - Returns: A `Duration` representing a given number of days. + @inlinable + public static func days(_ days: Double) -> Duration { + .seconds(days * 60 * 60 * 24) + } + + /// A duration given a number of weeks. + /// + /// ```swift + /// let duration: Duration = .weeks(4) + /// ``` + /// - Returns: A `Duration` representing a given number of weeks. + @inlinable + public static func weeks(_ weeks: some BinaryInteger) -> Duration { + .seconds(weeks * 60 * 60 * 24 * 7) + } + + /// A duration given a number of weeks. + /// + /// ```swift + /// let duration: Duration = .weeks(3.5) + /// ``` + /// - Returns: A `Duration` representing a given number of weeks. + @inlinable + public static func weeks(_ weeks: Double) -> Duration { + .seconds(weeks * 60 * 60 * 24 * 7) + } +} diff --git a/Sources/SpeziScheduler/Schedule/Schedule+Duration.swift b/Sources/SpeziScheduler/Schedule/Schedule+Duration.swift index d1db15c..144aaa2 100644 --- a/Sources/SpeziScheduler/Schedule/Schedule+Duration.swift +++ b/Sources/SpeziScheduler/Schedule/Schedule+Duration.swift @@ -11,14 +11,29 @@ extension Schedule { /// The duration of an occurrence. /// /// While we maintain atto-second accuracy for arithmetic operations on duration, the schedule will always retrieve the duration in a resolution of seconds. + /// + /// ## Topics + /// + /// ### Creating a Duration + /// - ``allDay`` + /// - ``tillEndOfDay`` + /// - ``seconds(_:)`` + /// - ``minutes(_:)-5tlmc`` + /// - ``minutes(_:)-ym89`` + /// - ``hours(_:)-8ihgw`` + /// - ``hours(_:)-5557k`` + /// + /// ### Properties + /// - ``isAllDay`` public enum Duration { /// An all-day occurrence. /// /// The start the will always the the `startOfDay` date. case allDay - /// An occurrence that + /// An occurrence that ends at the end of day. case tillEndOfDay /// Fixed length occurrence. + @_documentation(visibility: internal) case duration(Swift.Duration) } } @@ -26,7 +41,7 @@ extension Schedule { extension Schedule.Duration { /// Determine if a duration is all day. - public var isAllDay: Bool { + @inlinable public var isAllDay: Bool { self == .allDay } @@ -60,6 +75,7 @@ extension Schedule.Duration { /// let duration: Duration = .minutes(27.5) /// ``` /// - Returns: A `Duration` representing a given number of minutes. + @inlinable public static func minutes(_ minutes: Double) -> Schedule.Duration { .duration(.seconds(minutes * 60)) } @@ -80,9 +96,10 @@ extension Schedule.Duration { /// Creates a new duration given a number of hours by converting into the closest second scale value. /// /// ```swift - /// let duration: Duration = .hours(4) + /// let duration: Duration = .hours(4.5) /// ``` /// - Returns: A `Duration` representing a given number of hours. + @inlinable public static func hours(_ hours: Double) -> Schedule.Duration { .minutes(hours * 60) } diff --git a/Sources/SpeziScheduler/Schedule/Schedule.swift b/Sources/SpeziScheduler/Schedule/Schedule.swift index 4039017..fe4940a 100644 --- a/Sources/SpeziScheduler/Schedule/Schedule.swift +++ b/Sources/SpeziScheduler/Schedule/Schedule.swift @@ -51,8 +51,18 @@ public struct Schedule { /// We need a separate storage container as SwiftData cannot store values of type `Swift.Duration`. private var scheduleDuration: Duration.SwiftDataDuration + private var recurrenceRule: Data? + /// A simpler representation of the recurrence rule used for notification scheduling. + /// + /// Some simple recurrences can be expressed as an interval using `DateComponents`. In these cases, we are able to more efficiently + /// schedule notifications. Therefore, if possible we augment the interval hint for recurrence rules we know are simple enough. + /// + /// This is only possible if `DateComponents` doesn't describe another occurrence between `now` and `startDate`. Therefore, we might need to schedule + /// these occurrences manually, till we reach a date where `now` is near enough at the `startDate`. + private(set) var notificationMatchingHint: NotificationMatchingHint? + /// The duration of a single occurrence. /// /// If the duration is `nil`, the schedule provides a start date only. The end date will be automatically chosen to be end of day. @@ -80,6 +90,9 @@ public struct Schedule { } set { recurrenceRule = newValue.map { Data(encoding: $0) } + + // if someone updates the recurrence rule, our notificationMatchingHint is not valid anymore + notificationMatchingHint = nil } } @@ -120,6 +133,19 @@ public struct Schedule { } + init( + startingAt start: Date, + duration: Duration, + recurrence: Calendar.RecurrenceRule?, + notificationIntervalHint: NotificationMatchingHint? + ) { + self.duration = duration + self.start = start + self.recurrence = recurrence + self.notificationMatchingHint = notificationIntervalHint + } + + /// Create a new schedule. /// /// ```swift @@ -136,9 +162,7 @@ public struct Schedule { /// - duration: The duration of a single occurrence. /// - recurrence: Optional recurrence rule to specify how often and in which interval the event my reoccur. public init(startingAt start: Date, duration: Duration = .tillEndOfDay, recurrence: Calendar.RecurrenceRule? = nil) { - self.duration = duration - self.start = start - self.recurrence = recurrence + self.init(startingAt: start, duration: duration, recurrence: recurrence, notificationIntervalHint: nil) } @@ -172,11 +196,12 @@ public struct Schedule { } -extension Schedule: Equatable, Sendable, Codable { +extension Schedule: Equatable, Sendable, Codable {/* private enum CodingKeys: String, CodingKey { case startDate case scheduleDuration case recurrenceRule + case notificationMatchingHint } public init(from decoder: any Decoder) throws { @@ -184,6 +209,7 @@ extension Schedule: Equatable, Sendable, Codable { self.startDate = try container.decode(Date.self, forKey: .startDate) self.scheduleDuration = try container.decode(Schedule.Duration.SwiftDataDuration.self, forKey: .scheduleDuration) self.recurrenceRule = try container.decodeIfPresent(Data.self, forKey: .recurrenceRule) + self.notificationMatchingHint = try container.decodeIfPresent(NotificationMatchingHint.self, forKey: .notificationMatchingHint) } public func encode(to encoder: any Encoder) throws { @@ -191,7 +217,8 @@ extension Schedule: Equatable, Sendable, Codable { try container.encode(startDate, forKey: .startDate) try container.encode(scheduleDuration, forKey: .scheduleDuration) try container.encode(recurrenceRule, forKey: .recurrenceRule) - } + try container.encode(notificationMatchingHint, forKey: .notificationMatchingHint) + }*/ } @@ -245,7 +272,22 @@ extension Schedule { guard let startTime = Calendar.current.date(bySettingHour: hour, minute: minute, second: second, of: start) else { preconditionFailure("Failed to set time of start date for daily schedule. Can't set \(hour):\(minute):\(second) for \(start).") } - return Schedule(startingAt: startTime, duration: duration, recurrence: .daily(calendar: calendar, interval: interval, end: end)) + + let notificationIntervalHint = Schedule.notificationMatchingHint( + forMatchingInterval: interval, + calendar: calendar, + hour: hour, + minute: minute, + second: second, + consider: duration + ) + + return Schedule( + startingAt: startTime, + duration: duration, + recurrence: .daily(calendar: calendar, interval: interval, end: end), + notificationIntervalHint: notificationIntervalHint + ) } /// Create a schedule that repeats weekly. @@ -280,10 +322,23 @@ extension Schedule { guard let startTime = Calendar.current.date(bySettingHour: hour, minute: minute, second: second, of: start) else { preconditionFailure("Failed to set time of start time for weekly schedule. Can't set \(hour):\(minute):\(second) for \(start).") } + + let weekdayNum = weekday.map { $0.ordinal } ?? Calendar.current.component(.weekday, from: startTime) + let notificationIntervalHint = Schedule.notificationMatchingHint( + forMatchingInterval: interval, + calendar: calendar, + hour: hour, + minute: minute, + second: second, + weekday: weekdayNum, + consider: duration + ) + return Schedule( startingAt: startTime, duration: duration, - recurrence: .weekly(calendar: calendar, interval: interval, end: end, weekdays: weekday.map { [.every($0)] } ?? []) + recurrence: .weekly(calendar: calendar, interval: interval, end: end, weekdays: weekday.map { [.every($0)] } ?? []), + notificationIntervalHint: notificationIntervalHint ) } } @@ -334,21 +389,89 @@ extension Schedule { /// /// - Parameter range: A range that limits the search space. If `nil`, return all occurrences in the schedule. /// - Returns: Returns a potentially infinite sequence of ``Occurrence``s. - public func occurrences(in range: Range? = nil) -> some Sequence & Sendable { + public func occurrences(in range: Range? = nil) -> some Sequence { recurrencesSequence(in: range) - .lazy .map { element in Occurrence(start: element, schedule: self) } } - private func recurrencesSequence(in range: Range? = nil) -> some Sequence & Sendable { - if let recurrence { - recurrence.recurrences(of: self.start, in: range) + func nextOccurrence(in range: PartialRangeFrom) -> Occurrence? { + nextOccurrence(in: range.lowerBound..) -> Occurrence? { + occurrences(in: range) + .first { _ in + true + } + } + + func nextOccurrences(in range: PartialRangeFrom, count: Int) -> [Occurrence] { + nextOccurrences(in: range.lowerBound.., count: Int) -> [Occurrence] { + Array(occurrences(in: range).lazy.prefix(count)) + } + + /// Return the last occurrence of a schedule if its part of the requested range. + /// + /// This method iterates through the occurrences of the schedule to find if the last occurrence of the schedule is within the bounds of the provided range. + /// If it is contained in the range, it returns that last occurrence. Otherwise, it returns `nil` if there aren't any occurrences at all, or if the last occurrence occurs after the upper bound. + /// - Parameter range: The range. + /// - Returns: Returns the last occurrence if it is contained within the provided `range`. + func lastOccurrence(ifIn range: Range) -> Occurrence? { + var iterator = occurrences(in: range.lowerBound..= range.upperBound { + return lastOccurrence + } + lastOccurrence = next + } + return lastOccurrence + } + + private func recurrencesSequence(in range: Range? = nil) -> LazyFilterSequence> { + let start = start + let rangeUsedWithRule: Range? + + // When using `afterOccurrences(_:)` as an `end` condition, recurrence rule counts occurrences only base do on the specified range not + // based on the start date. Therefore, we must make sure that the lower-bound of the range is always set to the start date. + // Once SF-0010 is available, we could access if `end.occurrences` is not `nil` and only apply the custom range in this case. + // See https://github.com/apple/swift-foundation/blob/main/Proposals/0010-calendar-recurrence-rule-end-count-and-date.md. + rangeUsedWithRule = range.map { range in + if start > range.upperBound { + // range upper bound might be before start, the range should just return zero occurrences + range + } else { + start..= range.lowerBound + } else { + true + } + } } else { // workaround to make sure we return the same opaque but generic sequence (just equals to `start`) Calendar.RecurrenceRule(calendar: .current, frequency: .daily, end: .afterOccurrences(1)) - .recurrences(of: start, in: range) + .recurrences(of: start, in: rangeUsedWithRule) + .lazy + .filter { date in + if let range { + date >= range.lowerBound + } else { + true + } + } } } } @@ -387,3 +510,5 @@ extension Data { } } } + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziScheduler/Schedule/Weekday+Ordinal.swift b/Sources/SpeziScheduler/Schedule/Weekday+Ordinal.swift new file mode 100644 index 0000000..7b76b10 --- /dev/null +++ b/Sources/SpeziScheduler/Schedule/Weekday+Ordinal.swift @@ -0,0 +1,33 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension Locale.Weekday { + var ordinal: Int { + switch self { + case .sunday: + 1 + case .monday: + 2 + case .tuesday: + 3 + case .wednesday: + 4 + case .thursday: + 5 + case .friday: + 6 + case .saturday: + 7 + @unknown default: + preconditionFailure("A new weekday appeared we don't know about: \(self)") + } + } +} diff --git a/Sources/SpeziScheduler/Scheduler.swift b/Sources/SpeziScheduler/Scheduler.swift index 22f935d..ef7750d 100644 --- a/Sources/SpeziScheduler/Scheduler.swift +++ b/Sources/SpeziScheduler/Scheduler.swift @@ -9,7 +9,6 @@ import Combine import Foundation import Spezi -import SpeziLocalStorage import SwiftData import SwiftUI @@ -23,7 +22,7 @@ import SwiftUI /// for tasks. It allows to modify the properties (e.g., schedule) of future events without affecting occurrences of the past. /// /// You create and automatically update your tasks -/// using ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)``. +/// using ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:with:)``. /// /// Below is a example on how to create your own [`Module`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/module) /// to manage your tasks and ensure they are always up to date. @@ -59,12 +58,12 @@ import SwiftUI /// ### Configuration /// - ``init()`` /// -/// ### Creating Tasks -/// - ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)`` +/// ### Creating and Updating Tasks +/// - ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:with:)`` /// /// ### Query Tasks -/// - ``queryTasks(for:predicate:sortBy:prefetchOutcomes:)-f7se`` -/// - ``queryTasks(for:predicate:sortBy:prefetchOutcomes:)-583yk`` +/// - ``queryTasks(for:predicate:sortBy:fetchLimit:prefetchOutcomes:)-8z86i`` +/// - ``queryTasks(for:predicate:sortBy:fetchLimit:prefetchOutcomes:)-5cuwe`` /// /// ### Query Events /// - ``queryEvents(for:predicate:)`` @@ -88,8 +87,8 @@ public final class Scheduler { @Application(\.logger) private var logger - @Dependency(LocalStorage.self) - private var localStorage + @Dependency(SchedulerNotifications.self) + private var notifications private var _container: Result? @@ -130,20 +129,18 @@ public final class Scheduler { return // we have a container injected for testing purposes } - let testing: Bool + let configuration: ModelConfiguration #if targetEnvironment(simulator) || TEST - testing = true + configuration = ModelConfiguration(isStoredInMemoryOnly: true) #elseif os(macOS) - testing = Self.isTesting -#else - testing = false -#endif - - let configuration: ModelConfiguration = if testing { - ModelConfiguration(isStoredInMemoryOnly: true) + if Self.isTesting { + configuration = ModelConfiguration(isStoredInMemoryOnly: true) } else { - ModelConfiguration(url: URL.documentsDirectory.appending(path: "edu.stanford.spezi.scheduler.storage.sqlite")) + configuration = ModelConfiguration(url: URL.documentsDirectory.appending(path: "edu.stanford.spezi.scheduler.storage.sqlite")) } +#else + configuration = ModelConfiguration(url: URL.documentsDirectory.appending(path: "edu.stanford.spezi.scheduler.storage.sqlite")) +#endif do { _container = .success(try ModelContainer(for: Task.self, Outcome.self, configurations: configuration)) @@ -158,15 +155,15 @@ public final class Scheduler { // It also makes it easier to understand the SwiftData-related infrastructure around Spezi Scheduler. // One could think that Apple could have provided a lot of this information in their documentation. - - if Self.purgeLegacyStorage { - // the legacy scheduler 1.0 used to store tasks at this location. We don't support migration, so just remove it. - do { - try localStorage.delete(storageKey: Constants.taskStorageKey) - } catch { - logger.warning("Failed to remove legacy scheduler task storage: \(error)") - } - } + notifications.registerProcessingTask(using: self) + } + + /// Trigger a manual refresh of the scheduled notifications. + /// + /// Call this method after requesting notification authorization from the user, if you disabled the ``SchedulerNotifications/automaticallyRequestProvisionalAuthorization`` + /// option. + public func manuallyScheduleNotificationRefresh() { + notifications.scheduleNotificationsUpdate(using: self) } @@ -175,25 +172,26 @@ public final class Scheduler { /// When we add a new task we want to instantly save it to disk. This helps to, e.g., make sure a `@EventQuery` receives the update by subscribing to the /// `didSave` notification. We delay saving the context by a bit, by queuing a task for the next possible execution. This helps to avoid that adding a new task model /// blocks longer than needed and makes sure that creating multiple tasks in sequence (which happens at startup) doesn't call `save()` more often than required. - private func scheduleSave(for context: ModelContext) { - guard context.hasChanges else { - return - } - - guard saveTask == nil else { - return // se docs above - } + private func scheduleSave(for context: ModelContext, rescheduleNotifications: Bool) { + if saveTask == nil, context.hasChanges { + // as we run on the MainActor in the task, if the saveTask is not nil, + // we know that the Task isn't executed yet but will on the "next" tick. + + saveTask = _Concurrency.Task { @MainActor [logger] in + defer { + saveTask = nil + } - saveTask = _Concurrency.Task { [logger] in - defer { - saveTask = nil + do { + try context.save() + } catch { + logger.error("Failed to save the scheduler model context: \(error)") + } } + } - do { - try context.save() - } catch { - logger.error("Failed to save the scheduler model context: \(error)") - } + if rescheduleNotifications { + notifications.scheduleNotificationsUpdate(using: self) } } @@ -213,19 +211,23 @@ public final class Scheduler { /// - category: The user-visible category information of a task. /// - schedule: The schedule for the events of this task. /// - completionPolicy: The policy to decide when an event can be completed by the user. + /// - scheduleNotifications: Automatically schedule notifications for upcoming events. + /// - notificationThread: The behavior how task notifications are grouped in the notification center. /// - tags: Custom tags associated with the task. /// - effectiveFrom: The date from which this version of the task is effective. You typically do not want to modify this parameter. /// If you do specify a custom value, make sure to specify it relative to `now`. /// - contextClosure: The closure that allows to customize the ``Task/Context`` that is stored with the task. /// - Returns: Returns the latest version of the `task` and if the task was updated or created indicated by `didChange`. @discardableResult - public func createOrUpdateTask( // swiftlint:disable:this function_default_parameter_at_end + public func createOrUpdateTask( // swiftlint:disable:this function_default_parameter_at_end function_body_length id: String, title: String.LocalizationValue, instructions: String.LocalizationValue, category: Task.Category? = nil, schedule: Schedule, completionPolicy: AllowedCompletionPolicy = .sameDay, + scheduleNotifications: Bool = false, + notificationThread: NotificationThread = .global, tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection effectiveFrom: Date = .now, with contextClosure: ((inout Task.Context) -> Void)? = nil @@ -259,15 +261,19 @@ public final class Scheduler { category: category, schedule: schedule, completionPolicy: completionPolicy, + scheduleNotifications: scheduleNotifications, + notificationThread: notificationThread, tags: tags, effectiveFrom: effectiveFrom, with: contextClosure ) if result.didChange { - scheduleSave(for: context) + let notifications = Task.requiresNotificationRescheduling(previous: existingTask, updated: result.task) + scheduleSave(for: context, rescheduleNotifications: notifications) } + return result } else { let task = Task( @@ -277,12 +283,15 @@ public final class Scheduler { category: category, schedule: schedule, completionPolicy: completionPolicy, + scheduleNotifications: scheduleNotifications, + notificationThread: notificationThread, tags: tags ?? [], effectiveFrom: effectiveFrom, with: contextClosure ?? { _ in } ) context.insert(task) - scheduleSave(for: context) + scheduleSave(for: context, rescheduleNotifications: scheduleNotifications) + return (task, true) } } @@ -297,7 +306,7 @@ public final class Scheduler { } context.insert(outcome) - scheduleSave(for: context) + scheduleSave(for: context, rescheduleNotifications: false) } /// Delete a task from the store. @@ -331,7 +340,7 @@ public final class Scheduler { context.delete(task) } - scheduleSave(for: context) + scheduleSave(for: context, rescheduleNotifications: true) } /// Delete all versions of the supplied task from the store. @@ -367,7 +376,7 @@ public final class Scheduler { task.id == taskId }) - scheduleSave(for: context) + scheduleSave(for: context, rescheduleNotifications: true) } /// Query the list of tasks. @@ -381,15 +390,23 @@ public final class Scheduler { /// - range: The closed date range in which queried task versions need to be effective. /// - predicate: Specify additional conditions to filter the list of task that is fetched from the store. /// - sortDescriptors: Additionally sort descriptors. The list of task is always sorted by its ``Task/effectiveFrom``. + /// - fetchLimit: The maximum number of models the query can return. /// - prefetchOutcomes: Flag to indicate if the ``Task/outcomes`` relationship should be pre-fetched. By default this is `false` and relationship data is loaded lazily. /// - Returns: The list of `Task` that are effective in the specified date range and match the specified `predicate`. The result is ordered by the specified `sortDescriptors`. public func queryTasks( for range: ClosedRange, predicate: Predicate = #Predicate { _ in true }, sortBy sortDescriptors: [SortDescriptor] = [], + fetchLimit: Int? = nil, prefetchOutcomes: Bool = false ) throws -> [Task] { - try queryTask(with: inClosedRangePredicate(for: range), combineWith: predicate, sortBy: sortDescriptors, prefetchOutcomes: prefetchOutcomes) + try queryTasks( + with: inClosedRangePredicate(for: range), + combineWith: predicate, + sortBy: sortDescriptors, + fetchLimit: fetchLimit, + prefetchOutcomes: prefetchOutcomes + ) } @@ -404,15 +421,39 @@ public final class Scheduler { /// - range: The date range in which queried task versions need to be effective. /// - predicate: Specify additional conditions to filter the list of task that is fetched from the store. /// - sortDescriptors: Additionally sort descriptors. The list of task is always sorted by its ``Task/effectiveFrom``. + /// - fetchLimit: The maximum number of models the query can return. /// - prefetchOutcomes: Flag to indicate if the ``Task/outcomes`` relationship should be pre-fetched. By default this is `false` and relationship data is loaded lazily. /// - Returns: The list of `Task` that are effective in the specified date range and match the specified `predicate`. The result is ordered by the specified `sortDescriptors`. public func queryTasks( for range: Range, predicate: Predicate = #Predicate { _ in true }, sortBy sortDescriptors: [SortDescriptor] = [], + fetchLimit: Int? = nil, prefetchOutcomes: Bool = false ) throws -> [Task] { - try queryTask(with: inRangePredicate(for: range), combineWith: predicate, sortBy: sortDescriptors, prefetchOutcomes: prefetchOutcomes) + try queryTasks( + with: inRangePredicate(for: range), + combineWith: predicate, + sortBy: sortDescriptors, + fetchLimit: fetchLimit, + prefetchOutcomes: prefetchOutcomes + ) + } + + func queryTasks( + for range: PartialRangeFrom, + predicate: Predicate = #Predicate { _ in true }, + sortBy sortDescriptors: [SortDescriptor] = [], + fetchLimit: Int? = nil, + prefetchOutcomes: Bool = false + ) throws -> [Task] { + try queryTasks( + with: inPartialRangeFromPredicate(for: range), + combineWith: predicate, + sortBy: sortDescriptors, + fetchLimit: fetchLimit, + prefetchOutcomes: prefetchOutcomes + ) } /// Query the list of events. @@ -435,8 +476,32 @@ public final class Scheduler { let tasks = try queryTasks(for: range, predicate: taskPredicate) let outcomes = try queryOutcomes(for: range, predicate: taskPredicate) - let outcomesByOccurrence = outcomes.reduce(into: [:]) { partialResult, outcome in - partialResult[outcome.occurrenceStartDate] = outcome + return assembleEvents(for: range, tasks: tasks, outcomes: outcomes) + } +} + + +extension Scheduler: Module, EnvironmentAccessible, Sendable {} + + +extension Scheduler { + private struct OccurrenceId: Hashable { + let taskId: Task.ID + let startDate: Date + + init(task: Task, startDate: Date) { + self.taskId = task.id + self.startDate = startDate + } + } + + func assembleEvents>( + for range: Range, + tasks: S, + outcomes: [Outcome]? // swiftlint:disable:this discouraged_optional_collection + ) -> [Event] { + let outcomesByOccurrence = outcomes?.reduce(into: [:]) { partialResult, outcome in + partialResult[OccurrenceId(task: outcome.task, startDate: outcome.occurrenceStartDate)] = outcome } return tasks @@ -463,11 +528,15 @@ public final class Scheduler { return task.schedule .occurrences(in: lowerBound.. Event in + if let outcomesByOccurrence { + if let outcome = outcomesByOccurrence[OccurrenceId(task: task, startDate: occurrence.start)] { + Event(task: task, occurrence: occurrence, outcome: .value(outcome)) + } else { + Event(task: task, occurrence: occurrence, outcome: .createWith(self)) + } } else { - Event(task: task, occurrence: occurrence, outcome: .createWith(self)) + Event(task: task, occurrence: occurrence, outcome: .preventCreation) } } } @@ -476,6 +545,17 @@ public final class Scheduler { } } + func hasEventOccurrence>(in range: Range, tasks: S) -> Bool { + tasks + .lazy + .compactMap { task in + task.schedule.nextOccurrence(in: range) + } + .contains { _ in + true + } + } + func queryEventsAnchor( for range: Range, predicate taskPredicate: Predicate = #Predicate { _ in true } @@ -499,16 +579,25 @@ public final class Scheduler { } } - -extension Scheduler: Module, EnvironmentAccessible, Sendable {} - // MARK: - Fetch Implementations extension Scheduler { - private func queryTask( + func hasTasksWithNotifications(for range: PartialRangeFrom) throws -> Bool { + let rangePredicate = inPartialRangeFromPredicate(for: range) + let descriptor = FetchDescriptor( + predicate: #Predicate { task in + rangePredicate.evaluate(task) && task.scheduleNotifications + } + ) + + return try context.fetchCount(descriptor) > 0 + } + + private func queryTasks( // swiftlint:disable:this function_default_parameter_at_end with basePredicate: Predicate, combineWith userPredicate: Predicate, sortBy sortDescriptors: [SortDescriptor], + fetchLimit: Int? = nil, prefetchOutcomes: Bool ) throws -> [Task] { var descriptor = FetchDescriptor( @@ -517,6 +606,7 @@ extension Scheduler { }, sortBy: sortDescriptors ) + descriptor.fetchLimit = fetchLimit descriptor.sortBy.append(SortDescriptor(\.effectiveFrom, order: .forward)) // make sure querying the next version is always efficient @@ -530,12 +620,14 @@ extension Scheduler { } private func queryOutcomes(for range: Range, predicate taskPredicate: Predicate) throws -> [Outcome] { - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { outcome in range.contains(outcome.occurrenceStartDate) && taskPredicate.evaluate(outcome.task) } ) + descriptor.relationshipKeyPathsForPrefetching = [\.task] + return try context.fetch(descriptor) } @@ -578,6 +670,17 @@ extension Scheduler { } } + private func inPartialRangeFromPredicate(for range: PartialRangeFrom) -> Predicate { + #Predicate { task in + if let effectiveTo = task.nextVersion?.effectiveFrom { + task.effectiveFrom <= range.lowerBound + && range.lowerBound < effectiveTo + } else { + task.effectiveFrom <= range.lowerBound + } + } + } + private func inClosedRangePredicate(for range: ClosedRange) -> Predicate { #Predicate { task in if let effectiveTo = task.nextVersion?.effectiveFrom { diff --git a/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md b/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md index db729b9..1d3f56e 100644 --- a/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md +++ b/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md @@ -70,13 +70,33 @@ class MySchedulerModule: Module { ### Task - ``Task`` +- ``Task/ID-swift.struct`` - ``Task/Category-swift.struct`` - ``Event`` - ``Outcome`` - ``Property()`` +- ``AllowedCompletionPolicy`` -### Date Accessors +### Notifications + +- ``SchedulerNotifications`` +- ``SchedulerNotificationsConstraint`` +- ``NotificationTime`` + +### Date Extensions - ``Foundation/Date/today`` - ``Foundation/Date/tomorrow`` - ``Foundation/Date/yesterday`` +- ``Foundation/Date/nextWeek`` + +### Duration Extensions + +- ``Swift/Duration/minutes(_:)-109v7`` +- ``Swift/Duration/minutes(_:)-1i7j5`` +- ``Swift/Duration/hours(_:)-191bg`` +- ``Swift/Duration/hours(_:)-33xlm`` +- ``Swift/Duration/days(_:)-58sx4`` +- ``Swift/Duration/days(_:)-4geo0`` +- ``Swift/Duration/weeks(_:)-34lc3`` +- ``Swift/Duration/weeks(_:)-74s4k`` diff --git a/Sources/SpeziScheduler/Task/Event.swift b/Sources/SpeziScheduler/Task/Event.swift index 3c70cd3..3fb70dd 100644 --- a/Sources/SpeziScheduler/Task/Event.swift +++ b/Sources/SpeziScheduler/Task/Event.swift @@ -33,12 +33,14 @@ public struct Event { case value(Outcome) /// For testing support to avoid associating a scheduler. case mocked + /// Cannot create new outcomes with this instance of event. + case preventCreation var value: Outcome? { switch self { case let .value(value): value - case .createWith, .mocked: + case .createWith, .mocked, .preventCreation: nil } } @@ -129,6 +131,8 @@ public struct Event { return outcome case .mocked: return createNewOutCome(with: closure) + case .preventCreation: + preconditionFailure("Tried to complete an event that has an incomplete representation: \(self)") } } diff --git a/Sources/SpeziScheduler/Task/Task+Category.swift b/Sources/SpeziScheduler/Task/Task+Category.swift index d2fc321..6e693ac 100644 --- a/Sources/SpeziScheduler/Task/Task+Category.swift +++ b/Sources/SpeziScheduler/Task/Task+Category.swift @@ -16,6 +16,16 @@ extension Task { /// ```swift /// let myCategory: Task.Category = .custom("my-category") /// ``` + /// + /// ## Topics + /// + /// ### Default Categories + /// - ``questionnaire`` + /// - ``measurement`` + /// - ``medication`` + /// + /// ### Creating a new Category + /// - ``custom(_:)`` public struct Category { /// The category name. @_spi(APISupport) diff --git a/Sources/SpeziScheduler/Task/Task.swift b/Sources/SpeziScheduler/Task/Task.swift index f781968..7aa8f35 100644 --- a/Sources/SpeziScheduler/Task/Task.swift +++ b/Sources/SpeziScheduler/Task/Task.swift @@ -18,6 +18,8 @@ import SwiftData /// A task might occur once or multiple times. The occurrence of a task is referred to as an ``Event``. /// The ``Schedule`` defines when and how often a task reoccurs. /// +/// - Note: SpeziScheduler can automatically schedule notifications for your events. Refer to the documentation of the ``SchedulerNotifications`` module for more information. +/// /// ### Versioning /// Tasks are stored in an append-only format. If you want to modify the contents of a task (e.g., the schedule, title or instructions), you create a new version of the task /// and set the ``effectiveFrom`` to indicate the date and time at which the updated version becomes effective. Only the newest task version can be modified. @@ -50,12 +52,18 @@ import SwiftData /// - ``instructions`` /// - ``category`` /// - ``schedule`` +/// - ``completionPolicy`` /// - ``tags`` /// - ``outcomes`` /// +/// ### Notifications +/// +/// - ``scheduleNotifications`` +/// - ``notificationThread`` +/// /// ### Modifying a task /// - ``Scheduler/createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)`` -/// - ``createUpdatedVersion(title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)`` +/// - ``createUpdatedVersion(title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:with:)`` /// /// ### Storing additional information /// - ``Context`` @@ -116,6 +124,15 @@ public final class Task { /// The policy to decide when an event can be completed by the user. public private(set) var completionPolicy: AllowedCompletionPolicy + + /// Automatically schedule notifications for upcoming events. + /// + /// If this flag is set to `true`, the ``SchedulerNotifications`` will automatically schedule notifications for the upcoming + /// events of this task. Refer to the documentation of `SchedulerNotifications` for all necessary steps and configuration in order to use this feature. + public private(set) var scheduleNotifications: Bool + + /// The behavior how task notifications are grouped in the notification center. + public private(set) var notificationThread: NotificationThread /// Tags associated with the task. /// @@ -161,6 +178,8 @@ public final class Task { category: Category?, schedule: Schedule, completionPolicy: AllowedCompletionPolicy, + scheduleNotifications: Bool, + notificationThread: NotificationThread, tags: [String], effectiveFrom: Date, context: Context @@ -171,6 +190,8 @@ public final class Task { self.category = category self.schedule = schedule self.completionPolicy = completionPolicy + self.scheduleNotifications = scheduleNotifications + self.notificationThread = notificationThread self.outcomes = [] self.tags = tags self.effectiveFrom = effectiveFrom @@ -187,6 +208,8 @@ public final class Task { category: Category?, schedule: Schedule, completionPolicy: AllowedCompletionPolicy, + scheduleNotifications: Bool, + notificationThread: NotificationThread, tags: [String], effectiveFrom: Date, with contextClosure: (inout Context) -> Void = { _ in } @@ -201,6 +224,8 @@ public final class Task { category: category, schedule: schedule, completionPolicy: completionPolicy, + scheduleNotifications: scheduleNotifications, + notificationThread: notificationThread, tags: tags, effectiveFrom: effectiveFrom, context: context @@ -219,6 +244,8 @@ public final class Task { /// - category: The user-visible category information of a task. /// - schedule: The updated schedule or `nil` if the schedule should not be updated. /// - completionPolicy: The policy to decide when an event can be completed by the user. + /// - scheduleNotifications: Automatically schedule notifications for upcoming events. + /// - notificationThread: The behavior how task notifications are grouped in the notification center. /// - tags: Custom tags associated with the task. /// - effectiveFrom: The date this update is effective from. /// - contextClosure: The updated context or `nil` if the context should not be updated. @@ -229,6 +256,8 @@ public final class Task { category: Category? = nil, schedule: Schedule? = nil, completionPolicy: AllowedCompletionPolicy? = nil, + scheduleNotifications: Bool? = nil, // swiftlint:disable:this discouraged_optional_boolean + notificationThread: NotificationThread? = nil, tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection effectiveFrom: Date = .now, with contextClosure: ((inout Context) -> Void)? = nil @@ -240,21 +269,26 @@ public final class Task { category: category, schedule: schedule, completionPolicy: completionPolicy, + scheduleNotifications: scheduleNotifications, + notificationThread: notificationThread, + tags: tags, effectiveFrom: effectiveFrom, with: contextClosure ) } - func createUpdatedVersion( + func createUpdatedVersion( // swiftlint:disable:this function_body_length function_parameter_count skipShadowCheck: Bool, - title: String.LocalizationValue? = nil, - instructions: String.LocalizationValue? = nil, - category: Category? = nil, - schedule: Schedule? = nil, - completionPolicy: AllowedCompletionPolicy? = nil, - tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection - effectiveFrom: Date = .now, - with contextClosure: ((inout Context) -> Void)? = nil + title: String.LocalizationValue?, + instructions: String.LocalizationValue?, + category: Category?, + schedule: Schedule?, + completionPolicy: AllowedCompletionPolicy?, + scheduleNotifications: Bool?, // swiftlint:disable:this discouraged_optional_boolean + notificationThread: NotificationThread?, + tags: [String]?, // swiftlint:disable:this discouraged_optional_collection + effectiveFrom: Date, + with contextClosure: ((inout Context) -> Void)? ) throws -> (task: Task, didChange: Bool) { let context: Context? if let contextClosure { @@ -275,6 +309,8 @@ public final class Task { || didChange(schedule, for: \.schedule) || didChange(completionPolicy, for: \.completionPolicy) || didChange(tags, for: \.tags) + || didChange(scheduleNotifications, for: \.scheduleNotifications) + || didChange(notificationThread, for: \.notificationThread) || didChange(context?.userInfo, for: \.userInfo) else { return (self, false) // nothing changed } @@ -300,6 +336,8 @@ public final class Task { category: category ?? self.category, schedule: schedule ?? self.schedule, completionPolicy: completionPolicy ?? self.completionPolicy, + scheduleNotifications: scheduleNotifications ?? self.scheduleNotifications, + notificationThread: notificationThread ?? self.notificationThread, tags: tags ?? self.tags, effectiveFrom: effectiveFrom, context: context ?? Context() diff --git a/Sources/SpeziScheduler/Utils/Measure.swift b/Sources/SpeziScheduler/Utils/Measure.swift new file mode 100644 index 0000000..1c2c91a --- /dev/null +++ b/Sources/SpeziScheduler/Utils/Measure.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OSLog + + +private let logger = Logger(subsystem: "edu.stanford.spezi.scheduler", category: "EventQuery") + + +func measure( + clock: C = ContinuousClock(), + name: @autoclosure @escaping () -> StaticString, + _ action: () throws -> T +) rethrows -> T where C.Instant.Duration == Duration { + #if DEBUG || TEST + let start = clock.now + let result = try action() + let end = clock.now + logger.debug("Performing \(name()) took \(start.duration(to: end))") + return result + #else + try action() + #endif +} + + +func measure( + isolation: isolated (any Actor)? = #isolation, + clock: C = ContinuousClock(), + name: @autoclosure @escaping () -> StaticString, + _ action: () async throws -> sending T +) async rethrows -> sending T where C.Instant.Duration == Duration { +#if DEBUG || TEST + let start = clock.now + let result = try await action() + let end = clock.now + logger.debug("Performing \(name()) took \(start.duration(to: end))") + return result +#else + try await action() +#endif +} diff --git a/Sources/SpeziSchedulerUI/Category/DisableCategoryDefaultAppearancesModifier.swift b/Sources/SpeziSchedulerUI/Category/DisableCategoryDefaultAppearancesModifier.swift new file mode 100644 index 0000000..0cc5cf8 --- /dev/null +++ b/Sources/SpeziSchedulerUI/Category/DisableCategoryDefaultAppearancesModifier.swift @@ -0,0 +1,33 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DisableCategoryDefaultAppearancesModifier: ViewModifier { // swiftlint:disable:this type_name + private let disabled: Bool + + @Environment(\.taskCategoryAppearances) + private var taskCategoryAppearances + + init(disabled: Bool) { + self.disabled = disabled + } + + func body(content: Content) -> some View { + content + .environment(\.taskCategoryAppearances, taskCategoryAppearances.disableDefaultAppearances(disabled)) + } +} + + +extension View { + func disableCategoryDefaultAppearances(_ disabled: Bool = true) -> some View { + modifier(DisableCategoryDefaultAppearancesModifier(disabled: disabled)) + } +} diff --git a/Sources/SpeziSchedulerUI/Category/TaskCategoryAppearances.swift b/Sources/SpeziSchedulerUI/Category/TaskCategoryAppearances.swift index dc9351c..2ea2d37 100644 --- a/Sources/SpeziSchedulerUI/Category/TaskCategoryAppearances.swift +++ b/Sources/SpeziSchedulerUI/Category/TaskCategoryAppearances.swift @@ -15,26 +15,49 @@ import SwiftUI /// Stores all configured category appearances for the view hierarchy. public struct TaskCategoryAppearances { private let appearances: [Task.Category: Task.Category.Appearance] + private let disableDefaultAppearances: Bool // for test purposes init() { - self.init([:]) + self.init([:], disableDefaultAppearances: false) } - init(_ appearances: [Task.Category: Task.Category.Appearance]) { + init(_ appearances: [Task.Category: Task.Category.Appearance], disableDefaultAppearances: Bool) { self.appearances = appearances + self.disableDefaultAppearances = disableDefaultAppearances + } + + private func buildIntDefault(for category: Task.Category) -> Task.Category.Appearance? { + guard !disableDefaultAppearances else { + return nil + } + + return switch category { + case .questionnaire: + .init(label: "Questionnaire", image: .system("heart.text.clipboard.fill")) + case .measurement: + .init(label: "Measurement", image: .system("heart.text.square.fill")) + case .medication: + .init(label: "Medication", image: .system("pills.circle.fill")) + default: + nil + } } func inserting(_ appearance: Task.Category.Appearance, for category: Task.Category) -> Self { var appearances = appearances appearances[category] = appearance - return TaskCategoryAppearances(appearances) + return TaskCategoryAppearances(appearances, disableDefaultAppearances: disableDefaultAppearances) + } + + func disableDefaultAppearances(_ disabled: Bool = true) -> Self { + TaskCategoryAppearances(appearances, disableDefaultAppearances: disabled) } /// Retrieve the appearance for a given category. /// - Parameter category: The task category. /// - Returns: The appearance stored for the category. public subscript(_ category: Task.Category) -> Task.Category.Appearance? { - appearances[category] + appearances[category] ?? buildIntDefault(for: category) } } diff --git a/Sources/SpeziSchedulerUI/DefaultTileHeader.swift b/Sources/SpeziSchedulerUI/DefaultTileHeader.swift index 92b91ec..fdb1b1d 100644 --- a/Sources/SpeziSchedulerUI/DefaultTileHeader.swift +++ b/Sources/SpeziSchedulerUI/DefaultTileHeader.swift @@ -87,20 +87,44 @@ public struct DefaultTileHeader: View { } } + private func occurrenceStartTimeText() -> Text { + Text(event.occurrence.start, style: .time) + } + + private func occurrenceDurationText() -> Text { + Text( + "\(Text(event.occurrence.start, style: .time)) to \(Text(event.occurrence.end, style: .time))", + bundle: .module, + comment: "start time till end time" + ) + } + + private func dateOffsetText() -> Text { + Text(.currentDate, format: dateReferenceFormat(to: event.occurrence.start)) + } + private func dateSubheadline() -> Text? { switch event.occurrence.schedule.duration { case .allDay: nil case .tillEndOfDay: - Text(event.occurrence.start, style: .time) + if Calendar.current.isDateInToday(event.occurrence.start) { + occurrenceStartTimeText() + } else { + Text("\(dateOffsetText()), \(occurrenceStartTimeText())") + } case .duration: - Text( - "\(Text(event.occurrence.start, style: .time)) to \(Text(event.occurrence.end, style: .time))", - bundle: .module, - comment: "start time till end time" - ) + if Calendar.current.isDateInToday(event.occurrence.start) { + occurrenceDurationText() + } else { + Text("\(dateOffsetText()), \(occurrenceDurationText())") + } } } + + private func dateReferenceFormat(to date: Date) -> SystemFormatStyle.DateReference { + SystemFormatStyle.DateReference(to: date, allowedFields: [.year, .month, .week, .day, .hour], maxFieldCount: 1, thresholdField: .month) + } } diff --git a/Sources/SpeziSchedulerUI/EventScheduleList.swift b/Sources/SpeziSchedulerUI/EventScheduleList.swift index 2ec9879..72d9db6 100644 --- a/Sources/SpeziSchedulerUI/EventScheduleList.swift +++ b/Sources/SpeziSchedulerUI/EventScheduleList.swift @@ -115,7 +115,7 @@ public struct EventScheduleList: View { Image(systemName: "exclamationmark.triangle.fill") .accessibilityHidden(true) } - .symbolRenderingMode(.multicolor) + .symbolRenderingMode(.multicolor) } description: { if let localizedError = fetchError as? LocalizedError, let reason = localizedError.failureReason { @@ -154,7 +154,7 @@ public struct EventScheduleList: View { /// - Parameters: /// - date: The date for which the event schedule is display. /// - content: A closure that is called to display each event occurring today. - public init(date: Date = .today, content: @escaping (Event) -> Tile) { + public init(date: Date = .today, @ViewBuilder content: @escaping (Event) -> Tile) { self.date = Calendar.current.startOfDay(for: date) self.eventTile = content diff --git a/Sources/SpeziSchedulerUI/Resources/Localizable.xcstrings b/Sources/SpeziSchedulerUI/Resources/Localizable.xcstrings index 04ce11d..21a2406 100644 --- a/Sources/SpeziSchedulerUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziSchedulerUI/Resources/Localizable.xcstrings @@ -84,6 +84,12 @@ }, "Failed to fetch Events" : { + }, + "Measurement" : { + + }, + "Medication" : { + }, "More Information" : { "localizations" : { @@ -112,6 +118,9 @@ }, "No Events Yesterday" : { + }, + "Questionnaire" : { + }, "Retry" : { @@ -139,4 +148,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Sources/SpeziSchedulerUI/TestingSupport/SchedulerSampleData.swift b/Sources/SpeziSchedulerUI/TestingSupport/SchedulerSampleData.swift index 928efe3..e4aee0b 100644 --- a/Sources/SpeziSchedulerUI/TestingSupport/SchedulerSampleData.swift +++ b/Sources/SpeziSchedulerUI/TestingSupport/SchedulerSampleData.swift @@ -26,6 +26,8 @@ public struct SchedulerSampleData: PreviewModifier { category: .questionnaire, schedule: .daily(hour: 17, minute: 0, startingAt: .today), completionPolicy: .sameDay, + scheduleNotifications: false, + notificationThread: .task, tags: [], effectiveFrom: .today // make sure test task always starts from the start of today ) diff --git a/Sources/SpeziSchedulerUI/TodayList.swift b/Sources/SpeziSchedulerUI/TodayList.swift new file mode 100644 index 0000000..6db7ffc --- /dev/null +++ b/Sources/SpeziSchedulerUI/TodayList.swift @@ -0,0 +1,107 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SpeziScheduler +import SpeziViews +import SwiftUI + + +/// An overview of all task for today. +/// +/// The view renders all task occurring today in a list view. +/// +/// ```swift +/// TodayList { event in +/// InstructionsTile(event) { +/// QuestionnaireEventDetailView(event) +/// } action: { +/// event.complete() +/// } +/// } +/// .navigationTitle("Schedule") +/// ``` +public struct TodayList: View { + @EventQuery(in: Date.today.. Tile + + public var body: some View { + if let fetchError = $events.fetchError { + ContentUnavailableView { + Label { + Text("Failed to fetch Events", bundle: .module) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .accessibilityHidden(true) + } + .symbolRenderingMode(.multicolor) + } description: { + if let localizedError = fetchError as? LocalizedError, + let reason = localizedError.failureReason { + Text(reason) + } else { + Text("An unknown error occurred.") + } + } actions: { + Button { + viewUpdate.refresh() + } label: { + Label { + Text("Retry", bundle: .module) + } icon: { + Image(systemName: "arrow.clockwise") + .accessibilityHidden(true) + } + } + } + } else if events.isEmpty { + ContentUnavailableView { + Label { + Text("No Events Today", bundle: .module) + } icon: { + Image(systemName: "pencil.and.list.clipboard") + .accessibilityHidden(true) + } + } description: { + Text("There are no events scheduled for today.") + } + } else { + List { + Section { + Text("Today") + .foregroundStyle(.secondary) + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .font(.title) + .fontDesign(.rounded) + .fontWeight(.bold) + } + + + ForEach(events) { event in + Section { + eventTile(event) + } + } + } +#if !os(macOS) + .listSectionSpacing(.compact) +#endif + } + } + + /// Create a new today list. + /// - Parameter content: A closure that is called to display each event occurring today. + public init(@ViewBuilder content: @escaping (Event) -> Tile) { + self.eventTile = content + } +} diff --git a/Tests/SpeziSchedulerTests/NotificationsTests.swift b/Tests/SpeziSchedulerTests/NotificationsTests.swift new file mode 100644 index 0000000..3946a07 --- /dev/null +++ b/Tests/SpeziSchedulerTests/NotificationsTests.swift @@ -0,0 +1,18 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@testable import SpeziScheduler +import XCTest + + +final class NotificationsTests: XCTestCase { + func testSharedIdPrefix() { + XCTAssert(SchedulerNotifications.baseTaskNotificationId.starts(with: SchedulerNotifications.baseNotificationId)) + XCTAssert(SchedulerNotifications.baseEventNotificationId.starts(with: SchedulerNotifications.baseNotificationId)) + } +} diff --git a/Tests/SpeziSchedulerTests/ScheduleTests.swift b/Tests/SpeziSchedulerTests/ScheduleTests.swift index 4d0c461..ccf89ea 100644 --- a/Tests/SpeziSchedulerTests/ScheduleTests.swift +++ b/Tests/SpeziSchedulerTests/ScheduleTests.swift @@ -26,6 +26,79 @@ final class ScheduleTests: XCTestCase { XCTAssertNil(iterator.next()) } + func testNextOccurrenceOnceSchedule() throws { + let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25) + let schedule: Schedule = .once(at: startDate, duration: .hours(2)) + + let occurrence = try XCTUnwrap(schedule.nextOccurrence(in: .withTestDate(hour: 9, minute: 3)...)) + try XCTAssertNil(schedule.nextOccurrence(in: .withTestDate(hour: 9, minute: 25)...)) + + try XCTAssertEqual(occurrence.start, .withTestDate(hour: 9, minute: 23, second: 25)) + try XCTAssertEqual(occurrence.end, .withTestDate(hour: 11, minute: 23, second: 25)) + + + let occurrences = try schedule.nextOccurrences(in: .withTestDate(hour: 9, minute: 3)..., count: 2) + continueAfterFailure = false + XCTAssertEqual(occurrences.count, 1) + continueAfterFailure = true + XCTAssertEqual(occurrences[0], occurrence) + } + + func testNextOccurrence() throws { + let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25) + let schedule: Schedule = .daily(hour: 12, minute: 35, startingAt: startDate, end: .afterOccurrences(3)) + + let occurrence = try XCTUnwrap(schedule.nextOccurrence(in: .withTestDate(hour: 9, minute: 3)...)) + try XCTAssertNil(schedule.nextOccurrence(in: .withTestDate(day: 26, hour: 14, minute: 0)...)) + + try XCTAssertEqual(occurrence.start, .withTestDate(hour: 12, minute: 35)) + + + let occurrences = try schedule.nextOccurrences(in: .withTestDate(hour: 9, minute: 3)..., count: 2) + continueAfterFailure = false + XCTAssertEqual(occurrences.count, 2) + continueAfterFailure = true + XCTAssertEqual(occurrences[0], occurrence) + + let occurrence1 = occurrences[1] + try XCTAssertEqual(occurrence1.start, .withTestDate(day: 25, hour: 12, minute: 35, second: 0)) + } + + func testNextOccurrenceInfinite() throws { + let clock = ContinuousClock() + + let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25) + let schedule: Schedule = .daily(hour: 12, minute: 35, startingAt: startDate) + + var occurrence: Occurrence! // swiftlint:disable:this implicitly_unwrapped_optional + let duration = try clock.measure { + occurrence = try XCTUnwrap(schedule.nextOccurrence(in: .withTestDate(hour: 9, minute: 3)...)) + } + continueAfterFailure = false + XCTAssertNotNil(occurrence) + continueAfterFailure = true + + XCTAssert(duration < .milliseconds(10), "Querying next occurrence took longer than expected: \(duration)") + + try XCTAssertEqual(occurrence.start, .withTestDate(hour: 12, minute: 35)) + + + var occurrences: [Occurrence] = [] + let duration2 = try clock.measure { + occurrences = try schedule.nextOccurrences(in: .withTestDate(hour: 9, minute: 3)..., count: 2) + } + + XCTAssert(duration2 < .milliseconds(10), "Query next occurrences took longer than expected: \(duration2)") + + continueAfterFailure = false + XCTAssertEqual(occurrences.count, 2) + continueAfterFailure = true + XCTAssertEqual(occurrences[0], occurrence) + + let occurrence1 = occurrences[1] + try XCTAssertEqual(occurrence1.start, .withTestDate(day: 25, hour: 12, minute: 35, second: 0)) + } + func testDailyScheduleWithThreeOccurrences() throws { let startDate: Date = try .withTestDate(hour: 9, minute: 23, second: 25) let schedule: Schedule = .daily(hour: 12, minute: 35, startingAt: startDate, end: .afterOccurrences(3), duration: .minutes(30)) diff --git a/Tests/SpeziSchedulerUITests/SpeziSchedulerUITests.swift b/Tests/SpeziSchedulerUITests/SpeziSchedulerUITests.swift index 99dc20c..341aa83 100644 --- a/Tests/SpeziSchedulerUITests/SpeziSchedulerUITests.swift +++ b/Tests/SpeziSchedulerUITests/SpeziSchedulerUITests.swift @@ -9,7 +9,7 @@ import SnapshotTesting import SpeziScheduler @_spi(TestingSupport) -import SpeziSchedulerUI +@testable import SpeziSchedulerUI import SwiftUI import XCTest @@ -20,8 +20,11 @@ final class SpeziSchedulerUITests: XCTestCase { let event = SchedulerSampleData.makeTestEvent() let leadingTileHeader = DefaultTileHeader(event, alignment: .leading) + .disableCategoryDefaultAppearances() let centerTileHeader = DefaultTileHeader(event, alignment: .center) + .disableCategoryDefaultAppearances() let trailingTileHeader = DefaultTileHeader(event, alignment: .trailing) + .disableCategoryDefaultAppearances() #if os(iOS) assertSnapshot(of: leadingTileHeader, as: .image(layout: .device(config: .iPhone13Pro)), named: "leading") @@ -53,22 +56,29 @@ final class SpeziSchedulerUITests: XCTestCase { let event = SchedulerSampleData.makeTestEvent() let tileLeading = InstructionsTile(event, alignment: .leading) + .disableCategoryDefaultAppearances() let tileCenter = InstructionsTile(event, alignment: .center) + .disableCategoryDefaultAppearances() let tileTrailing = InstructionsTile(event, alignment: .trailing) + .disableCategoryDefaultAppearances() let tileLeadingMore = InstructionsTile(event, alignment: .leading, more: { Text("More Information") }) + .disableCategoryDefaultAppearances() let tileCenterMore = InstructionsTile(event, alignment: .center, more: { Text("More Information") }) + .disableCategoryDefaultAppearances() let tileTrailingMore = InstructionsTile(event, alignment: .trailing, more: { Text("More Information") }) + .disableCategoryDefaultAppearances() let tileWithAction = InstructionsTile(event) { print("Action was pressed") } + .disableCategoryDefaultAppearances() #if os(iOS) assertSnapshot(of: tileLeading, as: .image(layout: .device(config: .iPhone13Pro)), named: "leading") diff --git a/Tests/UITests/TestApp/QuestionnaireEventDetailView.swift b/Tests/UITests/TestApp/EventDetailView.swift similarity index 97% rename from Tests/UITests/TestApp/QuestionnaireEventDetailView.swift rename to Tests/UITests/TestApp/EventDetailView.swift index 37292b4..faff536 100644 --- a/Tests/UITests/TestApp/QuestionnaireEventDetailView.swift +++ b/Tests/UITests/TestApp/EventDetailView.swift @@ -12,7 +12,7 @@ import SpeziViews import SwiftUI -struct QuestionnaireEventDetailView: View { +struct EventDetailView: View { private let event: Event @Environment(\.dismiss) diff --git a/Tests/UITests/TestApp/Info.plist b/Tests/UITests/TestApp/Info.plist new file mode 100644 index 0000000..deb6415 --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist @@ -0,0 +1,15 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + edu.stanford.spezi.scheduler.notifications-scheduling + + UIBackgroundModes + + processing + fetch + + + diff --git a/Tests/UITests/TestApp/Info.plist.license b/Tests/UITests/TestApp/Info.plist.license new file mode 100644 index 0000000..a648e99 --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Tests/UITests/TestApp/NotificationsView.swift b/Tests/UITests/TestApp/NotificationsView.swift new file mode 100644 index 0000000..5b0fed0 --- /dev/null +++ b/Tests/UITests/TestApp/NotificationsView.swift @@ -0,0 +1,56 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OSLog +import Spezi +import SpeziScheduler +import SpeziViews +import SwiftUI +import XCTSpeziNotificationsUI + + +struct NotificationsView: View { + private let logger = Logger(subsystem: "edu.stanford.spezi.TestApp", category: "NotificationsView") + + @Environment(\.notificationSettings) + private var notificationSettings + @Environment(\.requestNotificationAuthorization) + private var requestNotificationAuthorization + + @Environment(Scheduler.self) + private var scheduler + + @State private var requestAuthorization = false + @State private var viewState: ViewState = .idle + + var body: some View { + NavigationStack { + PendingNotificationsList() + .toolbar { + if requestAuthorization { + AsyncButton(state: $viewState) { + try await requestNotificationAuthorization(options: [.alert, .sound, .badge]) + await queryAuthorization() + scheduler.manuallyScheduleNotificationRefresh() + } label: { + Label("Request Notification Authorization", systemImage: "alarm.waves.left.and.right.fill") + } + } + } + } + .task { + await queryAuthorization() + } + } + + private func queryAuthorization() async { + let status = await notificationSettings().authorizationStatus + requestAuthorization = status != .authorized && status != .denied + logger.debug("Notification authorization is now \(status.description)") + } +} diff --git a/Tests/UITests/TestApp/ScheduleView.swift b/Tests/UITests/TestApp/ScheduleView.swift index d6a18df..23b733a 100644 --- a/Tests/UITests/TestApp/ScheduleView.swift +++ b/Tests/UITests/TestApp/ScheduleView.swift @@ -34,10 +34,16 @@ struct ScheduleView: View { @Bindable var model = model NavigationStack { EventScheduleList(date: date) { event in - InstructionsTile(event, alignment: alignment) { - event.complete() - } more: { - QuestionnaireEventDetailView(event) + if event.task.id == TaskIdentifier.socialSupportQuestionnaire { + InstructionsTile(event, alignment: alignment) { + event.complete() + } more: { + EventDetailView(event) + } + } else { + InstructionsTile(event, more: { + EventDetailView(event) + }) } } .navigationTitle("Schedule") diff --git a/Tests/UITests/TestApp/TestApp.entitlements b/Tests/UITests/TestApp/TestApp.entitlements new file mode 100644 index 0000000..1ca50cc --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.usernotifications.time-sensitive + + + diff --git a/Tests/UITests/TestApp/TestApp.entitlements.license b/Tests/UITests/TestApp/TestApp.entitlements.license new file mode 100644 index 0000000..a648e99 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index ae61bde..45b7a64 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -18,7 +18,15 @@ struct UITestsApp: App { var body: some Scene { WindowGroup { - ScheduleView() + TabView { + Tab("Schedule", systemImage: "list.clipboard.fill") { + ScheduleView() + } + + Tab("Notifications", systemImage: "mail.fill") { + NotificationsView() + } + } .spezi(appDelegate) } } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index f00f048..147e564 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -14,6 +14,7 @@ class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { Scheduler() + SchedulerNotifications() TestAppScheduler() } } diff --git a/Tests/UITests/TestApp/TestAppScheduler.swift b/Tests/UITests/TestApp/TestAppScheduler.swift index 4588c6a..d4f198e 100644 --- a/Tests/UITests/TestApp/TestAppScheduler.swift +++ b/Tests/UITests/TestApp/TestAppScheduler.swift @@ -20,22 +20,22 @@ final class SchedulerModel { nonisolated init() {} } -struct TaskCategoryAppearances: ViewModifier { - nonisolated init() {} - func body(content: Content) -> some View { - content - .taskCategoryAppearance(for: .questionnaire, label: "Questionnaire", image: .system("list.clipboard.fill")) - } +enum TaskIdentifier { + static let socialSupportQuestionnaire = "test-task" + static let testMeasurement = "test-measurement" + static let testMedication = "test-medication" } final class TestAppScheduler: Module { + @Application(\.logger) + private var logger + @Dependency(Scheduler.self) private var scheduler @Model private var model = SchedulerModel() - @Modifier private var modifier = TaskCategoryAppearances() init() {} @@ -43,7 +43,7 @@ final class TestAppScheduler: Module { func configure() { do { try scheduler.createOrUpdateTask( - id: "test-task", + id: TaskIdentifier.socialSupportQuestionnaire, title: "Social Support Questionnaire", instructions: "Please fill out the Social Support Questionnaire every day.", category: .questionnaire, @@ -56,11 +56,49 @@ final class TestAppScheduler: Module { and overall well-being. """ } + + let now = Date.now + let time = notificationTestTime(for: now, adding: .seconds(40)) + + try scheduler.createOrUpdateTask( + id: TaskIdentifier.testMeasurement, + title: "Weight Measurement", + instructions: "Take a weight measurement every day.", + category: .measurement, + schedule: .daily(hour: time.hour, minute: time.minute, second: time.second, startingAt: now), + scheduleNotifications: true + ) { context in + context.about = "Take a measurement with your nearby bluetooth scale while the app is running in foreground." + } + + let nextWeek: Date = .nextWeek + + try scheduler.createOrUpdateTask( + id: TaskIdentifier.testMedication, + title: "Medication", + instructions: "Take your medication", + category: .medication, + schedule: .daily(hour: time.hour, minute: time.minute, second: time.second, startingAt: nextWeek), + scheduleNotifications: true // a daily task that starts next week requires event-level notification scheduling + ) } catch { + logger.error("Failed to scheduled TestApp tasks: \(error)") model.viewState = .error(AnyLocalizedError( error: error, defaultErrorDescription: "Failed to configure or update tasks." )) } } + + private func notificationTestTime(for date: Date, adding duration: Duration) -> NotificationTime { + let now = date.addingTimeInterval(Double(duration.components.seconds)) + let components = Calendar.current.dateComponents([.hour, .minute, .second], from: now) + guard let hour = components.hour, + let minute = components.minute, + let second = components.second else { + preconditionFailure("Consistency error") + } + + return NotificationTime(hour: hour, minute: minute, second: second) + } } diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index f3dd3e9..5d5d454 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -8,6 +8,7 @@ import XCTest import XCTestExtensions +import XCTSpeziNotifications class TestAppUITests: XCTestCase { @@ -31,7 +32,7 @@ class TestAppUITests: XCTestCase { XCTAssert(app.staticTexts["4:00 PM"].exists) XCTAssert(app.buttons["More Information"].exists) - app.buttons["More Information"].tap() + app.buttons["More Information"].firstMatch.tap() XCTAssertTrue(app.navigationBars.staticTexts["More Information"].waitForExistence(timeout: 4.0)) XCTAssertTrue(app.staticTexts["Instructions"].exists) @@ -47,4 +48,73 @@ class TestAppUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Completed"].waitForExistence(timeout: 2.0)) } + + @MainActor + func testNotificationScheduling() { + let app = XCUIApplication() + app.deleteAndLaunch(withSpringboardAppName: "TestApp") + + XCTAssert(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssert(app.tabBars.buttons["Notifications"].waitForExistence(timeout: 2.0)) + app.tabBars.buttons["Notifications"].tap() + + XCTAssert(app.staticTexts["Pending Notifications"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.navigationBars.buttons["Request Notification Authorization"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Weight Measurement"].exists, "It seems that provisional notification authorization didn't work.") + + app.navigationBars.buttons["Request Notification Authorization"].tap() + + app.confirmNotificationAuthorization() + + XCTAssert(app.staticTexts.matching(identifier: "Medication").count > 5) // ensure events are scheduled + + app.staticTexts["Weight Measurement"].tap() + + XCTAssert(app.navigationBars.staticTexts["Weight Measurement"].waitForExistence(timeout: 2.0)) + app.swipeUp() + + app.assertNotificationDetails( + identifier: "edu.stanford.spezi.scheduler.notification.task.test-measurement", + title: "Weight Measurement", + body: "Take a weight measurement every day.", + category: "edu.stanford.spezi.scheduler.notification.category.measurement", + thread: "edu.stanford.spezi.scheduler.notification", + sound: true, + interruption: .timeSensitive, + type: "Calendar", + nextTrigger: "in 10 seconds", + nextTriggerExistenceTimeout: 60 + ) + + + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let notification = springboard.otherElements["Notification"].descendants(matching: .any)["NotificationShortLookView"] + XCTAssert(notification.waitForExistence(timeout: 30)) + XCTAssert(notification.staticTexts["Weight Measurement"].exists) + XCTAssert(notification.staticTexts["Take a weight measurement every day."].exists) + notification.tap() + + XCTAssert(app.navigationBars.buttons["Pending Notifications"].waitForExistence(timeout: 2.0)) + app.navigationBars.buttons["Pending Notifications"].tap() + + XCTAssert(app.staticTexts["Medication"].firstMatch.waitForExistence(timeout: 2.0)) + app.staticTexts["Medication"].firstMatch.tap() + + XCTAssert(app.navigationBars.staticTexts["Medication"].waitForExistence(timeout: 2.0)) + app.swipeUp() + + app.assertNotificationDetails( + title: "Medication", + body: "Take your medication", + category: "edu.stanford.spezi.scheduler.notification.category.medication", + thread: "edu.stanford.spezi.scheduler.notification", + sound: true, + interruption: .timeSensitive, + type: "Interval", + nextTrigger: "in 1 week", + nextTriggerExistenceTimeout: 60 + ) + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 1d8da74..1bf6e58 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -14,10 +14,13 @@ 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; 2FE0B6E72A14C65900818AE9 /* SpeziScheduler in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE0B6E62A14C65900818AE9 /* SpeziScheduler */; }; 2FE0B6EA2A14D82600818AE9 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE0B6E92A14D82600818AE9 /* XCTestExtensions */; }; + A926A5352C9D87B100C92F94 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A926A5342C9D87B100C92F94 /* NotificationsView.swift */; }; A977F6732C92F4C00071A1D1 /* SpeziSchedulerUI in Frameworks */ = {isa = PBXBuildFile; productRef = A977F6722C92F4C00071A1D1 /* SpeziSchedulerUI */; }; A98B09C42C90913F0076E99A /* TestAppScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B09C32C9091390076E99A /* TestAppScheduler.swift */; }; A98B09C62C90B02B0076E99A /* Task+About.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B09C52C90B0260076E99A /* Task+About.swift */; }; - A98B09C82C90C8E40076E99A /* QuestionnaireEventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B09C72C90C8E30076E99A /* QuestionnaireEventDetailView.swift */; }; + A98B09C82C90C8E40076E99A /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98B09C72C90C8E30076E99A /* EventDetailView.swift */; }; + A9947BEA2CC128FA0068AA8A /* XCTSpeziNotificationsUI in Frameworks */ = {isa = PBXBuildFile; productRef = A9947BE92CC128FA0068AA8A /* XCTSpeziNotificationsUI */; }; + A9947BEC2CC12DA70068AA8A /* XCTSpeziNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = A9947BEB2CC12DA70068AA8A /* XCTSpeziNotifications */; }; A9C2951D2C899FA10038EF1B /* ScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C2951C2C899FA10038EF1B /* ScheduleView.swift */; }; /* End PBXBuildFile section */ @@ -40,9 +43,12 @@ 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 2FE0B6E52A14C64E00818AE9 /* SpeziScheduler */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziScheduler; path = ../..; sourceTree = ""; }; + A926A5342C9D87B100C92F94 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; + A960461B2C9840E800EA8022 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + A960461C2C9853C700EA8022 /* TestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestApp.entitlements; sourceTree = ""; }; A98B09C32C9091390076E99A /* TestAppScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppScheduler.swift; sourceTree = ""; }; A98B09C52C90B0260076E99A /* Task+About.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+About.swift"; sourceTree = ""; }; - A98B09C72C90C8E30076E99A /* QuestionnaireEventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionnaireEventDetailView.swift; sourceTree = ""; }; + A98B09C72C90C8E30076E99A /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; A9C2951C2C899FA10038EF1B /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -52,6 +58,7 @@ buildActionMask = 2147483647; files = ( 2F68C3C8292EA52000B3E12C /* Spezi in Frameworks */, + A9947BEA2CC128FA0068AA8A /* XCTSpeziNotificationsUI in Frameworks */, A977F6732C92F4C00071A1D1 /* SpeziSchedulerUI in Frameworks */, 2FE0B6E72A14C65900818AE9 /* SpeziScheduler in Frameworks */, ); @@ -62,6 +69,7 @@ buildActionMask = 2147483647; files = ( 2FE0B6EA2A14D82600818AE9 /* XCTestExtensions in Frameworks */, + A9947BEC2CC12DA70068AA8A /* XCTSpeziNotifications in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,13 +100,16 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( - 2F6D139928F5F386007C25D6 /* Assets.xcassets */, - A98B09C72C90C8E30076E99A /* QuestionnaireEventDetailView.swift */, + A960461C2C9853C700EA8022 /* TestApp.entitlements */, + A960461B2C9840E800EA8022 /* Info.plist */, + A926A5342C9D87B100C92F94 /* NotificationsView.swift */, + A98B09C72C90C8E30076E99A /* EventDetailView.swift */, A9C2951C2C899FA10038EF1B /* ScheduleView.swift */, A98B09C52C90B0260076E99A /* Task+About.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2F9F4D8429B80A1500ABE259 /* TestAppDelegate.swift */, A98B09C32C9091390076E99A /* TestAppScheduler.swift */, + 2F6D139928F5F386007C25D6 /* Assets.xcassets */, ); path = TestApp; sourceTree = ""; @@ -138,6 +149,7 @@ 2F68C3C7292EA52000B3E12C /* Spezi */, 2FE0B6E62A14C65900818AE9 /* SpeziScheduler */, A977F6722C92F4C00071A1D1 /* SpeziSchedulerUI */, + A9947BE92CC128FA0068AA8A /* XCTSpeziNotificationsUI */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -159,6 +171,7 @@ name = TestAppUITests; packageProductDependencies = ( 2FE0B6E92A14D82600818AE9 /* XCTestExtensions */, + A9947BEB2CC12DA70068AA8A /* XCTSpeziNotifications */, ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; @@ -193,6 +206,7 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2FE0B6E82A14D82600818AE9 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + A9947BE82CC128FA0068AA8A /* XCRemoteSwiftPackageReference "SpeziNotifications" */, ); preferredProjectObjectVersion = 77; productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; @@ -230,10 +244,11 @@ files = ( 2F9F4D8529B80A1500ABE259 /* TestAppDelegate.swift in Sources */, A98B09C62C90B02B0076E99A /* Task+About.swift in Sources */, - A98B09C82C90C8E40076E99A /* QuestionnaireEventDetailView.swift in Sources */, + A98B09C82C90C8E40076E99A /* EventDetailView.swift in Sources */, A98B09C42C90913F0076E99A /* TestAppScheduler.swift in Sources */, A9C2951D2C899FA10038EF1B /* ScheduleView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, + A926A5352C9D87B100C92F94 /* NotificationsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -317,6 +332,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -375,6 +391,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -385,6 +402,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -392,6 +410,7 @@ ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -421,6 +440,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -428,6 +448,7 @@ ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -543,6 +564,14 @@ minimumVersion = 1.0.0; }; }; + A9947BE82CC128FA0068AA8A /* XCRemoteSwiftPackageReference "SpeziNotifications" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordSpezi/SpeziNotifications"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -563,6 +592,16 @@ isa = XCSwiftPackageProductDependency; productName = SpeziSchedulerUI; }; + A9947BE92CC128FA0068AA8A /* XCTSpeziNotificationsUI */ = { + isa = XCSwiftPackageProductDependency; + package = A9947BE82CC128FA0068AA8A /* XCRemoteSwiftPackageReference "SpeziNotifications" */; + productName = XCTSpeziNotificationsUI; + }; + A9947BEB2CC12DA70068AA8A /* XCTSpeziNotifications */ = { + isa = XCSwiftPackageProductDependency; + package = A9947BE82CC128FA0068AA8A /* XCRemoteSwiftPackageReference "SpeziNotifications" */; + productName = XCTSpeziNotifications; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */;