Extracted the in-app notifications from the Sample app target into its own Notifying package target.

This commit is contained in:
2026-07-05 13:33:07 +02:00
parent 6bcb88c944
commit 59b0237638
8 changed files with 278 additions and 106 deletions
-73
View File
@@ -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)
}
}
}
-2
View File
@@ -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 */;
};
+13 -1
View File
@@ -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"],
@@ -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
}
}
@@ -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
))
}
}
@@ -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 }
}
}
}
@@ -18,6 +18,13 @@
"identifier" : "RecordingTests",
"name" : "RecordingTests"
}
},
{
"target" : {
"containerPath" : "container:",
"identifier" : "NotifyingTests",
"name" : "NotifyingTests"
}
}
],
"version" : 1
@@ -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)
}
}
}