From b472f73c94b4cb7fb4c5c456166b59b69a3ac7d0 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 08:20:01 -0800 Subject: [PATCH] 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 --- .../com/bytelyst/platform/ui/BroadcastUI.kt | 330 ++++++++ .../com/bytelyst/platform/ui/SurveyUI.kt | 754 ++++++++++++++++++ .../Sources/BLInAppMessageUI.swift | 260 ++++++ .../Sources/BLSurveyUI.swift | 584 ++++++++++++++ 4 files changed, 1928 insertions(+) create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt create mode 100644 packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift create mode 100644 packages/swift-platform-sdk/Sources/BLSurveyUI.swift diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt new file mode 100644 index 00000000..6874c302 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt @@ -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>(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(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") + } + } + } + } + } + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt new file mode 100644 index 00000000..d85fb10b --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt @@ -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(null) } + var currentQuestionIndex by remember { mutableIntStateOf(0) } + var answers by remember { mutableStateOf>(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(null) } + var selectedOptions by remember { mutableStateOf>(emptySet()) } + var ratingValue by remember { mutableIntStateOf(0) } + var textAnswer by remember { mutableStateOf("") } + var rankingOrder by remember { mutableStateOf>(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, + ratingValue: Int, + textAnswer: String, + rankingOrder: List, + onSuccess: (Int, Map) -> 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) -> Unit, + onRating: (Int) -> Unit, + onText: (String) -> Unit, + onRanking: (List) -> 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, + ratingValue: Int, + textAnswer: String, + rankingOrder: List, + onSelectedOptionChange: (String?) -> Unit, + onSelectedOptionsChange: (Set) -> Unit, + onRatingChange: (Int) -> Unit, + onTextChange: (String) -> Unit, + onRankingChange: (List) -> 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, + 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, + selected: Set, + onSelect: (Set) -> 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, + order: List, + onOrderChange: (List) -> 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.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") + } + } + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift b/packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift new file mode 100644 index 00000000..afd3166a --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLInAppMessageUI.swift @@ -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 + ) + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLSurveyUI.swift b/packages/swift-platform-sdk/Sources/BLSurveyUI.swift new file mode 100644 index 00000000..b3cc159c --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLSurveyUI.swift @@ -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 = [] + @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 + @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 + + 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) + } + } +}