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.
|
/// 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 let platformClient: BLPlatformClient
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep Link Router — Swift
|
* Deep Link Router — Swift
|
||||||
|
|||||||
@ -40,33 +40,34 @@ public struct BLInAppMessageBanner: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadMessages() async {
|
private func loadMessages() async {
|
||||||
let result = await client.listMessages()
|
do {
|
||||||
if let response = result.1 {
|
messages = try await client.listMessages()
|
||||||
messages = response.messages
|
|
||||||
unreadCount = messages.filter { $0.status == .unread }.count
|
unreadCount = messages.filter { $0.status == .unread }.count
|
||||||
|
} catch {
|
||||||
|
// Silently ignore load errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
client.startPolling(intervalMs: 60000) { updatedMessages in
|
client.startPolling(interval: 60) { updatedMessages in
|
||||||
messages = updatedMessages
|
self.messages = updatedMessages
|
||||||
unreadCount = updatedMessages.filter { $0.status == .unread }.count
|
self.unreadCount = updatedMessages.filter { $0.status == .unread }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dismissMessage(_ message: BLInAppMessage) async {
|
private func dismissMessage(_ message: BLInAppMessage) async {
|
||||||
_ = await client.markDismissed(message.id)
|
try? await client.markDismissed(messageId: message.id)
|
||||||
messages.removeAll { $0.id == message.id }
|
messages.removeAll { $0.id == message.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleTap(_ message: BLInAppMessage) async {
|
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) {
|
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
|
||||||
await UIApplication.shared.open(url)
|
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 }) {
|
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 {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(message.title)
|
Text(message.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if let body = message.body {
|
if !message.body.isEmpty {
|
||||||
Text(body)
|
Text(message.body)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@ -171,20 +159,20 @@ public struct BLBroadcastModal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
client.startPolling(intervalMs: 30000) { messages in
|
client.startPolling(interval: 30) { messages in
|
||||||
let modalMessages = messages.filter {
|
let modalMessages = messages.filter {
|
||||||
$0.status == .unread && ($0.style == .modal || $0.style == .fullscreen)
|
$0.status == .unread && ($0.style == .modal || $0.style == .fullscreen)
|
||||||
}
|
}
|
||||||
if let first = modalMessages.first, currentMessage == nil {
|
if let first = modalMessages.first, self.currentMessage == nil {
|
||||||
currentMessage = first
|
self.currentMessage = first
|
||||||
isPresented = true
|
self.isPresented = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dismissMessage() async {
|
private func dismissMessage() async {
|
||||||
if let message = currentMessage {
|
if let message = currentMessage {
|
||||||
_ = await client.markDismissed(message.id)
|
try? await client.markDismissed(messageId: message.id)
|
||||||
}
|
}
|
||||||
isPresented = false
|
isPresented = false
|
||||||
currentMessage = nil
|
currentMessage = nil
|
||||||
@ -192,11 +180,11 @@ public struct BLBroadcastModal: View {
|
|||||||
|
|
||||||
private func handleAction() async {
|
private func handleAction() async {
|
||||||
if let message = currentMessage {
|
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) {
|
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
|
||||||
await UIApplication.shared.open(url)
|
await UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
_ = await client.markRead(message.id)
|
try? await client.markRead(messageId: message.id)
|
||||||
}
|
}
|
||||||
isPresented = false
|
isPresented = false
|
||||||
currentMessage = nil
|
currentMessage = nil
|
||||||
@ -213,24 +201,11 @@ struct ModalContent: View {
|
|||||||
NavigationView {
|
NavigationView {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
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)
|
Text(message.title)
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
|
|
||||||
if let body = message.body {
|
if !message.body.isEmpty {
|
||||||
Text(body)
|
Text(message.body)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@ -164,6 +164,35 @@ public final class BLPlatformClient: @unchecked Sendable {
|
|||||||
session.dataTask(with: request) { _, _, _ in }.resume()
|
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
|
// MARK: - Encoder/Decoder Access
|
||||||
|
|
||||||
public var jsonEncoder: JSONEncoder { encoder }
|
public var jsonEncoder: JSONEncoder { encoder }
|
||||||
@ -172,6 +201,17 @@ public final class BLPlatformClient: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// 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 {
|
public enum BLNetworkError: LocalizedError {
|
||||||
case invalidURL(String)
|
case invalidURL(String)
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
|
|||||||
@ -108,7 +108,8 @@ public struct BLSurveyResponse: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Survey client for managing in-app surveys.
|
/// 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 let platformClient: BLPlatformClient
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
private var cachedResponses: [String: BLSurveyResponse] = [:]
|
private var cachedResponses: [String: BLSurveyResponse] = [:]
|
||||||
|
|||||||
@ -63,44 +63,49 @@ public struct BLSurveyModal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func checkForSurvey() async {
|
private func checkForSurvey() async {
|
||||||
let result = await client.getActiveSurvey()
|
do {
|
||||||
if let activeSurvey = result.1 {
|
if let activeSurvey = try await client.getActiveSurvey() {
|
||||||
survey = activeSurvey
|
survey = activeSurvey
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
isPresented = true
|
isPresented = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
client.startPolling(intervalMs: 60000) { newSurvey in
|
client.startPolling(interval: 60) { newSurvey in
|
||||||
if let newSurvey = newSurvey, survey == nil {
|
if let newSurvey = newSurvey, self.survey == nil {
|
||||||
survey = newSurvey
|
self.survey = newSurvey
|
||||||
isPresented = true
|
self.isPresented = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitAnswer(_ question: BLQuestion) async {
|
private func submitAnswer(_ question: BLSurveyQuestion) async {
|
||||||
let answer: BLSurveyAnswer
|
let answer: BLSurveyAnswer
|
||||||
|
|
||||||
switch question.type {
|
switch question.type {
|
||||||
case .singleChoice, .dropdown:
|
case .singleChoice, .dropdown:
|
||||||
guard let value = selectedOption else { return }
|
guard let value = selectedOption else { return }
|
||||||
answer = BLSurveyAnswer(type: "single_choice", value: .string(value))
|
answer = .singleChoice(optionId: value)
|
||||||
case .multipleChoice:
|
case .multipleChoice:
|
||||||
let values = Array(selectedOptions)
|
let values = Array(selectedOptions)
|
||||||
answer = BLSurveyAnswer(type: "multiple_choice", value: .stringArray(values))
|
answer = .multipleChoice(optionIds: values)
|
||||||
case .rating, .scale, .nps:
|
case .rating, .scale:
|
||||||
answer = BLSurveyAnswer(type: "rating", value: .int(ratingValue))
|
answer = .rating(value: ratingValue)
|
||||||
|
case .nps:
|
||||||
|
answer = .nps(value: ratingValue)
|
||||||
case .textShort, .textLong:
|
case .textShort, .textLong:
|
||||||
answer = BLSurveyAnswer(type: "text", value: .string(textAnswer))
|
answer = .text(value: textAnswer)
|
||||||
case .ranking:
|
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)
|
do {
|
||||||
if let response = result.1 {
|
let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
|
||||||
currentQuestionIndex = response.currentQuestionIndex
|
currentQuestionIndex = response.currentQuestionIndex
|
||||||
answers = response.answers
|
answers = response.answers
|
||||||
resetQuestionState()
|
resetQuestionState()
|
||||||
@ -108,16 +113,15 @@ public struct BLSurveyModal: View {
|
|||||||
if currentQuestionIndex >= survey!.questions.count {
|
if currentQuestionIndex >= survey!.questions.count {
|
||||||
await completeSurvey()
|
await completeSurvey()
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore submit errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skipQuestion() async {
|
private func skipQuestion() async {
|
||||||
let question = survey!.questions[currentQuestionIndex]
|
let question = survey!.questions[currentQuestionIndex]
|
||||||
if !question.required {
|
if !question.required {
|
||||||
// Submit empty answer to skip
|
// Skip by advancing without submitting
|
||||||
let answer = BLSurveyAnswer(type: "skipped", value: .null)
|
|
||||||
_ = await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
|
|
||||||
|
|
||||||
currentQuestionIndex += 1
|
currentQuestionIndex += 1
|
||||||
resetQuestionState()
|
resetQuestionState()
|
||||||
|
|
||||||
@ -128,17 +132,21 @@ public struct BLSurveyModal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func completeSurvey() async {
|
private func completeSurvey() async {
|
||||||
let result = await client.completeSurvey(surveyId: survey!.id)
|
do {
|
||||||
if let completion = result.1, completion.success {
|
let completion = try await client.completeSurvey(surveyId: survey!.id)
|
||||||
isComplete = true
|
if completion.success {
|
||||||
showCompletion = true
|
isComplete = true
|
||||||
|
showCompletion = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dismissSurvey() {
|
private func dismissSurvey() {
|
||||||
if let survey = survey {
|
if let survey = survey {
|
||||||
Task {
|
Task {
|
||||||
_ = await client.dismissSurvey(surveyId: survey.id)
|
try? await client.dismissSurvey(surveyId: survey.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isPresented = false
|
isPresented = false
|
||||||
@ -166,7 +174,7 @@ public struct BLSurveyModal: View {
|
|||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
struct QuestionView: View {
|
struct QuestionView: View {
|
||||||
let survey: BLActiveSurvey
|
let survey: BLActiveSurvey
|
||||||
let question: BLQuestion
|
let question: BLSurveyQuestion
|
||||||
let questionIndex: Int
|
let questionIndex: Int
|
||||||
let totalQuestions: Int
|
let totalQuestions: Int
|
||||||
|
|
||||||
@ -314,7 +322,7 @@ struct QuestionView: View {
|
|||||||
|
|
||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
struct SingleChoiceView: View {
|
struct SingleChoiceView: View {
|
||||||
let options: [BLQuestionOption]
|
let options: [BLSurveyOption]
|
||||||
@Binding var selected: String?
|
@Binding var selected: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -348,7 +356,7 @@ struct SingleChoiceView: View {
|
|||||||
|
|
||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
struct MultipleChoiceView: View {
|
struct MultipleChoiceView: View {
|
||||||
let options: [BLQuestionOption]
|
let options: [BLSurveyOption]
|
||||||
@Binding var selected: Set<String>
|
@Binding var selected: Set<String>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -459,7 +467,7 @@ struct TextAnswerView: View {
|
|||||||
|
|
||||||
@available(iOS 15.0, *)
|
@available(iOS 15.0, *)
|
||||||
struct RankingView: View {
|
struct RankingView: View {
|
||||||
let options: [BLQuestionOption]
|
let options: [BLSurveyOption]
|
||||||
@Binding var order: [String]
|
@Binding var order: [String]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -555,7 +563,7 @@ struct CompletionView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "gift.fill")
|
Image(systemName: "gift.fill")
|
||||||
.foregroundColor(.green)
|
.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)
|
.font(.headline)
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user