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