learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift

236 lines
7.3 KiB
Swift

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
)
}
}
}