import SwiftUI /** * Survey Modal — SwiftUI component for displaying and completing surveys. * Part of ByteLystPlatformSDK. */ @available(iOS 15.0, *) public struct BLSurveyModal: View { @ObservedObject var client: BLSurveyClient @State private var survey: BLActiveSurvey? @State private var currentQuestionIndex = 0 @State private var answers: [String: BLSurveyAnswer] = [:] @State private var isComplete = false @State private var showCompletion = false @State private var isPresented = false // Local state for question answers @State private var selectedOption: String? @State private var selectedOptions: Set = [] @State private var ratingValue: Int = 0 @State private var textAnswer: String = "" @State private var rankingOrder: [String] = [] public init(client: BLSurveyClient) { self.client = client } public var body: some View { EmptyView() .sheet(isPresented: $isPresented) { surveyContent } .task { await checkForSurvey() startPolling() } } @ViewBuilder private var surveyContent: some View { if showCompletion { CompletionView( survey: survey, onDismiss: { dismissSurvey() } ) } else if let survey = survey, currentQuestionIndex < survey.questions.count { let question = survey.questions[currentQuestionIndex] QuestionView( survey: survey, question: question, questionIndex: currentQuestionIndex, totalQuestions: survey.questions.count, selectedOption: $selectedOption, selectedOptions: $selectedOptions, ratingValue: $ratingValue, textAnswer: $textAnswer, rankingOrder: $rankingOrder, onSubmit: { await submitAnswer(question) }, onSkip: { await skipQuestion() }, onDismiss: { dismissSurvey() } ) } } private func checkForSurvey() async { do { if let activeSurvey = try await client.getActiveSurvey() { survey = activeSurvey if !isPresented { isPresented = true } } } catch { // Silently ignore } } private func startPolling() { client.startPolling(interval: 60) { newSurvey in if let newSurvey = newSurvey, self.survey == nil { self.survey = newSurvey self.isPresented = true } } } private func submitAnswer(_ question: BLSurveyQuestion) async { let answer: BLSurveyAnswer switch question.type { case .singleChoice, .dropdown: guard let value = selectedOption else { return } answer = .singleChoice(optionId: value) case .multipleChoice: let values = Array(selectedOptions) answer = .multipleChoice(optionIds: values) case .rating, .scale: answer = .rating(value: ratingValue) case .nps: answer = .nps(value: ratingValue) case .textShort, .textLong: answer = .text(value: textAnswer) case .ranking: answer = .ranking(rankedOptionIds: rankingOrder) } do { let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer) currentQuestionIndex = response.currentQuestionIndex answers = response.answers resetQuestionState() if currentQuestionIndex >= survey!.questions.count { await completeSurvey() } } catch { // Silently ignore submit errors } } private func skipQuestion() async { let question = survey!.questions[currentQuestionIndex] if !question.required { // Skip by advancing without submitting currentQuestionIndex += 1 resetQuestionState() if currentQuestionIndex >= survey!.questions.count { await completeSurvey() } } } private func completeSurvey() async { 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 { try? await client.dismissSurvey(surveyId: survey.id) } } isPresented = false resetSurvey() } private func resetSurvey() { survey = nil currentQuestionIndex = 0 answers = [:] isComplete = false showCompletion = false resetQuestionState() } private func resetQuestionState() { selectedOption = nil selectedOptions = [] ratingValue = 0 textAnswer = "" rankingOrder = [] } } @available(iOS 15.0, *) struct QuestionView: View { let survey: BLActiveSurvey let question: BLSurveyQuestion let questionIndex: Int let totalQuestions: Int @Binding var selectedOption: String? @Binding var selectedOptions: Set @Binding var ratingValue: Int @Binding var textAnswer: String @Binding var rankingOrder: [String] let onSubmit: () async -> Void let onSkip: () async -> Void let onDismiss: () -> Void var body: some View { NavigationView { ScrollView { VStack(spacing: 24) { // Progress bar ProgressView(value: Double(questionIndex + 1), total: Double(totalQuestions)) .padding(.horizontal) Text("Question \(questionIndex + 1) of \(totalQuestions)") .font(.caption) .foregroundColor(.secondary) // Question text VStack(alignment: .leading, spacing: 8) { Text(question.text) .font(.title3.bold()) if let description = question.description { Text(description) .font(.subheadline) .foregroundColor(.secondary) } if question.required { Text("Required") .font(.caption) .foregroundColor(.red) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) // Question input based on type questionInput Spacer() // Action buttons VStack(spacing: 12) { Button(action: { Task { await onSubmit() } }) { Text(isLast ? "Complete" : "Next") .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background(canSubmit ? Color.blue : Color.gray) .cornerRadius(12) } .disabled(!canSubmit) if !question.required { Button(action: { Task { await onSkip() } }) { Text("Skip") .font(.subheadline) .foregroundColor(.secondary) } } } .padding(.horizontal) } .padding(.vertical) } .navigationTitle(survey.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Dismiss") { onDismiss() } } } } } private var isLast: Bool { questionIndex == totalQuestions - 1 } private var canSubmit: Bool { if !question.required { return true } switch question.type { case .singleChoice, .dropdown: return selectedOption != nil case .multipleChoice: return !selectedOptions.isEmpty case .rating, .scale, .nps: return ratingValue > 0 case .textShort, .textLong: return !textAnswer.isEmpty case .ranking: return rankingOrder.count == (question.options?.count ?? 0) } } @ViewBuilder private var questionInput: some View { switch question.type { case .singleChoice, .dropdown: SingleChoiceView( options: question.options ?? [], selected: $selectedOption ) case .multipleChoice: MultipleChoiceView( options: question.options ?? [], selected: $selectedOptions ) case .rating, .scale, .nps: RatingView( minValue: question.minValue ?? (question.type == .nps ? 0 : 1), maxValue: question.maxValue ?? (question.type == .nps ? 10 : 5), rating: $ratingValue ) case .textShort, .textLong: TextAnswerView( text: $textAnswer, isLong: question.type == .textLong, minLength: question.minLength, maxLength: question.maxLength ) case .ranking: RankingView( options: question.options ?? [], order: $rankingOrder ) } } } // MARK: - Question Type Views @available(iOS 15.0, *) struct SingleChoiceView: View { let options: [BLSurveyOption] @Binding var selected: String? var body: some View { VStack(spacing: 8) { ForEach(options) { option in Button(action: { selected = option.id }) { HStack { Text(option.emoji ?? "") Text(option.text) .foregroundColor(.primary) Spacer() if selected == option.id { Image(systemName: "checkmark.circle.fill") .foregroundColor(.blue) } else { Image(systemName: "circle") .foregroundColor(.secondary) } } .padding() .background( RoundedRectangle(cornerRadius: 8) .fill(selected == option.id ? Color.blue.opacity(0.1) : Color(.systemGray6)) ) } } } .padding(.horizontal) } } @available(iOS 15.0, *) struct MultipleChoiceView: View { let options: [BLSurveyOption] @Binding var selected: Set var body: some View { VStack(spacing: 8) { ForEach(options) { option in Button(action: { toggleOption(option.id) }) { HStack { Text(option.emoji ?? "") Text(option.text) .foregroundColor(.primary) Spacer() if selected.contains(option.id) { Image(systemName: "checkmark.square.fill") .foregroundColor(.blue) } else { Image(systemName: "square") .foregroundColor(.secondary) } } .padding() .background( RoundedRectangle(cornerRadius: 8) .fill(selected.contains(option.id) ? Color.blue.opacity(0.1) : Color(.systemGray6)) ) } } } .padding(.horizontal) } private func toggleOption(_ id: String) { if selected.contains(id) { selected.remove(id) } else { selected.insert(id) } } } @available(iOS 15.0, *) struct RatingView: View { let minValue: Int let maxValue: Int @Binding var rating: Int var body: some View { VStack(spacing: 16) { HStack(spacing: 8) { ForEach(minValue...maxValue, id: \.self) { value in Button(action: { rating = value }) { Text("\(value)") .font(.headline) .frame(width: 44, height: 44) .background( Circle() .fill(rating == value ? Color.blue : Color(.systemGray5)) ) .foregroundColor(rating == value ? .white : .primary) } } } HStack { Text("Low") .font(.caption) .foregroundColor(.secondary) Spacer() Text("High") .font(.caption) .foregroundColor(.secondary) } .padding(.horizontal, 32) } } } @available(iOS 15.0, *) struct TextAnswerView: View { @Binding var text: String let isLong: Bool let minLength: Int? let maxLength: Int? var body: some View { VStack { if isLong { TextEditor(text: $text) .frame(minHeight: 120) .padding(8) .background(Color(.systemGray6)) .cornerRadius(8) } else { TextField("Your answer", text: $text) .padding() .background(Color(.systemGray6)) .cornerRadius(8) } if let max = maxLength { Text("\(text.count)/\(max)") .font(.caption) .foregroundColor(text.count > max ? .red : .secondary) } } .padding(.horizontal) } } @available(iOS 15.0, *) struct RankingView: View { let options: [BLSurveyOption] @Binding var order: [String] var body: some View { VStack(spacing: 8) { ForEach(options) { option in HStack { Text("\(order.firstIndex(of: option.id).map { "\($0 + 1)" } ?? "-")") .font(.caption) .frame(width: 24, height: 24) .background( Circle() .fill(order.contains(option.id) ? Color.blue : Color(.systemGray5)) ) .foregroundColor(order.contains(option.id) ? .white : .secondary) Text(option.text) Spacer() HStack(spacing: 4) { Button(action: { moveUp(option.id) }) { Image(systemName: "arrow.up") } .disabled(!canMoveUp(option.id)) Button(action: { moveDown(option.id) }) { Image(systemName: "arrow.down") } .disabled(!canMoveDown(option.id)) Button(action: { addToRanking(option.id) }) { Image(systemName: "plus") } .disabled(order.contains(option.id)) } } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } } .padding(.horizontal) } private func canMoveUp(_ id: String) -> Bool { guard let index = order.firstIndex(of: id), index > 0 else { return false } return true } private func canMoveDown(_ id: String) -> Bool { guard let index = order.firstIndex(of: id), index < order.count - 1 else { return false } return true } private func moveUp(_ id: String) { guard let index = order.firstIndex(of: id), index > 0 else { return } order.swapAt(index, index - 1) } private func moveDown(_ id: String) { guard let index = order.firstIndex(of: id), index < order.count - 1 else { return } order.swapAt(index, index + 1) } private func addToRanking(_ id: String) { if !order.contains(id) { order.append(id) } } } @available(iOS 15.0, *) struct CompletionView: View { let survey: BLActiveSurvey? let onDismiss: () -> Void var body: some View { NavigationView { VStack(spacing: 24) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 64)) .foregroundColor(.green) Text("Thank You!") .font(.title.bold()) Text("Your feedback helps us improve.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) if let incentive = survey?.incentive { HStack { Image(systemName: "gift.fill") .foregroundColor(.green) Text("You've earned \(incentive.amount) \(incentive.type == "pro_days" ? "Pro Days" : "Credits")!") .font(.headline) .foregroundColor(.green) } .padding() .background(Color.green.opacity(0.1)) .cornerRadius(12) } Spacer() Button(action: onDismiss) { Text("Close") .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background(Color.blue) .cornerRadius(12) } .padding(.horizontal) } .padding() .navigationBarHidden(true) } } }