fix(swift-sdk): add buildRequest, BLPlatformError, fix SDK compile errors for iOS 26
This commit is contained in:
parent
c5e292fe05
commit
96866dcaf6
@ -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>?
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/**
|
||||
* Deep Link Router — Swift
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] = [:]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user