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:
saravanakumardb1 2026-03-03 08:20:01 -08:00
parent 30583a1768
commit b472f73c94
4 changed files with 1928 additions and 0 deletions

View File

@ -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")
}
}
}
}
}
}
}
}

View File

@ -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")
}
}
}
}
}

View 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
)
}
}
}

View 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)
}
}
}