236 lines
7.3 KiB
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
|
|
)
|
|
}
|
|
}
|
|
}
|