Extracted the in-app notifications from the Sample app target into its own Notifying package target.
This commit is contained in:
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -24,10 +24,8 @@
|
|||||||
Attendi/App/AttendiApp.swift,
|
Attendi/App/AttendiApp.swift,
|
||||||
Attendi/Catalogs/Assets.xcassets,
|
Attendi/Catalogs/Assets.xcassets,
|
||||||
Attendi/Catalogs/Localizable.xcstrings,
|
Attendi/Catalogs/Localizable.xcstrings,
|
||||||
Attendi/Models/AppNotification.swift,
|
|
||||||
"Attendi/View Models/ContentViewModel.swift",
|
"Attendi/View Models/ContentViewModel.swift",
|
||||||
Attendi/Views/ContentView.swift,
|
Attendi/Views/ContentView.swift,
|
||||||
Styles/Labels/NotificationLabelStyle.swift,
|
|
||||||
);
|
);
|
||||||
target = 02870A0C2FF7EB610079EA3A /* Attendi */;
|
target = 02870A0C2FF7EB610079EA3A /* Attendi */;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,14 +13,26 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "Features",
|
name: "Features",
|
||||||
targets: ["Recording"]
|
targets: [
|
||||||
|
"Notifying",
|
||||||
|
"Recording",
|
||||||
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Notifying",
|
||||||
|
path: "Sources/Notifying"
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "Recording",
|
name: "Recording",
|
||||||
path: "Sources/Recording"
|
path: "Sources/Recording"
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "NotifyingTests",
|
||||||
|
dependencies: ["Notifying"],
|
||||||
|
path: "Tests/Notifying"
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "RecordingTests",
|
name: "RecordingTests",
|
||||||
dependencies: ["Recording"],
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-28
@@ -3,24 +3,24 @@ import SwiftUI
|
|||||||
/// The label style for the transient in-app notification banners.
|
/// 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
|
/// 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
|
/// matching the kind of notification given at initialization — green for an informational one, yellow for a warning, and red for an
|
||||||
/// failed one — and the title wears a medium-weight callout font.
|
/// error — and the title wears a medium-weight callout font.
|
||||||
struct NotificationLabelStyle: LabelStyle {
|
public struct NotificationLabelStyle: LabelStyle {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The download event whose matching color tints the label's icon.
|
/// The kind of notification whose matching color tints the label's icon.
|
||||||
private let event: AppNotification.Event
|
private let kind: AppNotification.Kind
|
||||||
|
|
||||||
// MARK: Initializers
|
// 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.
|
/// - Parameter kind: The kind of notification whose matching color tints the label's icon.
|
||||||
fileprivate init(
|
public init(
|
||||||
event: AppNotification.Event
|
kind: AppNotification.Kind
|
||||||
) {
|
) {
|
||||||
self.event = event
|
self.kind = kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
@@ -36,7 +36,7 @@ struct NotificationLabelStyle: LabelStyle {
|
|||||||
) {
|
) {
|
||||||
configuration.icon
|
configuration.icon
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(event.imageStyle)
|
.foregroundStyle(kind.imageStyle)
|
||||||
|
|
||||||
configuration.title
|
configuration.title
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -55,37 +55,36 @@ struct NotificationLabelStyle: LabelStyle {
|
|||||||
|
|
||||||
// MARK: - Styles
|
// MARK: - Styles
|
||||||
|
|
||||||
extension LabelStyle where Self == NotificationLabelStyle {
|
public extension LabelStyle where Self == NotificationLabelStyle {
|
||||||
|
|
||||||
/// The label style for the transient in-app notification banners.
|
/// The label style for the transient in-app notification banners.
|
||||||
///
|
///
|
||||||
/// - Parameter event: The download event whose matching color tints the label's icon.
|
/// - Parameter kind: The kind of notification whose matching color tints the label's icon.
|
||||||
/// - Returns: A notification label style for the given event.
|
/// - Returns: A notification label style for the given kind of notification.
|
||||||
static func notification(
|
static func notification(
|
||||||
event: AppNotification.Event
|
kind: AppNotification.Kind
|
||||||
) -> NotificationLabelStyle {
|
) -> NotificationLabelStyle {
|
||||||
.init(event: event)
|
.init(kind: kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AppNotification.Event+Properties
|
// MARK: - AppNotification.Kind+Properties
|
||||||
|
|
||||||
private extension AppNotification.Event {
|
private extension AppNotification.Kind {
|
||||||
|
|
||||||
/// The color that tints the icon of a notification label: green for a started download, yellow for a cancelled one, and red
|
/// The color that tints the icon of a notification label: green for an informational notification, yellow for a warning, and red
|
||||||
/// for a failed one.
|
/// for an error.
|
||||||
var imageStyle: Color {
|
var imageStyle: Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .cancelled: .yellow
|
case .error: .red
|
||||||
case .failed: .red
|
case .info: .green
|
||||||
case .started: .green
|
case .warning: .yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
/// The constant values used across the label style.
|
/// The constant values used across the label style.
|
||||||
@@ -120,7 +119,7 @@ private enum Constant {
|
|||||||
Image(systemName: "arrow.down.circle")
|
Image(systemName: "arrow.down.circle")
|
||||||
}
|
}
|
||||||
.labelStyle(.notification(
|
.labelStyle(.notification(
|
||||||
event: .started
|
kind: .info
|
||||||
))
|
))
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -129,7 +128,7 @@ private enum Constant {
|
|||||||
Image(systemName: "xmark.circle")
|
Image(systemName: "xmark.circle")
|
||||||
}
|
}
|
||||||
.labelStyle(.notification(
|
.labelStyle(.notification(
|
||||||
event: .cancelled
|
kind: .warning
|
||||||
))
|
))
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -138,8 +137,7 @@ private enum Constant {
|
|||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
}
|
}
|
||||||
.labelStyle(.notification(
|
.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",
|
"identifier" : "RecordingTests",
|
||||||
"name" : "RecordingTests"
|
"name" : "RecordingTests"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "NotifyingTests",
|
||||||
|
"name" : "NotifyingTests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 1
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user