fix(swift-sdk): add buildRequest, BLPlatformError, fix SDK compile errors for iOS 26

This commit is contained in:
saravanakumardb1 2026-03-19 14:22:18 -07:00
parent c5e292fe05
commit 96866dcaf6
6 changed files with 107 additions and 81 deletions

View File

@ -48,7 +48,8 @@ public struct BLInAppMessage: Codable, Sendable, Identifiable {
}
/// Broadcast client for fetching and managing in-app messages.
public actor BLBroadcastClient {
@available(iOS 15.0, macOS 12.0, watchOS 8.0, *)
public class BLBroadcastClient: ObservableObject {
private let platformClient: BLPlatformClient
private var pollTask: Task<Void, Never>?

View File

@ -1,4 +1,5 @@
import Foundation
import os
/**
* Deep Link Router Swift

View File

@ -40,33 +40,34 @@ public struct BLInAppMessageBanner: View {
}
private func loadMessages() async {
let result = await client.listMessages()
if let response = result.1 {
messages = response.messages
do {
messages = try await client.listMessages()
unreadCount = messages.filter { $0.status == .unread }.count
} catch {
// Silently ignore load errors
}
}
private func startPolling() {
client.startPolling(intervalMs: 60000) { updatedMessages in
messages = updatedMessages
unreadCount = updatedMessages.filter { $0.status == .unread }.count
client.startPolling(interval: 60) { updatedMessages in
self.messages = updatedMessages
self.unreadCount = updatedMessages.filter { $0.status == .unread }.count
}
}
private func dismissMessage(_ message: BLInAppMessage) async {
_ = await client.markDismissed(message.id)
try? await client.markDismissed(messageId: message.id)
messages.removeAll { $0.id == message.id }
}
private func handleTap(_ message: BLInAppMessage) async {
_ = await client.trackClick(message.id)
_ = try? await client.trackClick(messageId: message.id)
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
await UIApplication.shared.open(url)
}
_ = await client.markRead(message.id)
try? await client.markRead(messageId: message.id)
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index].status = .read
messages[index] = message // refresh from next poll
}
}
}
@ -79,25 +80,12 @@ struct BannerCard: View {
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)
if !message.body.isEmpty {
Text(message.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
@ -171,20 +159,20 @@ public struct BLBroadcastModal: View {
}
private func startPolling() {
client.startPolling(intervalMs: 30000) { messages in
client.startPolling(interval: 30) { 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
if let first = modalMessages.first, self.currentMessage == nil {
self.currentMessage = first
self.isPresented = true
}
}
}
private func dismissMessage() async {
if let message = currentMessage {
_ = await client.markDismissed(message.id)
try? await client.markDismissed(messageId: message.id)
}
isPresented = false
currentMessage = nil
@ -192,11 +180,11 @@ public struct BLBroadcastModal: View {
private func handleAction() async {
if let message = currentMessage {
_ = await client.trackClick(message.id)
_ = try? await client.trackClick(messageId: message.id)
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
await UIApplication.shared.open(url)
}
_ = await client.markRead(message.id)
try? await client.markRead(messageId: message.id)
}
isPresented = false
currentMessage = nil
@ -213,24 +201,11 @@ struct ModalContent: 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)
if !message.body.isEmpty {
Text(message.body)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)

View File

@ -164,6 +164,35 @@ public final class BLPlatformClient: @unchecked Sendable {
session.dataTask(with: request) { _, _, _ in }.resume()
}
// MARK: - Request Builder
/// Build an authenticated URLRequest (used by BLBroadcastClient, BLSurveyClient).
public func buildRequest(
path: String,
method: String = "GET",
body: [String: Any]? = nil
) throws -> URLRequest {
guard let url = URL(string: "\(config.baseURL)\(path)") else {
throw BLNetworkError.invalidURL(path)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id")
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
return request
}
// MARK: - Encoder/Decoder Access
public var jsonEncoder: JSONEncoder { encoder }
@ -172,6 +201,17 @@ public final class BLPlatformClient: @unchecked Sendable {
// MARK: - Errors
/// Legacy alias used by BLBroadcastClient, BLSurveyClient, BLDeepLinkRouter.
public enum BLPlatformError: LocalizedError {
case requestFailed(String)
public var errorDescription: String? {
switch self {
case .requestFailed(let msg): return msg
}
}
}
public enum BLNetworkError: LocalizedError {
case invalidURL(String)
case invalidResponse

View File

@ -108,7 +108,8 @@ public struct BLSurveyResponse: Codable, Sendable {
}
/// Survey client for managing in-app surveys.
public actor BLSurveyClient {
@available(iOS 15.0, macOS 12.0, watchOS 8.0, *)
public class BLSurveyClient: ObservableObject {
private let platformClient: BLPlatformClient
private var pollTask: Task<Void, Never>?
private var cachedResponses: [String: BLSurveyResponse] = [:]

View File

@ -63,44 +63,49 @@ public struct BLSurveyModal: View {
}
private func checkForSurvey() async {
let result = await client.getActiveSurvey()
if let activeSurvey = result.1 {
survey = activeSurvey
if !isPresented {
isPresented = true
do {
if let activeSurvey = try await client.getActiveSurvey() {
survey = activeSurvey
if !isPresented {
isPresented = true
}
}
} catch {
// Silently ignore
}
}
private func startPolling() {
client.startPolling(intervalMs: 60000) { newSurvey in
if let newSurvey = newSurvey, survey == nil {
survey = newSurvey
isPresented = true
client.startPolling(interval: 60) { newSurvey in
if let newSurvey = newSurvey, self.survey == nil {
self.survey = newSurvey
self.isPresented = true
}
}
}
private func submitAnswer(_ question: BLQuestion) async {
private func submitAnswer(_ question: BLSurveyQuestion) async {
let answer: BLSurveyAnswer
switch question.type {
case .singleChoice, .dropdown:
guard let value = selectedOption else { return }
answer = BLSurveyAnswer(type: "single_choice", value: .string(value))
answer = .singleChoice(optionId: value)
case .multipleChoice:
let values = Array(selectedOptions)
answer = BLSurveyAnswer(type: "multiple_choice", value: .stringArray(values))
case .rating, .scale, .nps:
answer = BLSurveyAnswer(type: "rating", value: .int(ratingValue))
answer = .multipleChoice(optionIds: values)
case .rating, .scale:
answer = .rating(value: ratingValue)
case .nps:
answer = .nps(value: ratingValue)
case .textShort, .textLong:
answer = BLSurveyAnswer(type: "text", value: .string(textAnswer))
answer = .text(value: textAnswer)
case .ranking:
answer = BLSurveyAnswer(type: "ranking", value: .stringArray(rankingOrder))
answer = .ranking(rankedOptionIds: rankingOrder)
}
let result = await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
if let response = result.1 {
do {
let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
currentQuestionIndex = response.currentQuestionIndex
answers = response.answers
resetQuestionState()
@ -108,16 +113,15 @@ public struct BLSurveyModal: View {
if currentQuestionIndex >= survey!.questions.count {
await completeSurvey()
}
} catch {
// Silently ignore submit errors
}
}
private func skipQuestion() async {
let question = survey!.questions[currentQuestionIndex]
if !question.required {
// Submit empty answer to skip
let answer = BLSurveyAnswer(type: "skipped", value: .null)
_ = await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
// Skip by advancing without submitting
currentQuestionIndex += 1
resetQuestionState()
@ -128,17 +132,21 @@ public struct BLSurveyModal: View {
}
private func completeSurvey() async {
let result = await client.completeSurvey(surveyId: survey!.id)
if let completion = result.1, completion.success {
isComplete = true
showCompletion = true
do {
let completion = try await client.completeSurvey(surveyId: survey!.id)
if completion.success {
isComplete = true
showCompletion = true
}
} catch {
// Silently ignore
}
}
private func dismissSurvey() {
if let survey = survey {
Task {
_ = await client.dismissSurvey(surveyId: survey.id)
try? await client.dismissSurvey(surveyId: survey.id)
}
}
isPresented = false
@ -166,7 +174,7 @@ public struct BLSurveyModal: View {
@available(iOS 15.0, *)
struct QuestionView: View {
let survey: BLActiveSurvey
let question: BLQuestion
let question: BLSurveyQuestion
let questionIndex: Int
let totalQuestions: Int
@ -314,7 +322,7 @@ struct QuestionView: View {
@available(iOS 15.0, *)
struct SingleChoiceView: View {
let options: [BLQuestionOption]
let options: [BLSurveyOption]
@Binding var selected: String?
var body: some View {
@ -348,7 +356,7 @@ struct SingleChoiceView: View {
@available(iOS 15.0, *)
struct MultipleChoiceView: View {
let options: [BLQuestionOption]
let options: [BLSurveyOption]
@Binding var selected: Set<String>
var body: some View {
@ -459,7 +467,7 @@ struct TextAnswerView: View {
@available(iOS 15.0, *)
struct RankingView: View {
let options: [BLQuestionOption]
let options: [BLSurveyOption]
@Binding var order: [String]
var body: some View {
@ -555,7 +563,7 @@ struct CompletionView: View {
HStack {
Image(systemName: "gift.fill")
.foregroundColor(.green)
Text("You've earned \(incentive.amount) \(incentive.type == .proDays ? "Pro Days" : "Credits")!")
Text("You've earned \(incentive.amount) \(incentive.type == "pro_days" ? "Pro Days" : "Credits")!")
.font(.headline)
.foregroundColor(.green)
}