fix(questions): update category and pack library screens

This commit is contained in:
null 2026-06-16 03:07:32 -05:00
parent 888ffa3c1a
commit 0e9606366b
2 changed files with 355 additions and 86 deletions

View File

@ -1,6 +1,8 @@
package com.couplesconnect.app.ui.questions
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@ -22,6 +25,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -59,6 +65,13 @@ private fun QuestionCategoryContent(
state: QuestionCategoryUiState,
onQuestionSelected: (Question) -> Unit
) {
var selectedType by remember { mutableStateOf<String?>(null) }
val visibleQuestions = remember(state.questions, selectedType) {
state.questions.filter { question ->
selectedType == null || question.type == selectedType
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -81,22 +94,12 @@ private fun QuestionCategoryContent(
item {
val title = state.category?.displayName
?: categoryId.displayCategoryName()
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
CategoryHero(
title = title,
category = state.category,
questionCount = state.questions.size,
modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)
)
Text(
text = state.category?.description
?: "Browse prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
when {
@ -115,12 +118,25 @@ private fun QuestionCategoryContent(
}
else -> {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("${state.questions.size} prompts")
state.category?.access?.let { CategoryPill(it.displayCategoryName()) }
CategoryFilters(
questions = state.questions,
selectedType = selectedType,
onTypeSelected = { selectedType = it }
)
}
if (visibleQuestions.isEmpty()) {
item {
CategoryMessageCard(
title = "No prompts match",
message = "Try another filter to keep browsing."
)
}
items(state.questions, key = { it.id }) { question ->
} else {
visibleQuestions.groupBy { it.depthLevel }.toSortedMap().forEach { (depth, questions) ->
item(key = "depth-$depth") {
DepthHeader(depth = depth, count = questions.size)
}
items(questions, key = { it.id }) { question ->
QuestionListCard(
question = question,
onClick = { onQuestionSelected(question) }
@ -130,6 +146,133 @@ private fun QuestionCategoryContent(
}
}
}
}
}
}
@Composable
private fun CategoryHero(
title: String,
category: QuestionCategory?,
questionCount: Int,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = category?.description
?: "Browse prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryPill("$questionCount ${if (questionCount == 1) "prompt" else "prompts"}", emphasis = true)
category?.access?.let { CategoryPill(it.displayCategoryName()) }
category?.iconName
?.takeIf { it.isNotBlank() }
?.let { it.displayCategoryName() }
?.takeIf { it != "Question" }
?.let { CategoryPill(it) }
}
}
}
@Composable
private fun CategoryFilters(
questions: List<Question>,
selectedType: String?,
onTypeSelected: (String?) -> Unit
) {
val types = questions.map { it.type }.distinct().sorted()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Format",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642),
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
(listOf(null) + types).forEach { option ->
FilterPill(
label = option?.displayQuestionFilterName() ?: "All",
selected = selectedType == option,
onClick = { onTypeSelected(option) }
)
}
}
}
}
private fun String.displayQuestionFilterName(): String {
return when (this) {
"single_choice" -> "Single"
"multi_choice" -> "Multi"
"this_or_that" -> "Either/or"
"scale" -> "Scale"
else -> "Written"
}
}
@Composable
private fun FilterPill(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
shadowElevation = if (selected) 2.dp else 0.dp
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 13.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF5F3A87) else Color(0xFF3E3734),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1
)
}
}
@Composable
private fun DepthHeader(depth: Int, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Depth $depth",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
CategoryPill("$count ${if (count == 1) "prompt" else "prompts"}")
}
}
@Composable
@ -140,13 +283,13 @@ private fun QuestionListCard(
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) {
Column(
modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = question.text,
@ -157,7 +300,6 @@ private fun QuestionListCard(
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("Depth ${question.depthLevel}")
CategoryPill(question.type.displayQuestionType())
if (question.isPremium) {
CategoryPill("Premium")
@ -170,16 +312,19 @@ private fun QuestionListCard(
}
@Composable
private fun CategoryPill(label: String) {
private fun CategoryPill(
label: String,
emphasis: Boolean = false
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
color = if (emphasis) Color(0xFF5F3A87) else Color(0xFF3E3734),
maxLines = 1
)
}

View File

@ -1,33 +1,36 @@
package com.couplesconnect.app.ui.questions
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -41,6 +44,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionCategory
private enum class PackFilter(val label: String) {
ALL("All"),
FREE("Free"),
MIXED("Mixed"),
PREMIUM("Premium")
}
@Composable
fun QuestionPackLibraryScreen(
onNavigate: (String) -> Unit = {},
@ -61,6 +71,18 @@ private fun QuestionPackLibraryContent(
onPackSelected: (QuestionPackItem) -> Unit,
onPaywall: () -> Unit
) {
var selectedFilter by remember { mutableStateOf(PackFilter.ALL) }
val visiblePacks = remember(state.packs, selectedFilter) {
state.packs.filter { item ->
when (selectedFilter) {
PackFilter.ALL -> true
PackFilter.FREE -> item.category.access == "free"
PackFilter.MIXED -> item.category.access == "mixed"
PackFilter.PREMIUM -> item.category.access == "premium"
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -112,8 +134,28 @@ private fun QuestionPackLibraryContent(
message = "Question packs are not available right now. Try again in a moment."
)
}
visiblePacks.isEmpty() -> {
item {
PackFilterRow(
selected = selectedFilter,
onSelected = { selectedFilter = it }
)
}
item {
PackMessageCard(
title = "Nothing in ${selectedFilter.label.lowercase()} yet",
message = "Try another filter to keep browsing."
)
}
}
else -> {
items(state.packs, key = { it.category.id }) { item ->
item {
PackFilterRow(
selected = selectedFilter,
onSelected = { selectedFilter = it }
)
}
items(visiblePacks, key = { it.category.id }) { item ->
QuestionPackCard(
item = item,
onClick = {
@ -148,24 +190,34 @@ private fun QuestionPackCard(
onClick: () -> Unit
) {
val containerColor = if (item.isLocked)
Color(0xFFF5F0EC).copy(alpha = 0.84f)
Color(0xFFFAF7F5).copy(alpha = 0.9f)
else
Color.White.copy(alpha = 0.84f)
Color.White.copy(alpha = 0.9f)
val accent = packAccent(item.category.id)
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(5.dp)
.background(if (item.isLocked) Color(0xFFD9D1CE) else accent)
)
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
modifier = Modifier
.fillMaxWidth()
.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Column(
@ -175,55 +227,127 @@ private fun QuestionPackCard(
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleLarge,
color = if (item.isLocked) Color(0xFF9E9693) else Color(0xFF27211F),
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.category.description,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
maxLines = 3,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
if (item.isLocked) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "Premium",
tint = Color(0xFFB0A9A6),
modifier = Modifier.size(20.dp)
)
} else {
PackPill("${item.questionCount} prompts")
PackPill(item.promptCountLabel(), emphasis = true)
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item.metadataLabels().forEach { label ->
PackPill(label, emphasis = label == "Premium")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.isPremium) PackPill("Premium")
PackPill(item.category.access.displayCategoryName())
PackPill(item.category.iconName.ifBlank { "question" }.displayCategoryName())
}
}
}
}
@Composable
private fun PackPill(label: String) {
private fun PackFilterRow(
selected: PackFilter,
onSelected: (PackFilter) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
PackFilter.entries.forEach { filter ->
FilterPill(
label = filter.label,
selected = selected == filter,
onClick = { onSelected(filter) }
)
}
}
}
@Composable
private fun FilterPill(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(999.dp),
color = if (selected) Color(0xFFF3E8FF) else Color.White.copy(alpha = 0.74f),
tonalElevation = 0.dp,
shadowElevation = if (selected) 3.dp else 0.dp
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 13.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = if (selected) Color(0xFF5F3A87) else Color(0xFF3E3734),
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1
)
}
}
@Composable
private fun PackPill(
label: String,
emphasis: Boolean = false
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
color = if (emphasis) Color(0xFF5F3A87) else Color(0xFF3E3734),
maxLines = 1
)
}
}
private fun QuestionPackItem.promptCountLabel(): String =
"$questionCount ${if (questionCount == 1) "prompt" else "prompts"}"
private fun QuestionPackItem.metadataLabels(): List<String> {
val access = when (category.access) {
"premium" -> "Premium"
"mixed" -> "Mixed access"
"free" -> "Free"
else -> category.access.displayCategoryName()
}
return listOf(
access,
category.iconName.ifBlank { "question" }.displayCategoryName()
).filterNot { it == "Question" }.distinct()
}
private fun packAccent(categoryId: String): Color {
val palette = listOf(
Color(0xFF5F3A87),
Color(0xFF5C7C8A),
Color(0xFF6F7D4F),
Color(0xFF8A5A74),
Color(0xFF7A6A3A)
)
return palette[kotlin.math.abs(categoryId.hashCode()) % palette.size]
}
@Composable
private fun LoadingPackCard() {
Card(