import SwiftUI /** * In-App Message Banner — SwiftUI component for top/bottom banner display. * Part of ByteLystPlatformSDK. */ @available(iOS 15.0, *) public struct BLInAppMessageBanner: View { @ObservedObject var client: BLBroadcastClient @State private var messages: [BLInAppMessage] = [] @State private var unreadCount = 0 let position: BannerPosition public enum BannerPosition { case top, bottom } public init(client: BLBroadcastClient, position: BannerPosition = .top) { self.client = client self.position = position } public var body: some View { VStack(spacing: 8) { ForEach(messages.filter { $0.status == .unread && ($0.style == .banner || $0.style == .toast) }) { message in BannerCard( message: message, onDismiss: { await dismissMessage(message) }, onTap: { await handleTap(message) } ) } } .padding(.horizontal) .padding(position == .top ? .top : .bottom, 8) .task { await loadMessages() startPolling() } } private func loadMessages() async { do { messages = try await client.listMessages() unreadCount = messages.filter { $0.status == .unread }.count } catch { // Silently ignore load errors } } private func startPolling() { client.startPolling(interval: 60) { updatedMessages in self.messages = updatedMessages self.unreadCount = updatedMessages.filter { $0.status == .unread }.count } } private func dismissMessage(_ message: BLInAppMessage) async { try? await client.markDismissed(messageId: message.id) messages.removeAll { $0.id == message.id } } private func handleTap(_ message: BLInAppMessage) async { _ = try? await client.trackClick(messageId: message.id) if let urlString = message.ctaUrl, let url = URL(string: urlString) { await UIApplication.shared.open(url) } try? await client.markRead(messageId: message.id) if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index] = message // refresh from next poll } } } @available(iOS 15.0, *) struct BannerCard: View { let message: BLInAppMessage let onDismiss: () async -> Void let onTap: () async -> Void var body: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(message.title) .font(.headline) if !message.body.isEmpty { Text(message.body) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(2) } if message.ctaText != nil { Text("Tap to open") .font(.caption) .foregroundColor(.blue) } } Spacer() if message.dismissible { Button(action: { Task { await onDismiss() } }) { Image(systemName: "xmark") .foregroundColor(.secondary) .padding(8) } } } .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(backgroundColor) .shadow(radius: 2) ) .contentShape(Rectangle()) .onTapGesture { Task { await onTap() } } } private var backgroundColor: Color { switch message.priority { case .urgent: return Color.red.opacity(0.1) case .high: return Color.orange.opacity(0.1) default: return Color(.systemBackground) } } } @available(iOS 15.0, *) public struct BLBroadcastModal: View { @ObservedObject var client: BLBroadcastClient @State private var currentMessage: BLInAppMessage? @State private var isPresented = false public init(client: BLBroadcastClient) { self.client = client } public var body: some View { EmptyView() .sheet(isPresented: $isPresented) { if let message = currentMessage { ModalContent( message: message, onDismiss: { await dismissMessage() }, onAction: { await handleAction() } ) } } .task { startPolling() } } private func startPolling() { client.startPolling(interval: 30) { messages in let modalMessages = messages.filter { $0.status == .unread && ($0.style == .modal || $0.style == .fullscreen) } if let first = modalMessages.first, self.currentMessage == nil { self.currentMessage = first self.isPresented = true } } } private func dismissMessage() async { if let message = currentMessage { try? await client.markDismissed(messageId: message.id) } isPresented = false currentMessage = nil } private func handleAction() async { if let message = currentMessage { _ = try? await client.trackClick(messageId: message.id) if let urlString = message.ctaUrl, let url = URL(string: urlString) { await UIApplication.shared.open(url) } try? await client.markRead(messageId: message.id) } isPresented = false currentMessage = nil } } @available(iOS 15.0, *) struct ModalContent: View { let message: BLInAppMessage let onDismiss: () async -> Void let onAction: () async -> Void var body: some View { NavigationView { ScrollView { VStack(spacing: 20) { Text(message.title) .font(.title2.bold()) if !message.body.isEmpty { Text(message.body) .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } if message.ctaText != nil { Button(action: { Task { await onAction() } }) { Text(message.ctaText!) .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background(Color.blue) .cornerRadius(12) } } } .padding() } .navigationBarItems( trailing: message.dismissible ? Button("Close") { Task { await onDismiss() } } : nil ) } } }