learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLSurveyClient.swift

370 lines
12 KiB
Swift

// Survey Client
// In-app survey client for iOS/watchOS/macOS.
// Part of ByteLystPlatformSDK.
import Foundation
/// Survey question types.
public enum BLSurveyQuestionType: String, Codable, Sendable {
case singleChoice = "single_choice"
case multipleChoice = "multiple_choice"
case rating = "rating"
case nps = "nps"
case textShort = "text_short"
case textLong = "text_long"
case dropdown = "dropdown"
case scale = "scale"
case ranking = "ranking"
}
/// Survey status.
public enum BLSurveyStatus: String, Codable, Sendable {
case draft = "draft"
case active = "active"
case paused = "paused"
case closed = "closed"
}
/// Represents a survey question option.
public struct BLSurveyOption: Codable, Sendable, Identifiable {
public let id: String
public let text: String
public let emoji: String?
}
/// Represents a survey question.
public struct BLSurveyQuestion: Codable, Sendable, Identifiable {
public let id: String
public let type: BLSurveyQuestionType
public let text: String
public let description: String?
public let required: Bool
public let options: [BLSurveyOption]?
public let minLength: Int?
public let maxLength: Int?
public let minValue: Int?
public let maxValue: Int?
}
/// Represents an active survey for display.
public struct BLActiveSurvey: Codable, Sendable, Identifiable {
public let id: String
public let title: String
public let description: String?
public let questions: [BLSurveyQuestion]
public let incentive: BLSurveyIncentive?
public let displayTrigger: BLSurveyTrigger
}
/// Survey incentive.
public struct BLSurveyIncentive: Codable, Sendable {
public let type: String
public let amount: Int
}
/// Survey display trigger.
public struct BLSurveyTrigger: Codable, Sendable {
public let type: String
public let seconds: Int?
public let eventName: String?
public let pagePattern: String?
}
/// Survey answer types.
public enum BLSurveyAnswer: Codable, Sendable {
case singleChoice(optionId: String)
case multipleChoice(optionIds: [String])
case rating(value: Int)
case nps(value: Int)
case text(value: String)
case ranking(rankedOptionIds: [String])
public var type: String {
switch self {
case .singleChoice: return "single_choice"
case .multipleChoice: return "multiple_choice"
case .rating: return "rating"
case .nps: return "nps"
case .text: return "text"
case .ranking: return "ranking"
}
}
}
/// Represents a survey response.
public struct BLSurveyResponse: Codable, Sendable {
public let id: String
public let surveyId: String
public let userId: String
public var answers: [String: BLSurveyAnswer]
public var currentQuestionIndex: Int
public let startedAt: String
public var completedAt: String?
public var isComplete: Bool
public var incentiveClaimed: Bool
public var incentiveClaimedAt: String?
public let createdAt: String
public let updatedAt: String
}
/// Survey client for managing in-app surveys.
@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] = [:]
public init(platformClient: BLPlatformClient) {
self.platformClient = platformClient
}
/// Get active survey for the current user (if any).
public func getActiveSurvey() async throws -> BLActiveSurvey? {
let request = try platformClient.buildRequest(path: "/surveys/active")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error")
}
let result = try JSONDecoder().decode(ActiveSurveyResponse.self, from: data)
return result.survey
}
/// Start a survey session.
public func startSurvey(surveyId: String) async throws -> BLSurveyResponse {
let request = try platformClient.buildRequest(
path: "/surveys/\(surveyId)/start",
method: "POST"
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BLPlatformError.requestFailed("Failed to start survey")
}
let result = try JSONDecoder().decode(StartSurveyResponse.self, from: data)
// Build and cache the response
let surveyResponse = BLSurveyResponse(
id: result.responseId,
surveyId: surveyId,
userId: "", // Will be filled by server
answers: result.answers,
currentQuestionIndex: result.currentQuestionIndex,
startedAt: result.startedAt,
completedAt: nil,
isComplete: false,
incentiveClaimed: false,
incentiveClaimedAt: nil,
createdAt: result.startedAt,
updatedAt: result.startedAt
)
cachedResponses[surveyId] = surveyResponse
return surveyResponse
}
/// Submit an answer to a survey question.
public func submitAnswer(
surveyId: String,
questionId: String,
answer: BLSurveyAnswer
) async throws -> BLSurveyResponse {
let request = try platformClient.buildRequest(
path: "/surveys/\(surveyId)/response",
method: "POST",
body: [
"questionId": questionId,
"answer": [
"type": answer.type,
"value": encodeAnswerValue(answer)
]
]
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BLPlatformError.requestFailed("Failed to submit answer")
}
let result = try JSONDecoder().decode(SubmitAnswerResponse.self, from: data)
// Update cache
if var cached = cachedResponses[surveyId] {
cached.answers = result.answers
cached.currentQuestionIndex = result.currentQuestionIndex
cachedResponses[surveyId] = cached
}
// Return updated response
return BLSurveyResponse(
id: result.responseId,
surveyId: surveyId,
userId: "",
answers: result.answers,
currentQuestionIndex: result.currentQuestionIndex,
startedAt: "",
completedAt: nil,
isComplete: false,
incentiveClaimed: false,
incentiveClaimedAt: nil,
createdAt: "",
updatedAt: Date().ISO8601Format()
)
}
/// Complete a survey.
public func completeSurvey(surveyId: String) async throws -> SurveyCompletionResult {
let request = try platformClient.buildRequest(
path: "/surveys/\(surveyId)/complete",
method: "POST"
)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BLPlatformError.requestFailed("Failed to complete survey")
}
let result = try JSONDecoder().decode(SurveyCompletionResult.self, from: data)
// Clear cache on completion
cachedResponses.removeValue(forKey: surveyId)
return result
}
/// Dismiss a survey (won't show again).
public func dismissSurvey(surveyId: String) async throws {
let request = try platformClient.buildRequest(
path: "/surveys/\(surveyId)/dismiss",
method: "POST"
)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BLPlatformError.requestFailed("Failed to dismiss survey")
}
// Clear cache
cachedResponses.removeValue(forKey: surveyId)
}
/// Get cached response for a survey.
public func getCachedResponse(surveyId: String) -> BLSurveyResponse? {
return cachedResponses[surveyId]
}
/// Start polling for eligible surveys.
public func startPolling(
interval: TimeInterval = 60,
onUpdate: @escaping (BLActiveSurvey?) -> Void
) {
stopPolling()
pollTask = Task {
while !Task.isCancelled {
do {
let survey = try await getActiveSurvey()
onUpdate(survey)
} catch {
// Silently ignore polling errors
}
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
}
}
}
/// Stop polling for surveys.
public func stopPolling() {
pollTask?.cancel()
pollTask = nil
}
}
// MARK: - Survey Completion Result
public struct SurveyCompletionResult: Codable, Sendable {
public let success: Bool
public let timeSpentSeconds: Int
public let incentiveClaimed: Bool
}
// MARK: - Response Types
private struct ActiveSurveyResponse: Codable {
let survey: BLActiveSurvey?
}
private struct StartSurveyResponse: Codable {
let responseId: String
let startedAt: String
let currentQuestionIndex: Int
let answers: [String: BLSurveyAnswer]
}
private struct SubmitAnswerResponse: Codable {
let responseId: String
let currentQuestionIndex: Int
let answers: [String: BLSurveyAnswer]
}
// MARK: - Helper Functions
private func encodeAnswerValue(_ answer: BLSurveyAnswer) -> AnyCodable {
switch answer {
case .singleChoice(let optionId):
return AnyCodable(optionId)
case .multipleChoice(let optionIds):
return AnyCodable(optionIds)
case .rating(let value), .nps(let value):
return AnyCodable(value)
case .text(let value):
return AnyCodable(value)
case .ranking(let rankedOptionIds):
return AnyCodable(rankedOptionIds)
}
}
/// Type-erased codable wrapper for encoding heterogeneous types.
private struct AnyCodable: Codable {
private let value: Any
init(_ value: Any) {
self.value = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let string = value as? String {
try container.encode(string)
} else if let int = value as? Int {
try container.encode(int)
} else if let array = value as? [String] {
try container.encode(array)
} else {
try container.encode(String(describing: value))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
value = string
} else if let int = try? container.decode(Int.self) {
value = int
} else if let array = try? container.decode([String].self) {
value = array
} else {
value = ""
}
}
}