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 package com.couplesconnect.app.ui.questions
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -22,6 +25,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -59,6 +65,13 @@ private fun QuestionCategoryContent(
state: QuestionCategoryUiState, state: QuestionCategoryUiState,
onQuestionSelected: (Question) -> Unit 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -81,22 +94,12 @@ private fun QuestionCategoryContent(
item { item {
val title = state.category?.displayName val title = state.category?.displayName
?: categoryId.displayCategoryName() ?: categoryId.displayCategoryName()
Column( CategoryHero(
modifier = Modifier.padding(top = 20.dp, bottom = 6.dp), title = title,
verticalArrangement = Arrangement.spacedBy(10.dp) category = state.category,
) { questionCount = state.questions.size,
Text( modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)
text = title, )
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = state.category?.description
?: "Browse prompts for this kind of conversation.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
} }
when { when {
@ -115,23 +118,163 @@ private fun QuestionCategoryContent(
} }
else -> { else -> {
item { item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { CategoryFilters(
CategoryPill("${state.questions.size} prompts") questions = state.questions,
state.category?.access?.let { CategoryPill(it.displayCategoryName()) } selectedType = selectedType,
} onTypeSelected = { selectedType = it }
}
items(state.questions, key = { it.id }) { question ->
QuestionListCard(
question = question,
onClick = { onQuestionSelected(question) }
) )
} }
if (visibleQuestions.isEmpty()) {
item {
CategoryMessageCard(
title = "No prompts match",
message = "Try another filter to keep browsing."
)
}
} 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) }
)
}
}
}
} }
} }
} }
} }
} }
@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 @Composable
private fun QuestionListCard( private fun QuestionListCard(
question: Question, question: Question,
@ -140,13 +283,13 @@ private fun QuestionListCard(
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f)), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp) elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(17.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text( Text(
text = question.text, text = question.text,
@ -157,7 +300,6 @@ private fun QuestionListCard(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("Depth ${question.depthLevel}")
CategoryPill(question.type.displayQuestionType()) CategoryPill(question.type.displayQuestionType())
if (question.isPremium) { if (question.isPremium) {
CategoryPill("Premium") CategoryPill("Premium")
@ -170,16 +312,19 @@ private fun QuestionListCard(
} }
@Composable @Composable
private fun CategoryPill(label: String) { private fun CategoryPill(
label: String,
emphasis: Boolean = false
) {
Surface( Surface(
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1) color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFF8F4F1)
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp), modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734), color = if (emphasis) Color(0xFF5F3A87) else Color(0xFF3E3734),
maxLines = 1 maxLines = 1
) )
} }

View File

@ -1,33 +1,36 @@
package com.couplesconnect.app.ui.questions package com.couplesconnect.app.ui.questions
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionCategory import com.couplesconnect.app.domain.model.QuestionCategory
private enum class PackFilter(val label: String) {
ALL("All"),
FREE("Free"),
MIXED("Mixed"),
PREMIUM("Premium")
}
@Composable @Composable
fun QuestionPackLibraryScreen( fun QuestionPackLibraryScreen(
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
@ -61,6 +71,18 @@ private fun QuestionPackLibraryContent(
onPackSelected: (QuestionPackItem) -> Unit, onPackSelected: (QuestionPackItem) -> Unit,
onPaywall: () -> 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -112,8 +134,28 @@ private fun QuestionPackLibraryContent(
message = "Question packs are not available right now. Try again in a moment." 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 -> { else -> {
items(state.packs, key = { it.category.id }) { item -> item {
PackFilterRow(
selected = selectedFilter,
onSelected = { selectedFilter = it }
)
}
items(visiblePacks, key = { it.category.id }) { item ->
QuestionPackCard( QuestionPackCard(
item = item, item = item,
onClick = { onClick = {
@ -148,82 +190,164 @@ private fun QuestionPackCard(
onClick: () -> Unit onClick: () -> Unit
) { ) {
val containerColor = if (item.isLocked) val containerColor = if (item.isLocked)
Color(0xFFF5F0EC).copy(alpha = 0.84f) Color(0xFFFAF7F5).copy(alpha = 0.9f)
else else
Color.White.copy(alpha = 0.84f) Color.White.copy(alpha = 0.9f)
val accent = packAccent(item.category.id)
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp), shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = containerColor), colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) { ) {
Column( Column(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.padding(18.dp), Box(
verticalArrangement = Arrangement.spacedBy(14.dp) modifier = Modifier
) { .fillMaxWidth()
Row( .height(5.dp)
modifier = Modifier.fillMaxWidth(), .background(if (item.isLocked) Color(0xFFD9D1CE) else accent)
horizontalArrangement = Arrangement.SpaceBetween, )
verticalAlignment = Alignment.Top Column(
modifier = Modifier
.fillMaxWidth()
.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Column( Row(
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) { ) {
Text( Column(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() }, modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge, verticalArrangement = Arrangement.spacedBy(6.dp)
color = if (item.isLocked) Color(0xFF9E9693) else Color(0xFF27211F), ) {
fontWeight = FontWeight.SemiBold, Text(
maxLines = 2, text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
overflow = TextOverflow.Ellipsis style = MaterialTheme.typography.titleLarge,
) color = Color(0xFF27211F),
Text( fontWeight = FontWeight.SemiBold,
text = item.category.description, maxLines = 1,
style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis
color = Color(0xFF4E4642), )
maxLines = 3, Text(
overflow = TextOverflow.Ellipsis text = item.category.description,
) style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
PackPill(item.promptCountLabel(), emphasis = true)
} }
if (item.isLocked) { Row(
Icon( modifier = Modifier
imageVector = Icons.Default.Lock, .fillMaxWidth()
contentDescription = "Premium", .horizontalScroll(rememberScrollState()),
tint = Color(0xFFB0A9A6), horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.size(20.dp) ) {
) item.metadataLabels().forEach { label ->
} else { PackPill(label, emphasis = label == "Premium")
PackPill("${item.questionCount} prompts") }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.isPremium) PackPill("Premium")
PackPill(item.category.access.displayCategoryName())
PackPill(item.category.iconName.ifBlank { "question" }.displayCategoryName())
}
} }
} }
} }
@Composable @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( Surface(
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1) color = if (emphasis) Color(0xFFF3E8FF) else Color(0xFFF8F4F1)
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp), modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734), color = if (emphasis) Color(0xFF5F3A87) else Color(0xFF3E3734),
maxLines = 1 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 @Composable
private fun LoadingPackCard() { private fun LoadingPackCard() {
Card( Card(