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

593 lines
19 KiB
Swift

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<String> = []
@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<String>
@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<String>
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)
}
}
}