From 59b023763807afaae1f0fa01cdcbe130eca4ba60 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 5 Jul 2026 13:25:29 +0200 Subject: [PATCH] Extracted the in-app notifications from the Sample app target into its own Notifying package target. --- Apps/Attendi/Models/AppNotification.swift | 73 ------------ Attendi.xcodeproj/project.pbxproj | 2 - Packages/Features/Package.swift | 14 ++- .../Notifying/Models/AppNotification.swift | 55 +++++++++ .../Labels/NotificationLabelStyle.swift | 58 +++++---- .../Notifying/View Models/Notifier.swift | 65 +++++++++++ Packages/Features/Tests/Features.xctestplan | 7 ++ .../Notifying/View Models/NotifierTests.swift | 110 ++++++++++++++++++ 8 files changed, 278 insertions(+), 106 deletions(-) delete mode 100644 Apps/Attendi/Models/AppNotification.swift create mode 100644 Packages/Features/Sources/Notifying/Models/AppNotification.swift rename {Apps => Packages/Features/Sources/Notifying}/Styles/Labels/NotificationLabelStyle.swift (67%) create mode 100644 Packages/Features/Sources/Notifying/View Models/Notifier.swift create mode 100644 Packages/Features/Tests/Notifying/View Models/NotifierTests.swift diff --git a/Apps/Attendi/Models/AppNotification.swift b/Apps/Attendi/Models/AppNotification.swift deleted file mode 100644 index 60186b0..0000000 --- a/Apps/Attendi/Models/AppNotification.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation - -/// An in-app notification reporting an event of a speech model asset download. -struct AppNotification: Equatable, Identifiable { - - // MARK: Properties - - /// The unique identifier of the notification. - let id: UUID - - /// The download event the notification reports. - let event: Event - - /// The localized name of the locale whose speech model the event is about. - let localeName: String - - // MARK: Initializers - - /// Creates a notification for a given download event and locale name. - /// - /// - Parameters: - /// - id: The unique identifier of the notification. Defaults to a newly generated identifier. - /// - event: The download event the notification reports. - /// - localeName: The localized name of the locale whose speech model the event is about. - init( - id: UUID = .init(), - event: Event, - localeName: String - ) { - self.id = id - self.event = event - self.localeName = localeName - } - -} - -// MARK: - Enumerations - -extension AppNotification { - /// The download event the notification reports. - enum Event: Sendable { - /// The download was cancelled before it finished. - case cancelled - /// The download failed. - case failed - /// The download started. - case started - } -} - -// MARK: - Properties - -extension AppNotification { - - /// The system symbol of the icon matching the notification's event. - var imageSymbol: String { - switch event { - case .cancelled: "xmark.circle" - case .failed: "exclamationmark.triangle" - case .started: "arrow.down.circle" - } - } - - /// The localized message of the notification, naming the locale of the speech model, from the app's string catalog. - var textMessage: LocalizedStringResource { - switch event { - case .cancelled: .viewRecordingNotificationDownloadCancelled(localeName) - case .failed: .viewRecordingNotificationDownloadFailed(localeName) - case .started: .viewRecordingNotificationDownloadStarted(localeName) - } - } - -} diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index fcf654f..ad00b7e 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -24,10 +24,8 @@ Attendi/App/AttendiApp.swift, Attendi/Catalogs/Assets.xcassets, Attendi/Catalogs/Localizable.xcstrings, - Attendi/Models/AppNotification.swift, "Attendi/View Models/ContentViewModel.swift", Attendi/Views/ContentView.swift, - Styles/Labels/NotificationLabelStyle.swift, ); target = 02870A0C2FF7EB610079EA3A /* Attendi */; }; diff --git a/Packages/Features/Package.swift b/Packages/Features/Package.swift index af8387a..c682b29 100644 --- a/Packages/Features/Package.swift +++ b/Packages/Features/Package.swift @@ -13,14 +13,26 @@ let package = Package( products: [ .library( name: "Features", - targets: ["Recording"] + targets: [ + "Notifying", + "Recording", + ] ), ], targets: [ + .target( + name: "Notifying", + path: "Sources/Notifying" + ), .target( name: "Recording", path: "Sources/Recording" ), + .testTarget( + name: "NotifyingTests", + dependencies: ["Notifying"], + path: "Tests/Notifying" + ), .testTarget( name: "RecordingTests", dependencies: ["Recording"], diff --git a/Packages/Features/Sources/Notifying/Models/AppNotification.swift b/Packages/Features/Sources/Notifying/Models/AppNotification.swift new file mode 100644 index 0000000..b5a660b --- /dev/null +++ b/Packages/Features/Sources/Notifying/Models/AppNotification.swift @@ -0,0 +1,55 @@ +import Foundation + +/// A transient in-app notification, posted and dismissed by a ``Notifier``. +public struct AppNotification: Equatable, Identifiable, Sendable { + + // MARK: Properties + + /// The unique identifier of the notification. + public let id: UUID + + /// The kind of the notification, driving its visual weight. + public let kind: Kind + + /// The localized message of the notification. + public let message: String + + /// The system symbol of the notification's icon. + public let symbol: String + + // MARK: Initializers + + /// Creates a notification of a given kind, with a localized message and a system symbol. + /// + /// - Parameters: + /// - id: The unique identifier of the notification. Defaults to a newly generated identifier. + /// - kind: The kind of the notification. + /// - message: The localized message of the notification. + /// - symbol: The system symbol of the notification's icon. + public init( + id: UUID = .init(), + kind: Kind, + message: String, + symbol: String + ) { + self.id = id + self.kind = kind + self.message = message + self.symbol = symbol + } + +} + +// MARK: - Enumerations + +extension AppNotification { + /// The kind of a notification, driving its visual weight. + public enum Kind: Sendable { + /// The notification reports an error. + case error + /// The notification reports an informational event. + case info + /// The notification reports a warning. + case warning + } +} diff --git a/Apps/Styles/Labels/NotificationLabelStyle.swift b/Packages/Features/Sources/Notifying/Styles/Labels/NotificationLabelStyle.swift similarity index 67% rename from Apps/Styles/Labels/NotificationLabelStyle.swift rename to Packages/Features/Sources/Notifying/Styles/Labels/NotificationLabelStyle.swift index 2ac365f..b92e445 100644 --- a/Apps/Styles/Labels/NotificationLabelStyle.swift +++ b/Packages/Features/Sources/Notifying/Styles/Labels/NotificationLabelStyle.swift @@ -3,24 +3,24 @@ import SwiftUI /// The label style for the transient in-app notification banners. /// /// The style renders the label's icon next to its title, over a padded, capsule Liquid Glass background. The icon is tinted with a color -/// matching the download event given at initialization — green for a started download, yellow for a cancelled one, and red for a -/// failed one — and the title wears a medium-weight callout font. -struct NotificationLabelStyle: LabelStyle { +/// matching the kind of notification given at initialization — green for an informational one, yellow for a warning, and red for an +/// error — and the title wears a medium-weight callout font. +public struct NotificationLabelStyle: LabelStyle { // MARK: Properties - /// The download event whose matching color tints the label's icon. - private let event: AppNotification.Event + /// The kind of notification whose matching color tints the label's icon. + private let kind: AppNotification.Kind // MARK: Initializers - /// Creates a notification label style for a given download event. + /// Creates a notification label style for a given kind of notification. /// - /// - Parameter event: The download event whose matching color tints the label's icon. - fileprivate init( - event: AppNotification.Event + /// - Parameter kind: The kind of notification whose matching color tints the label's icon. + public init( + kind: AppNotification.Kind ) { - self.event = event + self.kind = kind } // MARK: Methods @@ -36,7 +36,7 @@ struct NotificationLabelStyle: LabelStyle { ) { configuration.icon .font(.title3) - .foregroundStyle(event.imageStyle) + .foregroundStyle(kind.imageStyle) configuration.title .font(.callout) @@ -55,36 +55,35 @@ struct NotificationLabelStyle: LabelStyle { // MARK: - Styles -extension LabelStyle where Self == NotificationLabelStyle { +public extension LabelStyle where Self == NotificationLabelStyle { /// The label style for the transient in-app notification banners. /// - /// - Parameter event: The download event whose matching color tints the label's icon. - /// - Returns: A notification label style for the given event. + /// - Parameter kind: The kind of notification whose matching color tints the label's icon. + /// - Returns: A notification label style for the given kind of notification. static func notification( - event: AppNotification.Event + kind: AppNotification.Kind ) -> NotificationLabelStyle { - .init(event: event) + .init(kind: kind) } } -// MARK: - AppNotification.Event+Properties +// MARK: - AppNotification.Kind+Properties -private extension AppNotification.Event { - - /// The color that tints the icon of a notification label: green for a started download, yellow for a cancelled one, and red - /// for a failed one. +private extension AppNotification.Kind { + + /// The color that tints the icon of a notification label: green for an informational notification, yellow for a warning, and red + /// for an error. var imageStyle: Color { switch self { - case .cancelled: .yellow - case .failed: .red - case .started: .green + case .error: .red + case .info: .green + case .warning: .yellow } } - -} +} // MARK: - Constants @@ -120,7 +119,7 @@ private enum Constant { Image(systemName: "arrow.down.circle") } .labelStyle(.notification( - event: .started + kind: .info )) Label { @@ -129,7 +128,7 @@ private enum Constant { Image(systemName: "xmark.circle") } .labelStyle(.notification( - event: .cancelled + kind: .warning )) Label { @@ -138,8 +137,7 @@ private enum Constant { Image(systemName: "exclamationmark.triangle") } .labelStyle(.notification( - event: .failed + kind: .error )) } - } diff --git a/Packages/Features/Sources/Notifying/View Models/Notifier.swift b/Packages/Features/Sources/Notifying/View Models/Notifier.swift new file mode 100644 index 0000000..fa7d49d --- /dev/null +++ b/Packages/Features/Sources/Notifying/View Models/Notifier.swift @@ -0,0 +1,65 @@ +import Accessibility +import Foundation +import Observation + +/// The observable model that owns the lifecycle of the transient in-app notifications. +/// +/// Posting a notification appends it to ``notifications``, announces its message to assistive technologies — since the visual +/// banners are transient — and schedules its automatic dismissal a few seconds later, which is the only way a notification is +/// dismissed. +@MainActor +@Observable +public final class Notifier { + + // MARK: Properties + + /// The notifications currently presented, in post order. + public private(set) var notifications: [AppNotification] = [] + + /// The time a posted notification stays visible before its automatic dismissal. + @ObservationIgnored + private let delayDismissal: Duration + + // MARK: Initializers + + /// Creates a notifier. + /// + /// - Parameter delayDismissal: The time a posted notification stays visible before its automatic dismissal. + /// Defaults to four seconds. + public init( + delayDismissal: Duration = .seconds(4) + ) { + self.delayDismissal = delayDismissal + } + + // MARK: Methods + + /// Posts a notification, announcing its message to assistive technologies and scheduling its automatic dismissal. + /// + /// - Parameters: + /// - kind: The kind of the notification. + /// - message: The localized message of the notification. + /// - symbol: The system symbol of the notification's icon. + public func post( + _ kind: AppNotification.Kind, + message: String, + symbol: String + ) { + let notification = AppNotification( + kind: kind, + message: message, + symbol: symbol + ) + + notifications.append(notification) + + AccessibilityNotification.Announcement(message).post() + + Task { + try? await Task.sleep(for: delayDismissal) + + notifications.removeAll { $0.id == notification.id } + } + } + +} diff --git a/Packages/Features/Tests/Features.xctestplan b/Packages/Features/Tests/Features.xctestplan index 2087afd..cfe4b62 100644 --- a/Packages/Features/Tests/Features.xctestplan +++ b/Packages/Features/Tests/Features.xctestplan @@ -18,6 +18,13 @@ "identifier" : "RecordingTests", "name" : "RecordingTests" } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "NotifyingTests", + "name" : "NotifyingTests" + } } ], "version" : 1 diff --git a/Packages/Features/Tests/Notifying/View Models/NotifierTests.swift b/Packages/Features/Tests/Notifying/View Models/NotifierTests.swift new file mode 100644 index 0000000..c80e1c1 --- /dev/null +++ b/Packages/Features/Tests/Notifying/View Models/NotifierTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import Notifying + +@Suite("Notifier view model") +struct NotifierTests { + + // MARK: Posting + + @MainActor + @Suite("Posting") + struct Posting { + + @Test func `posting appends a notification with the given values`() { + let notifier = Notifier() + + notifier.post( + .info, + message: "This is a notification.", + symbol: "info.circle" + ) + + #expect(notifier.notifications.count == 1) + #expect(notifier.notifications.first?.kind == .info) + #expect(notifier.notifications.first?.message == "This is a notification.") + #expect(notifier.notifications.first?.symbol == "info.circle") + } + + @Test func `posted notifications stack in post order`() { + let notifier = Notifier() + + notifier.post( + .info, + message: "First", + symbol: "1.circle" + ) + notifier.post( + .warning, + message: "Second", + symbol: "2.circle" + ) + notifier.post( + .error, + message: "Third", + symbol: "3.circle" + ) + + #expect(notifier.notifications.map(\.message) == ["First", "Second", "Third"]) + } + + } + + // MARK: Dismissal + + @MainActor + @Suite("Dismissal") + struct Dismissal { + + @Test func `a notification dismisses itself after the delay`() async throws { + let notifier = Notifier( + delayDismissal: .seconds(0.2) + ) + + notifier.post( + .info, + message: "This is a notification.", + symbol: "info.circle" + ) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(notifier.notifications.count == 1) + + try await Task.sleep(for: .seconds(0.2)) + + #expect(notifier.notifications.isEmpty) + } + + @Test func `notifications dismiss independently`() async throws { + let notifier = Notifier( + delayDismissal: .seconds(0.3) + ) + + notifier.post( + .info, + message: "First", + symbol: "1.circle" + ) + + try await Task.sleep(for: .seconds(0.2)) + + notifier.post( + .warning, + message: "Second", + symbol: "2.circle" + ) + + try await Task.sleep(for: .seconds(0.2)) + + #expect(notifier.notifications.map(\.message) == ["Second"]) + + try await Task.sleep(for: .seconds(0.2)) + + #expect(notifier.notifications.isEmpty) + } + + } + +}