learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift
saravanakumardb1 b472f73c94 feat(platform-sdk): Phase 4.2/4.3 - iOS and Android native UI components
- BLInAppMessageUI.swift: Banner + Modal SwiftUI components
- BLSurveyUI.swift: Survey modal with all 9 question types for iOS
- BroadcastUI.kt: Banner + Modal Jetpack Compose components
- SurveyUI.kt: Survey modal with all 9 question types for Android
2026-03-03 08:20:01 -08:00

261 lines
8.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 {
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
)
}
}
}