From 539170eb4593ed9fee3fe9e290290ac94c3119a8 Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Mon, 12 Jan 2026 18:50:44 +0100 Subject: [PATCH 1/3] InAppNotifications for SwiftUI --- .../SwiftUI_InAppNotifications.swift | 131 ++++++++++++++++++ ...s.swift => UIKit_InAppNotifications.swift} | 0 2 files changed, 131 insertions(+) create mode 100644 Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift rename Sources/BSWInterfaceKit/InAppNotifications/{InAppNotifications.swift => UIKit_InAppNotifications.swift} (100%) diff --git a/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift new file mode 100644 index 00000000..f94b753a --- /dev/null +++ b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift @@ -0,0 +1,131 @@ +// +// Created by Michele Restuccia on 12/1/26. +// + +import SwiftUI + +#if canImport(Darwin) + +// MARK: Previews + +#Preview() { + SampleView() +} + +private struct SampleView: View { + + @State + var message: String? + + @State + var kind: ToastView.Kind = .message + + var body: some View { + VStack(spacing: 16) { + Button("Show Message") { + kind = .message + message = "Forza Milan" + } + Button("Show Error") { + kind = .error + message = "Something went wrong" + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .modifier(ToastView(message: $message, kind: kind)) + } +} + +#endif + +// MARK: Extensions + +public extension View { + + func showNotification(message: Binding) -> some View { + modifier(ToastView(message: message, kind: .message)) + } + + func showNotificationError(message: Binding) -> some View { + modifier(ToastView(message: message, kind: .error)) + } +} + +// MARK: ToastView + +struct ToastView: ViewModifier { + + let message: Binding + + let kind: Kind + enum Kind { + case message, error + + var bgColor: Color { + switch self { + case .message: return .green + case .error: return .red + } + } + } + + @State + var isVisible: Bool = false + + @State + var dismissTask: Task? + + func body(content: Content) -> some View { + ZStack { + content + + if let message = message.wrappedValue { + toastView(message) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .padding(16) + .opacity(isVisible ? 1 : 0) + .offset(y: isVisible ? 0 : -40) + #if canImport(Darwin) + .allowsHitTesting(false) + #endif + .onAppear { scheduleDismiss() } + } + } + } + + // MARK: ViewBuilders + + @ViewBuilder + private func toastView(_ message: String) -> some View { + Text(message) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(kind.bgColor.opacity(0.85)) + ) + .shadow(radius: 8) + } + + private func scheduleDismiss() { + dismissTask?.cancel() + withAnimation(.easeOut(duration: 0.2)) { + isVisible = true + } + dismissTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + withAnimation(.easeIn(duration: 0.2)) { + isVisible = false + } + try? await Task.sleep(for: .seconds(2)) + message.wrappedValue = nil + } + } +} diff --git a/Sources/BSWInterfaceKit/InAppNotifications/InAppNotifications.swift b/Sources/BSWInterfaceKit/InAppNotifications/UIKit_InAppNotifications.swift similarity index 100% rename from Sources/BSWInterfaceKit/InAppNotifications/InAppNotifications.swift rename to Sources/BSWInterfaceKit/InAppNotifications/UIKit_InAppNotifications.swift From af6f2b15db89f0d11e297731482cf2c93cde0a0d Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Tue, 13 Jan 2026 13:49:27 +0100 Subject: [PATCH 2/3] Improve public api --- .../SwiftUI_InAppNotifications.swift | 116 ++++++++---------- 1 file changed, 54 insertions(+), 62 deletions(-) diff --git a/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift index f94b753a..2c600a6e 100644 --- a/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift +++ b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift @@ -13,27 +13,24 @@ import SwiftUI } private struct SampleView: View { - + @State - var message: String? + var event: InAppNotificationEvent? @State - var kind: ToastView.Kind = .message - + var count: Int = 0 + var body: some View { VStack(spacing: 16) { Button("Show Message") { - kind = .message - message = "Forza Milan" + event = .message("Forza Milan") } Button("Show Error") { - kind = .error - message = "Something went wrong" + count += 1 + event = .error("Error #\(count)") } } - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .modifier(ToastView(message: $message, kind: kind)) + .showInAppNotification($event) } } @@ -43,89 +40,84 @@ private struct SampleView: View { public extension View { - func showNotification(message: Binding) -> some View { - modifier(ToastView(message: message, kind: .message)) + func showInAppNotification(_ event: Binding) -> some View { + modifier(ToastView(event: event)) } +} + +// MARK: InAppNotificationEvent + +public struct InAppNotificationEvent: Equatable, Identifiable { + public let id = UUID() + let text: String + let kind: Kind + enum Kind { case message, error } - func showNotificationError(message: Binding) -> some View { - modifier(ToastView(message: message, kind: .error)) + public static func message(_ txt: String) -> InAppNotificationEvent { + .init(text: txt, kind: .message) + } + public static func error(_ txt: String) -> InAppNotificationEvent { + .init(text: txt, kind: .error) } } // MARK: ToastView struct ToastView: ViewModifier { - - let message: Binding - let kind: Kind - enum Kind { - case message, error - - var bgColor: Color { - switch self { - case .message: return .green - case .error: return .red - } - } - } - - @State - var isVisible: Bool = false + @Binding + var event: InAppNotificationEvent? @State - var dismissTask: Task? - + var isVisible = false + func body(content: Content) -> some View { ZStack { content - if let message = message.wrappedValue { - toastView(message) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .top - ) + if let event { + toastView(event) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(16) .opacity(isVisible ? 1 : 0) .offset(y: isVisible ? 0 : -40) #if canImport(Darwin) .allowsHitTesting(false) #endif - .onAppear { scheduleDismiss() } + } + } + .task(id: event?.id) { + guard event != nil else { return } + withAnimation(.easeOut(duration: 0.2)) { + isVisible = true + } + try? await Task.sleep(for: .seconds(2)) + withAnimation(.easeIn(duration: 0.2)) { + isVisible = false } } } - // MARK: ViewBuilders - @ViewBuilder - private func toastView(_ message: String) -> some View { - Text(message) + private func toastView(_ event: InAppNotificationEvent) -> some View { + Text(event.text) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .padding(.horizontal, 16) .padding(.vertical, 8) - .background( - Capsule() - .fill(kind.bgColor.opacity(0.85)) - ) + .background(Capsule().fill(event.kind.bgColor.opacity(0.85))) .shadow(radius: 8) } - - private func scheduleDismiss() { - dismissTask?.cancel() - withAnimation(.easeOut(duration: 0.2)) { - isVisible = true - } - dismissTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) - withAnimation(.easeIn(duration: 0.2)) { - isVisible = false - } - try? await Task.sleep(for: .seconds(2)) - message.wrappedValue = nil +} + +// MARK: Extensions + +private extension InAppNotificationEvent.Kind { + + var bgColor: Color { + switch self { + case .message: return .green + case .error: return .red } } } From 5ef4a19e4dee05b23194ca90193136d8a184155b Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Tue, 13 Jan 2026 15:50:57 +0100 Subject: [PATCH 3/3] Match UIKit functionality --- .../SwiftUI_InAppNotifications.swift | 109 +++++++++++++----- 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift index 2c600a6e..94e4ed9c 100644 --- a/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift +++ b/Sources/BSWInterfaceKit/InAppNotifications/SwiftUI_InAppNotifications.swift @@ -23,7 +23,13 @@ private struct SampleView: View { var body: some View { VStack(spacing: 16) { Button("Show Message") { - event = .message("Forza Milan") + event = .message( + "Forza Milan", + message: "Forza lotta vincerai, non ti lasceremo mai", + icon: Image(systemName: "star.circle.fill"), + backgroundColor: .green, + dismissDelay: 5 + ) } Button("Show Error") { count += 1 @@ -49,15 +55,67 @@ public extension View { public struct InAppNotificationEvent: Equatable, Identifiable { public let id = UUID() - let text: String + let title: String + let message: String? + let icon: Image? + let backgroundColor: Color + let dismissDelay: TimeInterval + let kind: Kind enum Kind { case message, error } - public static func message(_ txt: String) -> InAppNotificationEvent { - .init(text: txt, kind: .message) + /// Creates an in-app notification event to inform the user about + /// a non-critical action or state change. + /// + /// - Parameters: + /// - title: The main title displayed in the notification. + /// - message: An optional message providing additional context. + /// - icon: An optional icon displayed next to the title. + /// - backgroundColor: The background color of the notification. + /// - dismissDelay: The amount of time (in seconds) before the notification is automatically dismissed. + /// - Returns: An in-app notification event configured as a message. + public static func message( + _ title: String, + message: String? = nil, + icon: Image? = nil, + backgroundColor: Color = .green, + dismissDelay: TimeInterval = 2 + ) -> InAppNotificationEvent { + .init( + title: title, + message: message, + icon: icon, + backgroundColor: backgroundColor, + dismissDelay: dismissDelay, + kind: .message + ) } - public static func error(_ txt: String) -> InAppNotificationEvent { - .init(text: txt, kind: .error) + + /// Creates an in-app notification event to inform the user about + /// an error or a failed operation. + /// + /// - Parameters: + /// - title: The main title describing the error. + /// - message: An optional message with additional details. + /// - icon: An optional icon displayed next to the title. + /// - backgroundColor: The background color of the notification. + /// - dismissDelay: The amount of time (in seconds) before the notification is automatically dismissed. + /// - Returns: An in-app notification event configured as an error. + public static func error( + _ title: String, + message: String? = nil, + icon: Image? = nil, + backgroundColor: Color = .red, + dismissDelay: TimeInterval = 2 + ) -> InAppNotificationEvent { + .init( + title: title, + message: message, + icon: icon, + backgroundColor: backgroundColor, + dismissDelay: dismissDelay, + kind: .error + ) } } @@ -87,11 +145,11 @@ struct ToastView: ViewModifier { } } .task(id: event?.id) { - guard event != nil else { return } + guard let event else { return } withAnimation(.easeOut(duration: 0.2)) { isVisible = true } - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: .seconds(event.dismissDelay)) withAnimation(.easeIn(duration: 0.2)) { isVisible = false } @@ -100,24 +158,23 @@ struct ToastView: ViewModifier { @ViewBuilder private func toastView(_ event: InAppNotificationEvent) -> some View { - Text(event.text) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Capsule().fill(event.kind.bgColor.opacity(0.85))) - .shadow(radius: 8) - } -} - -// MARK: Extensions - -private extension InAppNotificationEvent.Kind { - - var bgColor: Color { - switch self { - case .message: return .green - case .error: return .red + VStack(spacing: 4) { + HStack(spacing: 8) { + if let icon = event.icon { + icon + } + Text(event.title) + } + if let message = event.message { + Text(message) + } } + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule().fill(event.backgroundColor.opacity(0.85))) + .shadow(radius: 8) } }