feat(platform-sdk): Phase 4.2/4.3 - iOS and Android native UI components
- BLInAppMessageUI.swift: Banner + Modal SwiftUI components - BLSurveyUI.swift: Survey modal with all 9 question types for iOS - BroadcastUI.kt: Banner + Modal Jetpack Compose components - SurveyUI.kt: Survey modal with all 9 question types for Android
This commit is contained in:
parent
30583a1768
commit
b472f73c94
@ -0,0 +1,330 @@
|
|||||||
|
package com.bytelyst.platform.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.bytelyst.platform.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-App Message Banner — Jetpack Compose component for top/bottom banner display.
|
||||||
|
* Part of ByteLystPlatformSDK.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InAppMessageBanner(
|
||||||
|
client: BLBroadcastClient,
|
||||||
|
position: BannerPosition = BannerPosition.TOP,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var messages by remember { mutableStateOf<List<InAppMessage>>(emptyList()) }
|
||||||
|
var unreadCount by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(client) {
|
||||||
|
// Initial load
|
||||||
|
val result = client.getMessages()
|
||||||
|
result.onSuccess { response ->
|
||||||
|
messages = response.messages
|
||||||
|
unreadCount = messages.count { it.status == MessageStatus.UNREAD }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
client.startPolling(60000L) { updatedMessages ->
|
||||||
|
messages = updatedMessages
|
||||||
|
unreadCount = updatedMessages.count { it.status == MessageStatus.UNREAD }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bannerMessages = messages.filter {
|
||||||
|
it.status == MessageStatus.UNREAD && (it.style == MessageStyle.BANNER || it.style == MessageStyle.TOAST)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bannerMessages.isEmpty()) return
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(
|
||||||
|
top = if (position == BannerPosition.TOP) 16.dp else 0.dp,
|
||||||
|
bottom = if (position == BannerPosition.BOTTOM) 16.dp else 0.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
bannerMessages.forEach { message ->
|
||||||
|
BannerCard(
|
||||||
|
message = message,
|
||||||
|
onDismiss = {
|
||||||
|
scope.launch {
|
||||||
|
client.markDismissed(message.id)
|
||||||
|
messages = messages.filter { it.id != message.id }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
scope.launch {
|
||||||
|
client.trackClick(message.id)
|
||||||
|
message.ctaUrl?.let { url ->
|
||||||
|
// Open URL
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
client.markRead(message.id)
|
||||||
|
messages = messages.map {
|
||||||
|
if (it.id == message.id) it.copy(status = MessageStatus.READ)
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BannerPosition {
|
||||||
|
TOP, BOTTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerCard(
|
||||||
|
message: InAppMessage,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onTap: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor = when (message.priority) {
|
||||||
|
MessagePriority.URGENT -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
MessagePriority.HIGH -> Color(0xFFFFF3E0) // Orange-ish
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(4.dp, RoundedCornerShape(12.dp))
|
||||||
|
.clickable { onTap() },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = backgroundColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
if (message.imageUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
message.body?.let { body ->
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ctaText != null) {
|
||||||
|
Text(
|
||||||
|
text = "Tap to open →",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.dismissible) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Dismiss",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast Modal — Jetpack Compose component for modal/fullscreen broadcast display.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BroadcastModal(
|
||||||
|
client: BLBroadcastClient,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var currentMessage by remember { mutableStateOf<InAppMessage?>(null) }
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(client) {
|
||||||
|
// Start polling for modal messages
|
||||||
|
client.startPolling(30000L) { messages ->
|
||||||
|
val modalMessages = messages.filter {
|
||||||
|
it.status == MessageStatus.UNREAD && (it.style == MessageStyle.MODAL || it.style == MessageStyle.FULLSCREEN)
|
||||||
|
}
|
||||||
|
if (modalMessages.isNotEmpty() && currentMessage == null) {
|
||||||
|
currentMessage = modalMessages.first()
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog && currentMessage != null) {
|
||||||
|
val message = currentMessage!!
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
if (message.dismissible) {
|
||||||
|
scope.launch {
|
||||||
|
client.markDismissed(message.id)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
currentMessage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Header with image
|
||||||
|
if (message.imageUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(160.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = message.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
message.body?.let { body ->
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (message.dismissible) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
client.markDismissed(message.id)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
currentMessage = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ctaText != null) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
client.trackClick(message.id)
|
||||||
|
message.ctaUrl?.let { url ->
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
client.markRead(message.id)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
currentMessage = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(message.ctaText)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.OpenInNew,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
client.markRead(message.id)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
currentMessage = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Got it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,754 @@
|
|||||||
|
package com.bytelyst.platform.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.bytelyst.platform.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Survey Modal — Jetpack Compose component for displaying and completing surveys.
|
||||||
|
* Part of ByteLystPlatformSDK.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SurveyModal(
|
||||||
|
client: BLSurveyClient,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var survey by remember { mutableStateOf<ActiveSurvey?>(null) }
|
||||||
|
var currentQuestionIndex by remember { mutableIntStateOf(0) }
|
||||||
|
var answers by remember { mutableStateOf<Map<String, SurveyAnswer>>(emptyMap()) }
|
||||||
|
var isComplete by remember { mutableStateOf(false) }
|
||||||
|
var showCompletion by remember { mutableStateOf(false) }
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Question state
|
||||||
|
var selectedOption by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedOptions by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
var ratingValue by remember { mutableIntStateOf(0) }
|
||||||
|
var textAnswer by remember { mutableStateOf("") }
|
||||||
|
var rankingOrder by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
|
||||||
|
LaunchedEffect(client) {
|
||||||
|
// Check for active survey
|
||||||
|
val result = client.getActiveSurvey()
|
||||||
|
result.onSuccess { response ->
|
||||||
|
response?.let {
|
||||||
|
survey = it
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
client.startPolling(60000L) { newSurvey ->
|
||||||
|
if (newSurvey != null && survey == null) {
|
||||||
|
survey = newSurvey
|
||||||
|
showDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
when {
|
||||||
|
showCompletion -> {
|
||||||
|
CompletionDialog(
|
||||||
|
survey = survey,
|
||||||
|
onDismiss = {
|
||||||
|
scope.launch {
|
||||||
|
survey?.let { client.dismissSurvey(it.id) }
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
resetSurvey()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
survey != null && currentQuestionIndex < survey!!.questions.size -> {
|
||||||
|
val question = survey!!.questions[currentQuestionIndex]
|
||||||
|
QuestionDialog(
|
||||||
|
survey = survey!!,
|
||||||
|
question = question,
|
||||||
|
questionIndex = currentQuestionIndex,
|
||||||
|
totalQuestions = survey!!.questions.size,
|
||||||
|
selectedOption = selectedOption,
|
||||||
|
selectedOptions = selectedOptions,
|
||||||
|
ratingValue = ratingValue,
|
||||||
|
textAnswer = textAnswer,
|
||||||
|
rankingOrder = rankingOrder,
|
||||||
|
onSelectedOptionChange = { selectedOption = it },
|
||||||
|
onSelectedOptionsChange = { selectedOptions = it },
|
||||||
|
onRatingChange = { ratingValue = it },
|
||||||
|
onTextChange = { textAnswer = it },
|
||||||
|
onRankingChange = { rankingOrder = it },
|
||||||
|
onSubmit = {
|
||||||
|
scope.launch {
|
||||||
|
submitAnswer(
|
||||||
|
client = client,
|
||||||
|
survey = survey!!,
|
||||||
|
question = question,
|
||||||
|
selectedOption = selectedOption,
|
||||||
|
selectedOptions = selectedOptions,
|
||||||
|
ratingValue = ratingValue,
|
||||||
|
textAnswer = textAnswer,
|
||||||
|
rankingOrder = rankingOrder,
|
||||||
|
onSuccess = { newIndex, newAnswers ->
|
||||||
|
currentQuestionIndex = newIndex
|
||||||
|
answers = newAnswers
|
||||||
|
resetQuestionState(
|
||||||
|
onSelectedOption = { selectedOption = it },
|
||||||
|
onSelectedOptions = { selectedOptions = it },
|
||||||
|
onRating = { ratingValue = it },
|
||||||
|
onText = { textAnswer = it },
|
||||||
|
onRanking = { rankingOrder = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentQuestionIndex >= survey!!.questions.size) {
|
||||||
|
completeSurvey(client, survey!!.id) { success, incentive ->
|
||||||
|
if (success) {
|
||||||
|
isComplete = true
|
||||||
|
showCompletion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip = {
|
||||||
|
if (!question.required) {
|
||||||
|
scope.launch {
|
||||||
|
skipQuestion(client, survey!!, question.id) { newIndex ->
|
||||||
|
currentQuestionIndex = newIndex
|
||||||
|
resetQuestionState(
|
||||||
|
onSelectedOption = { selectedOption = it },
|
||||||
|
onSelectedOptions = { selectedOptions = it },
|
||||||
|
onRating = { ratingValue = it },
|
||||||
|
onText = { textAnswer = it },
|
||||||
|
onRanking = { rankingOrder = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentQuestionIndex >= survey!!.questions.size) {
|
||||||
|
completeSurvey(client, survey!!.id) { success, _ ->
|
||||||
|
if (success) {
|
||||||
|
isComplete = true
|
||||||
|
showCompletion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
scope.launch {
|
||||||
|
survey?.let { client.dismissSurvey(it.id) }
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
resetSurvey()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSurvey() {
|
||||||
|
survey = null
|
||||||
|
currentQuestionIndex = 0
|
||||||
|
answers = emptyMap()
|
||||||
|
isComplete = false
|
||||||
|
showCompletion = false
|
||||||
|
resetQuestionState(
|
||||||
|
onSelectedOption = { selectedOption = it },
|
||||||
|
onSelectedOptions = { selectedOptions = it },
|
||||||
|
onRating = { ratingValue = it },
|
||||||
|
onText = { textAnswer = it },
|
||||||
|
onRanking = { rankingOrder = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun submitAnswer(
|
||||||
|
client: BLSurveyClient,
|
||||||
|
survey: ActiveSurvey,
|
||||||
|
question: Question,
|
||||||
|
selectedOption: String?,
|
||||||
|
selectedOptions: Set<String>,
|
||||||
|
ratingValue: Int,
|
||||||
|
textAnswer: String,
|
||||||
|
rankingOrder: List<String>,
|
||||||
|
onSuccess: (Int, Map<String, SurveyAnswer>) -> Unit
|
||||||
|
) {
|
||||||
|
val answer: SurveyAnswer = when (question.type) {
|
||||||
|
QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> {
|
||||||
|
selectedOption?.let {
|
||||||
|
SurveyAnswer(type = "single_choice", value = JsonObject(mapOf("value" to JsonPrimitive(it))))
|
||||||
|
} ?: return
|
||||||
|
}
|
||||||
|
QuestionType.MULTIPLE_CHOICE -> {
|
||||||
|
SurveyAnswer(type = "multiple_choice", value = JsonObject(mapOf("values" to JsonArray(selectedOptions.map { JsonPrimitive(it) }))))
|
||||||
|
}
|
||||||
|
QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> {
|
||||||
|
SurveyAnswer(type = "rating", value = JsonObject(mapOf("value" to JsonPrimitive(ratingValue))))
|
||||||
|
}
|
||||||
|
QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> {
|
||||||
|
SurveyAnswer(type = "text", value = JsonObject(mapOf("value" to JsonPrimitive(textAnswer))))
|
||||||
|
}
|
||||||
|
QuestionType.RANKING -> {
|
||||||
|
SurveyAnswer(type = "ranking", value = JsonObject(mapOf("order" to JsonArray(rankingOrder.map { JsonPrimitive(it) }))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = client.submitAnswer(survey.id, question.id, answer)
|
||||||
|
result.onSuccess { response ->
|
||||||
|
onSuccess(response.currentQuestionIndex, response.answers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun skipQuestion(
|
||||||
|
client: BLSurveyClient,
|
||||||
|
survey: ActiveSurvey,
|
||||||
|
questionId: String,
|
||||||
|
onSuccess: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
val answer = SurveyAnswer(type = "skipped", value = JsonObject(emptyMap()))
|
||||||
|
val result = client.submitAnswer(survey.id, questionId, answer)
|
||||||
|
result.onSuccess { response ->
|
||||||
|
onSuccess(response.currentQuestionIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun completeSurvey(
|
||||||
|
client: BLSurveyClient,
|
||||||
|
surveyId: String,
|
||||||
|
onComplete: (Boolean, Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val result = client.completeSurvey(surveyId)
|
||||||
|
result.onSuccess { completion ->
|
||||||
|
onComplete(completion.success, completion.incentiveClaimed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetQuestionState(
|
||||||
|
onSelectedOption: (String?) -> Unit,
|
||||||
|
onSelectedOptions: (Set<String>) -> Unit,
|
||||||
|
onRating: (Int) -> Unit,
|
||||||
|
onText: (String) -> Unit,
|
||||||
|
onRanking: (List<String>) -> Unit
|
||||||
|
) {
|
||||||
|
onSelectedOption(null)
|
||||||
|
onSelectedOptions(emptySet())
|
||||||
|
onRating(0)
|
||||||
|
onText("")
|
||||||
|
onRanking(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuestionDialog(
|
||||||
|
survey: ActiveSurvey,
|
||||||
|
question: Question,
|
||||||
|
questionIndex: Int,
|
||||||
|
totalQuestions: Int,
|
||||||
|
selectedOption: String?,
|
||||||
|
selectedOptions: Set<String>,
|
||||||
|
ratingValue: Int,
|
||||||
|
textAnswer: String,
|
||||||
|
rankingOrder: List<String>,
|
||||||
|
onSelectedOptionChange: (String?) -> Unit,
|
||||||
|
onSelectedOptionsChange: (Set<String>) -> Unit,
|
||||||
|
onRatingChange: (Int) -> Unit,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onRankingChange: (List<String>) -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Progress
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (questionIndex + 1) / totalQuestions.toFloat() },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Question ${questionIndex + 1} of $totalQuestions",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// Question text
|
||||||
|
Text(
|
||||||
|
text = question.text,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
question.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (question.required) {
|
||||||
|
Text(
|
||||||
|
text = "Required",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question input
|
||||||
|
when (question.type) {
|
||||||
|
QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> {
|
||||||
|
SingleChoiceInput(
|
||||||
|
options = question.options ?: emptyList(),
|
||||||
|
selected = selectedOption,
|
||||||
|
onSelect = onSelectedOptionChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QuestionType.MULTIPLE_CHOICE -> {
|
||||||
|
MultipleChoiceInput(
|
||||||
|
options = question.options ?: emptyList(),
|
||||||
|
selected = selectedOptions,
|
||||||
|
onSelect = onSelectedOptionsChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> {
|
||||||
|
RatingInput(
|
||||||
|
minValue = question.minValue ?: if (question.type == QuestionType.NPS) 0 else 1,
|
||||||
|
maxValue = question.maxValue ?: if (question.type == QuestionType.NPS) 10 else 5,
|
||||||
|
rating = ratingValue,
|
||||||
|
onRatingChange = onRatingChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> {
|
||||||
|
TextAnswerInput(
|
||||||
|
text = textAnswer,
|
||||||
|
isLong = question.type == QuestionType.TEXT_LONG,
|
||||||
|
maxLength = question.maxLength,
|
||||||
|
onTextChange = onTextChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QuestionType.RANKING -> {
|
||||||
|
RankingInput(
|
||||||
|
options = question.options ?: emptyList(),
|
||||||
|
order = rankingOrder,
|
||||||
|
onOrderChange = onRankingChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (!question.required) {
|
||||||
|
TextButton(onClick = onSkip) {
|
||||||
|
Text("Skip")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(1.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
val canSubmit = when (question.type) {
|
||||||
|
QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> selectedOption != null
|
||||||
|
QuestionType.MULTIPLE_CHOICE -> selectedOptions.isNotEmpty()
|
||||||
|
QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> ratingValue > 0
|
||||||
|
QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> textAnswer.isNotBlank()
|
||||||
|
QuestionType.RANKING -> rankingOrder.size == (question.options?.size ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLast = questionIndex == totalQuestions - 1
|
||||||
|
Button(
|
||||||
|
onClick = onSubmit,
|
||||||
|
enabled = canSubmit
|
||||||
|
) {
|
||||||
|
Text(if (isLast) "Complete" else "Next")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SingleChoiceInput(
|
||||||
|
options: List<QuestionOption>,
|
||||||
|
selected: String?,
|
||||||
|
onSelect: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (selected == option.id) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.clickable { onSelect(option.id) }
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = option.emoji ?: "")
|
||||||
|
Text(
|
||||||
|
text = option.text,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = if (selected == option.id) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (selected == option.id) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MultipleChoiceInput(
|
||||||
|
options: List<QuestionOption>,
|
||||||
|
selected: Set<String>,
|
||||||
|
onSelect: (Set<String>) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val isSelected = selected.contains(option.id)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
val newSet = if (isSelected) selected - option.id else selected + option.id
|
||||||
|
onSelect(newSet)
|
||||||
|
}
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = option.emoji ?: "")
|
||||||
|
Text(
|
||||||
|
text = option.text,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = {
|
||||||
|
val newSet = if (it) selected + option.id else selected - option.id
|
||||||
|
onSelect(newSet)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RatingInput(
|
||||||
|
minValue: Int,
|
||||||
|
maxValue: Int,
|
||||||
|
rating: Int,
|
||||||
|
onRatingChange: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
(minValue..maxValue).forEach { value ->
|
||||||
|
Button(
|
||||||
|
onClick = { onRatingChange(value) },
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (rating == value) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = if (rating == value) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
modifier = Modifier.size(44.dp)
|
||||||
|
) {
|
||||||
|
Text("$value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (maxValue == 10) "Not likely" else "Low",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (maxValue == 10) "Very likely" else "High",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextAnswerInput(
|
||||||
|
text: String,
|
||||||
|
isLong: Boolean,
|
||||||
|
maxLength: Int?,
|
||||||
|
onTextChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (isLong) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
maxLength?.let {
|
||||||
|
if (newText.length <= it) onTextChange(newText)
|
||||||
|
} ?: onTextChange(newText)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 120.dp),
|
||||||
|
maxLines = 6,
|
||||||
|
keyboardOptions = KeyboardOptions.Default
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
maxLength?.let {
|
||||||
|
if (newText.length <= it) onTextChange(newText)
|
||||||
|
} ?: onTextChange(newText)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions.Default
|
||||||
|
)
|
||||||
|
}
|
||||||
|
maxLength?.let {
|
||||||
|
Text(
|
||||||
|
text = "${text.length}/$it",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (text.length > it) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RankingInput(
|
||||||
|
options: List<QuestionOption>,
|
||||||
|
order: List<String>,
|
||||||
|
onOrderChange: (List<String>) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val rank = order.indexOf(option.id).let { if (it >= 0) it + 1 else null }
|
||||||
|
val isRanked = rank != null
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Rank badge
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isRanked) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = rank?.toString() ?: "-",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isRanked) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = option.text,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val idx = order.indexOf(option.id)
|
||||||
|
if (idx > 0) {
|
||||||
|
val newOrder = order.toMutableList()
|
||||||
|
newOrder.swap(idx, idx - 1)
|
||||||
|
onOrderChange(newOrder)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = isRanked && rank!! > 1,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val idx = order.indexOf(option.id)
|
||||||
|
if (idx < order.size - 1) {
|
||||||
|
val newOrder = order.toMutableList()
|
||||||
|
newOrder.swap(idx, idx + 1)
|
||||||
|
onOrderChange(newOrder)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = isRanked && rank!! < order.size,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!isRanked) {
|
||||||
|
onOrderChange(order + option.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isRanked,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.swap(i: Int, j: Int) {
|
||||||
|
val temp = this[i]
|
||||||
|
this[i] = this[j]
|
||||||
|
this[j] = temp
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompletionDialog(
|
||||||
|
survey: ActiveSurvey?,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Thank You!",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Your feedback helps us improve.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
survey?.incentive?.let { incentive ->
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFE8F5E9)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "You've earned ${incentive.amount} ${if (incentive.type == "pro_days") "Pro Days" else "Credits"}!",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = Color(0xFF2E7D32)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
260
packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift
Normal file
260
packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-App Message Banner — SwiftUI component for top/bottom banner display.
|
||||||
|
* Part of ByteLystPlatformSDK.
|
||||||
|
*/
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
public struct BLInAppMessageBanner: View {
|
||||||
|
@ObservedObject var client: BLBroadcastClient
|
||||||
|
@State private var messages: [BLInAppMessage] = []
|
||||||
|
@State private var unreadCount = 0
|
||||||
|
|
||||||
|
let position: BannerPosition
|
||||||
|
|
||||||
|
public enum BannerPosition {
|
||||||
|
case top, bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(client: BLBroadcastClient, position: BannerPosition = .top) {
|
||||||
|
self.client = client
|
||||||
|
self.position = position
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(messages.filter { $0.status == .unread && ($0.style == .banner || $0.style == .toast) }) { message in
|
||||||
|
BannerCard(
|
||||||
|
message: message,
|
||||||
|
onDismiss: { await dismissMessage(message) },
|
||||||
|
onTap: { await handleTap(message) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(position == .top ? .top : .bottom, 8)
|
||||||
|
.task {
|
||||||
|
await loadMessages()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMessages() async {
|
||||||
|
let result = await client.listMessages()
|
||||||
|
if let response = result.1 {
|
||||||
|
messages = response.messages
|
||||||
|
unreadCount = messages.filter { $0.status == .unread }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
client.startPolling(intervalMs: 60000) { updatedMessages in
|
||||||
|
messages = updatedMessages
|
||||||
|
unreadCount = updatedMessages.filter { $0.status == .unread }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissMessage(_ message: BLInAppMessage) async {
|
||||||
|
_ = await client.markDismissed(message.id)
|
||||||
|
messages.removeAll { $0.id == message.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTap(_ message: BLInAppMessage) async {
|
||||||
|
_ = await client.trackClick(message.id)
|
||||||
|
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
|
||||||
|
await UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
_ = await client.markRead(message.id)
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
messages[index].status = .read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
struct BannerCard: View {
|
||||||
|
let message: BLInAppMessage
|
||||||
|
let onDismiss: () async -> Void
|
||||||
|
let onTap: () async -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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) {
|
||||||
|
Text(message.title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let body = message.body {
|
||||||
|
Text(body)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.ctaText != nil {
|
||||||
|
Text("Tap to open")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if message.dismissible {
|
||||||
|
Button(action: { Task { await onDismiss() } }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
Task { await onTap() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch message.priority {
|
||||||
|
case .urgent:
|
||||||
|
return Color.red.opacity(0.1)
|
||||||
|
case .high:
|
||||||
|
return Color.orange.opacity(0.1)
|
||||||
|
default:
|
||||||
|
return Color(.systemBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
public struct BLBroadcastModal: View {
|
||||||
|
@ObservedObject var client: BLBroadcastClient
|
||||||
|
@State private var currentMessage: BLInAppMessage?
|
||||||
|
@State private var isPresented = false
|
||||||
|
|
||||||
|
public init(client: BLBroadcastClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
EmptyView()
|
||||||
|
.sheet(isPresented: $isPresented) {
|
||||||
|
if let message = currentMessage {
|
||||||
|
ModalContent(
|
||||||
|
message: message,
|
||||||
|
onDismiss: { await dismissMessage() },
|
||||||
|
onAction: { await handleAction() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
client.startPolling(intervalMs: 30000) { messages in
|
||||||
|
let modalMessages = messages.filter {
|
||||||
|
$0.status == .unread && ($0.style == .modal || $0.style == .fullscreen)
|
||||||
|
}
|
||||||
|
if let first = modalMessages.first, currentMessage == nil {
|
||||||
|
currentMessage = first
|
||||||
|
isPresented = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissMessage() async {
|
||||||
|
if let message = currentMessage {
|
||||||
|
_ = await client.markDismissed(message.id)
|
||||||
|
}
|
||||||
|
isPresented = false
|
||||||
|
currentMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAction() async {
|
||||||
|
if let message = currentMessage {
|
||||||
|
_ = await client.trackClick(message.id)
|
||||||
|
if let urlString = message.ctaUrl, let url = URL(string: urlString) {
|
||||||
|
await UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
_ = await client.markRead(message.id)
|
||||||
|
}
|
||||||
|
isPresented = false
|
||||||
|
currentMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
struct ModalContent: View {
|
||||||
|
let message: BLInAppMessage
|
||||||
|
let onDismiss: () async -> Void
|
||||||
|
let onAction: () async -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
ScrollView {
|
||||||
|
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)
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
if let body = message.body {
|
||||||
|
Text(body)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.ctaText != nil {
|
||||||
|
Button(action: { Task { await onAction() } }) {
|
||||||
|
Text(message.ctaText!)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.blue)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationBarItems(
|
||||||
|
trailing: message.dismissible ? Button("Close") {
|
||||||
|
Task { await onDismiss() }
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
584
packages/swift-platform-sdk/Sources/BLSurveyUI.swift
Normal file
584
packages/swift-platform-sdk/Sources/BLSurveyUI.swift
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
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 {
|
||||||
|
let result = await client.getActiveSurvey()
|
||||||
|
if let activeSurvey = result.1 {
|
||||||
|
survey = activeSurvey
|
||||||
|
if !isPresented {
|
||||||
|
isPresented = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPolling() {
|
||||||
|
client.startPolling(intervalMs: 60000) { newSurvey in
|
||||||
|
if let newSurvey = newSurvey, survey == nil {
|
||||||
|
survey = newSurvey
|
||||||
|
isPresented = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitAnswer(_ question: BLQuestion) async {
|
||||||
|
let answer: BLSurveyAnswer
|
||||||
|
|
||||||
|
switch question.type {
|
||||||
|
case .singleChoice, .dropdown:
|
||||||
|
guard let value = selectedOption else { return }
|
||||||
|
answer = BLSurveyAnswer(type: "single_choice", value: .string(value))
|
||||||
|
case .multipleChoice:
|
||||||
|
let values = Array(selectedOptions)
|
||||||
|
answer = BLSurveyAnswer(type: "multiple_choice", value: .stringArray(values))
|
||||||
|
case .rating, .scale, .nps:
|
||||||
|
answer = BLSurveyAnswer(type: "rating", value: .int(ratingValue))
|
||||||
|
case .textShort, .textLong:
|
||||||
|
answer = BLSurveyAnswer(type: "text", value: .string(textAnswer))
|
||||||
|
case .ranking:
|
||||||
|
answer = BLSurveyAnswer(type: "ranking", value: .stringArray(rankingOrder))
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
|
||||||
|
if let response = result.1 {
|
||||||
|
currentQuestionIndex = response.currentQuestionIndex
|
||||||
|
answers = response.answers
|
||||||
|
resetQuestionState()
|
||||||
|
|
||||||
|
if currentQuestionIndex >= survey!.questions.count {
|
||||||
|
await completeSurvey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skipQuestion() async {
|
||||||
|
let question = survey!.questions[currentQuestionIndex]
|
||||||
|
if !question.required {
|
||||||
|
// Submit empty answer to skip
|
||||||
|
let answer = BLSurveyAnswer(type: "skipped", value: .null)
|
||||||
|
_ = await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer)
|
||||||
|
|
||||||
|
currentQuestionIndex += 1
|
||||||
|
resetQuestionState()
|
||||||
|
|
||||||
|
if currentQuestionIndex >= survey!.questions.count {
|
||||||
|
await completeSurvey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeSurvey() async {
|
||||||
|
let result = await client.completeSurvey(surveyId: survey!.id)
|
||||||
|
if let completion = result.1, completion.success {
|
||||||
|
isComplete = true
|
||||||
|
showCompletion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissSurvey() {
|
||||||
|
if let survey = survey {
|
||||||
|
Task {
|
||||||
|
_ = 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: BLQuestion
|
||||||
|
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: [BLQuestionOption]
|
||||||
|
@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: [BLQuestionOption]
|
||||||
|
@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: [BLQuestionOption]
|
||||||
|
@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 == .proDays ? "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user