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 { let result = await client.listMessages() if let response = result.1 { messages = response.messages unreadCount = messages.filter { $0.status == .unread }.count } } private func startPolling() { client.startPolling(intervalMs: 60000) { updatedMessages in messages = updatedMessages unreadCount = updatedMessages.filter { $0.status == .unread }.count } } private func dismissMessage(_ message: BLInAppMessage) async { _ = await client.markDismissed(message.id) messages.removeAll { $0.id == message.id } } private func handleTap(_ message: BLInAppMessage) async { _ = await client.trackClick(message.id) if let urlString = message.ctaUrl, let url = URL(string: urlString) { await UIApplication.shared.open(url) } _ = await client.markRead(message.id) if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index].status = .read } } } @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) { if let imageUrl = message.imageUrl, let url = URL(string: imageUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) default: Color.gray } } .frame(width: 48, height: 48) .clipShape(RoundedRectangle(cornerRadius: 8)) } VStack(alignment: .leading, spacing: 4) { Text(message.title) .font(.headline) if let body = message.body { Text(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(intervalMs: 30000) { messages in let modalMessages = messages.filter { $0.status == .unread && ($0.style == .modal || $0.style == .fullscreen) } if let first = modalMessages.first, currentMessage == nil { currentMessage = first isPresented = true } } } private func dismissMessage() async { if let message = currentMessage { _ = await client.markDismissed(message.id) } isPresented = false currentMessage = nil } private func handleAction() async { if let message = currentMessage { _ = await client.trackClick(message.id) if let urlString = message.ctaUrl, let url = URL(string: urlString) { await UIApplication.shared.open(url) } _ = await client.markRead(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) { if let imageUrl = message.imageUrl, let url = URL(string: imageUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fit) default: Color.gray } } .frame(maxHeight: 200) .clipShape(RoundedRectangle(cornerRadius: 12)) } Text(message.title) .font(.title2.bold()) if let body = message.body { Text(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 ) } } }