593 lines
19 KiB
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)
|
|
}
|
|
}
|
|
}
|