feat: category picker redesign, bucket list wiring, settings + nav updates

This commit is contained in:
null 2026-06-17 20:38:06 -05:00
parent c816033e74
commit a0fd1d56f6
9 changed files with 622 additions and 66 deletions

View File

@ -3,9 +3,9 @@ package app.closer.core.navigation
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -47,6 +47,7 @@ import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.DateBuilderScreen
import app.closer.ui.dates.BucketListScreen import app.closer.ui.dates.BucketListScreen
import app.closer.ui.paywall.PaywallScreen import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.play.PlayHubScreen
import app.closer.ui.questions.DailyQuestionScreen import app.closer.ui.questions.DailyQuestionScreen
import app.closer.ui.questions.QuestionCategoryScreen import app.closer.ui.questions.QuestionCategoryScreen
import app.closer.ui.questions.QuestionComposerScreen import app.closer.ui.questions.QuestionComposerScreen
@ -170,6 +171,9 @@ fun AppNavigation(
composable(route = AppRoute.PARTNER_HOME) { composable(route = AppRoute.PARTNER_HOME) {
PartnerHomeScreen(onNavigate = navigateRoute) PartnerHomeScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.PLAY) {
PlayHubScreen(onNavigate = navigateRoute)
}
// Daily Question // Daily Question
composable(route = AppRoute.DAILY_QUESTION) { composable(route = AppRoute.DAILY_QUESTION) {
@ -349,8 +353,8 @@ private data class TopLevelRoute(
private val topLevelRoutes = listOf( private val topLevelRoutes = listOf(
TopLevelRoute(AppRoute.HOME, "Home", Icons.Filled.Home), TopLevelRoute(AppRoute.HOME, "Home", Icons.Filled.Home),
TopLevelRoute(AppRoute.DAILY_QUESTION, "Today", Icons.Filled.Favorite), TopLevelRoute(AppRoute.DAILY_QUESTION, "Today", Icons.Filled.Favorite),
TopLevelRoute(AppRoute.PLAY, "Play", Icons.Filled.PlayArrow),
TopLevelRoute(AppRoute.QUESTION_PACKS, "Packs", Icons.Filled.Star), TopLevelRoute(AppRoute.QUESTION_PACKS, "Packs", Icons.Filled.Star),
TopLevelRoute(AppRoute.ANSWER_HISTORY, "Answers", Icons.Filled.Done),
TopLevelRoute(AppRoute.SETTINGS, "Settings", Icons.Filled.Settings) TopLevelRoute(AppRoute.SETTINGS, "Settings", Icons.Filled.Settings)
) )
@ -364,6 +368,11 @@ private val shellBackRoutes = setOf(
AppRoute.SPIN_WHEEL, AppRoute.SPIN_WHEEL,
AppRoute.WHEEL_SESSION, AppRoute.WHEEL_SESSION,
AppRoute.WHEEL_COMPLETE, AppRoute.WHEEL_COMPLETE,
AppRoute.WHEEL_HISTORY,
AppRoute.DATE_MATCH,
AppRoute.DATE_MATCHES,
AppRoute.DATE_BUILDER,
AppRoute.BUCKET_LIST,
AppRoute.ACCOUNT, AppRoute.ACCOUNT,
AppRoute.SUBSCRIPTION, AppRoute.SUBSCRIPTION,
AppRoute.PAYWALL AppRoute.PAYWALL

View File

@ -10,6 +10,7 @@ object AppRoute {
const val CREATE_PROFILE = "create_profile" const val CREATE_PROFILE = "create_profile"
const val HOME = "home" const val HOME = "home"
const val PARTNER_HOME = "partner_home" const val PARTNER_HOME = "partner_home"
const val PLAY = "play"
const val DAILY_QUESTION = "daily_question" const val DAILY_QUESTION = "daily_question"
const val QUESTION_PACKS = "question_packs" const val QUESTION_PACKS = "question_packs"
const val QUESTION_CATEGORY = "question_category/{categoryId}" const val QUESTION_CATEGORY = "question_category/{categoryId}"
@ -56,6 +57,7 @@ object AppRoute {
Definition(FORGOT_PASSWORD, "Forgot Password", "auth"), Definition(FORGOT_PASSWORD, "Forgot Password", "auth"),
Definition(HOME, "Home", "home"), Definition(HOME, "Home", "home"),
Definition(PARTNER_HOME, "Partner", "home"), Definition(PARTNER_HOME, "Partner", "home"),
Definition(PLAY, "Play", "play"),
Definition(DAILY_QUESTION, "Daily Question", "questions"), Definition(DAILY_QUESTION, "Daily Question", "questions"),
Definition(QUESTION_PACKS, "Question Packs", "questions"), Definition(QUESTION_PACKS, "Question Packs", "questions"),
Definition(QUESTION_CATEGORY, "Question Pack", "questions"), Definition(QUESTION_CATEGORY, "Question Pack", "questions"),
@ -89,8 +91,8 @@ object AppRoute {
val topLevelRoutes = setOf( val topLevelRoutes = setOf(
HOME, HOME,
DAILY_QUESTION, DAILY_QUESTION,
PLAY,
QUESTION_PACKS, QUESTION_PACKS,
ANSWER_HISTORY,
SETTINGS SETTINGS
) )

View File

@ -28,24 +28,22 @@ class BucketListRepositoryImpl @Inject constructor(
dataSource.updateItem(item.coupleId, item) dataSource.updateItem(item.coupleId, item)
} }
override suspend fun getItem(itemId: String): BucketListItem? { override suspend fun getItem(coupleId: String, itemId: String): BucketListItem? {
// Need coupleId to fetch - in practice, this would be called from a context return dataSource.getItem(coupleId, itemId)
// where the coupleId is known. For now, return null.
return null
} }
override suspend fun getItems(coupleId: String): List<BucketListItem> { override suspend fun getItems(coupleId: String): List<BucketListItem> {
return dataSource.getItems(coupleId) return dataSource.getItems(coupleId)
} }
override suspend fun deleteItem(itemId: String) { override suspend fun deleteItem(coupleId: String, itemId: String) {
// Need coupleId to delete - same limitation as getItem dataSource.deleteItem(coupleId, itemId)
} }
// ─── Completion methods ────────────────────────────────────────────────── // ─── Completion methods ──────────────────────────────────────────────────
override suspend fun completeItem(itemId: String, completedBy: String) { override suspend fun completeItem(coupleId: String, itemId: String, completedBy: String) {
// Need coupleId - same limitation dataSource.completeItem(coupleId, itemId, completedBy)
} }
// ─── Category methods ──────────────────────────────────────────────────── // ─── Category methods ────────────────────────────────────────────────────

View File

@ -22,18 +22,18 @@ interface BucketListRepository {
suspend fun updateItem(item: BucketListItem) suspend fun updateItem(item: BucketListItem)
/** Get a bucket list item by ID. */ /** Get a bucket list item by ID. */
suspend fun getItem(itemId: String): BucketListItem? suspend fun getItem(coupleId: String, itemId: String): BucketListItem?
/** Get all bucket list items for a couple. */ /** Get all bucket list items for a couple. */
suspend fun getItems(coupleId: String): List<BucketListItem> suspend fun getItems(coupleId: String): List<BucketListItem>
/** Delete a bucket list item. */ /** Delete a bucket list item. */
suspend fun deleteItem(itemId: String) suspend fun deleteItem(coupleId: String, itemId: String)
// ─── Completion methods ────────────────────────────────────────────────── // ─── Completion methods ──────────────────────────────────────────────────
/** Mark an item as completed. */ /** Mark an item as completed. */
suspend fun completeItem(itemId: String, completedBy: String) suspend fun completeItem(coupleId: String, itemId: String, completedBy: String)
// ─── Category methods ──────────────────────────────────────────────────── // ─── Category methods ────────────────────────────────────────────────────

View File

@ -62,6 +62,8 @@ class BucketListViewModel @Inject constructor(
fun toggleComplete(itemId: String) { fun toggleComplete(itemId: String) {
val item = _uiState.value.items.find { it.id == itemId } ?: return val item = _uiState.value.items.find { it.id == itemId } ?: return
val coupleId = _uiState.value.coupleId
if (coupleId.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
if (item.isCompleted) { if (item.isCompleted) {
@ -72,7 +74,7 @@ class BucketListViewModel @Inject constructor(
) )
} }
} else { } else {
repository.completeItem(itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "") repository.completeItem(coupleId, itemId, FirebaseAuth.getInstance().currentUser?.uid ?: "")
_uiState.update { _uiState.update {
it.copy( it.copy(
items = it.items.map { items = it.items.map {
@ -86,8 +88,11 @@ class BucketListViewModel @Inject constructor(
} }
fun deleteItem(itemId: String) { fun deleteItem(itemId: String) {
val coupleId = _uiState.value.coupleId
if (coupleId.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
repository.deleteItem(itemId) repository.deleteItem(coupleId, itemId)
_uiState.update { _uiState.update {
it.copy(items = it.items.filter { it.id != itemId }) it.copy(items = it.items.filter { it.id != itemId })
} }

View File

@ -0,0 +1,356 @@
package app.closer.ui.play
import androidx.compose.foundation.background
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.heightIn
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import app.closer.core.navigation.AppRoute
@Composable
fun PlayHubScreen(
onNavigate: (String) -> Unit = {}
) {
PlayHubContent(onNavigate = onNavigate)
}
@Composable
private fun PlayHubContent(
onNavigate: (String) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Column(
modifier = Modifier.padding(top = 22.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Play",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Pick something light, useful, or a little surprising to do together.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
item {
FeaturedPlayCard(
onClick = { onNavigate(AppRoute.CATEGORY_PICKER) }
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CompactPlayCard(
title = "Date Match",
subtitle = "Swipe ideas",
icon = Icons.Filled.Favorite,
tint = Color(0xFF9B1B5A),
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.DATE_MATCH) }
)
CompactPlayCard(
title = "Plan Date",
subtitle = "Set the shape",
icon = Icons.Filled.Star,
tint = Color(0xFF6A4A00),
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.DATE_BUILDER) }
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CompactPlayCard(
title = "Bucket List",
subtitle = "Save ideas",
icon = Icons.Filled.Done,
tint = Color(0xFF2F5B4F),
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.BUCKET_LIST) }
)
CompactPlayCard(
title = "Wheel History",
subtitle = "Past sessions",
icon = Icons.Filled.Home,
tint = Color(0xFF56306F),
modifier = Modifier.weight(1f),
onClick = { onNavigate(AppRoute.WHEEL_HISTORY) }
)
}
}
}
}
}
@Composable
private fun FeaturedPlayCard(
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
listOf(Color.White, Color(0xFFF3E8FF), Color(0xFFFFF7FB)),
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color.White.copy(alpha = 0.72f)
) {
Text(
text = "Wheel",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
}
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFFFE8F4)
) {
Text(
text = "10 prompts",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF6D2B55),
fontWeight = FontWeight.SemiBold
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
WheelGlyph(
modifier = Modifier.size(74.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Spin the Wheel",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Choose a mood, spin once, and move through a short session together.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF56306F),
contentColor = Color.White
)
) {
Text("Choose category")
}
}
}
}
@Composable
private fun CompactPlayCard(
title: String,
subtitle: String,
icon: ImageVector,
tint: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier.heightIn(min = 154.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = tint.copy(alpha = 0.14f),
modifier = Modifier.size(46.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(24.dp)
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5A5060),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = tint,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
@Composable
private fun WheelGlyph(
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(26.dp),
color = Color(0xFF56306F)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.background(
Brush.linearGradient(
listOf(Color(0xFFB98AF4), Color(0xFFE7A2D1), Color(0xFF56306F)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(34.dp)
)
}
}
}
@Preview
@Composable
fun PlayHubScreenPreview() {
PlayHubContent(onNavigate = {})
}

View File

@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
@ -330,6 +331,12 @@ fun SettingsScreen(
colors = CardDefaults.cardColors(containerColor = SettingsCard) colors = CardDefaults.cardColors(containerColor = SettingsCard)
) { ) {
Column { Column {
SettingsRow(
icon = Icons.Filled.Done,
label = "Answer History",
onClick = { onNavigate(AppRoute.ANSWER_HISTORY) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
SettingsRow( SettingsRow(
icon = Icons.Filled.Notifications, icon = Icons.Filled.Notifications,
label = "Notifications", label = "Notifications",

View File

@ -15,8 +15,13 @@ import androidx.compose.foundation.layout.size
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.automirrored.filled.ArrowForward
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
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
@ -54,6 +59,7 @@ fun CategoryPickerScreen(
if (item.isLocked) onNavigate(AppRoute.PAYWALL) if (item.isLocked) onNavigate(AppRoute.PAYWALL)
else onNavigate(AppRoute.spinWheel(item.category.id)) else onNavigate(AppRoute.spinWheel(item.category.id))
}, },
onHistory = { onNavigate(AppRoute.WHEEL_HISTORY) },
onRetry = viewModel::load onRetry = viewModel::load
) )
} }
@ -62,6 +68,7 @@ fun CategoryPickerScreen(
private fun CategoryPickerContent( private fun CategoryPickerContent(
state: CategoryPickerUiState, state: CategoryPickerUiState,
onCategorySelected: (CategoryPickerItem) -> Unit, onCategorySelected: (CategoryPickerItem) -> Unit,
onHistory: () -> Unit,
onRetry: () -> Unit onRetry: () -> Unit
) { ) {
Box( Box(
@ -84,25 +91,10 @@ private fun CategoryPickerContent(
verticalArrangement = Arrangement.spacedBy(14.dp) verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
item { item {
Column( WheelPickerHeader(
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp), onHistory = onHistory,
verticalArrangement = Arrangement.spacedBy(10.dp) modifier = Modifier.padding(top = 20.dp, bottom = 4.dp)
) { )
Text(
text = "Choose the weather",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Pick a category that matches where you are tonight. The wheel picks the question.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
} }
when { when {
@ -125,6 +117,13 @@ private fun CategoryPickerContent(
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Categories unavailable", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color(0xFF261D2E)) Text("Categories unavailable", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color(0xFF261D2E))
Text(state.error, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF5A5060)) Text(state.error, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF5A5060))
Button(
onClick = onRetry,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F))
) {
Text("Retry", color = Color.White)
}
} }
} }
} }
@ -138,27 +137,138 @@ private fun CategoryPickerContent(
} }
} }
@Composable
private fun WheelPickerHeader(
onHistory: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Spin the Wheel",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Choose a mood and let chance pick the next conversation.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = onHistory,
modifier = Modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(999.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.82f),
contentColor = Color(0xFF56306F)
)
) {
Text("History")
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
color = Color(0xFFFFF8FC)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color(0xFFF0DFFF),
modifier = Modifier.size(46.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
tint = Color(0xFF56306F),
modifier = Modifier.size(24.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = "Ten prompts per spin",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "You can skip, continue, or end whenever the moment feels complete.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5A5060),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable @Composable
private fun CategoryCard( private fun CategoryCard(
item: CategoryPickerItem, item: CategoryPickerItem,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val containerColor = if (item.isLocked) Color(0xFFFFF8FC).copy(alpha = 0.84f) else Color.White.copy(alpha = 0.86f) val containerColor = if (item.isLocked) Color(0xFFFFF8FC) else Color.White
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = containerColor), colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) elevation = CardDefaults.cardElevation(defaultElevation = if (item.isLocked) 2.dp else 6.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(18.dp), modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Surface(
shape = RoundedCornerShape(18.dp),
color = if (item.isLocked) Color(0xFFF0EDF9) else Color(0xFFFFE8F4),
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = if (item.isLocked) Icons.Default.Lock else Icons.Filled.Star,
contentDescription = null,
tint = if (item.isLocked) Color(0xFF9B8AA6) else Color(0xFF6D2B55),
modifier = Modifier.size(22.dp)
)
}
}
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text( Text(
@ -174,14 +284,12 @@ private fun CategoryCard(
if (item.isLocked) CategoryPill("Premium") if (item.isLocked) CategoryPill("Premium")
} }
} }
if (item.isLocked) { Icon(
Icon( imageVector = Icons.AutoMirrored.Filled.ArrowForward,
imageVector = Icons.Default.Lock, contentDescription = if (item.isLocked) "Unlock" else "Spin",
contentDescription = "Locked", tint = if (item.isLocked) Color(0xFF9B8AA6) else Color(0xFF56306F),
tint = Color(0xFF9B8AA6), modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp) )
)
}
} }
} }
} }
@ -224,6 +332,7 @@ fun CategoryPickerScreenPreview() {
) )
), ),
onCategorySelected = {}, onCategorySelected = {},
onHistory = {},
onRetry = {} onRetry = {}
) )
} }

View File

@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -36,12 +37,13 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@Composable @Composable
@ -137,22 +139,11 @@ private fun SpinWheelContent(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Surface( WheelSpinner(
modifier = Modifier isSpinning = state.isSpinning,
.size(220.dp) spunAndReady = state.spunAndReady,
.rotate(if (state.isSpinning) rotation else 0f), rotation = rotation
shape = CircleShape, )
color = Color(0xFFF0EDF9),
shadowElevation = 12.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = if (state.spunAndReady) "" else "",
fontSize = 64.sp,
color = if (state.spunAndReady) Color(0xFFB98AF4) else Color(0xFF56306F)
)
}
}
} }
Column( Column(
@ -212,7 +203,7 @@ private fun SpinWheelContent(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F))
) { ) {
Text("Spin", color = Color.White) Text("Spin wheel", color = Color.White)
} }
} }
} }
@ -221,6 +212,85 @@ private fun SpinWheelContent(
} }
} }
@Composable
private fun WheelSpinner(
isSpinning: Boolean,
spunAndReady: Boolean,
rotation: Float
) {
val segmentColors = listOf(
Color(0xFFB98AF4),
Color(0xFFFFC2DD),
Color(0xFFE7A2D1),
Color(0xFFF0DFFF),
Color(0xFF8F67C5),
Color(0xFFFFE8F4),
Color(0xFF56306F),
Color(0xFFF7C8E4)
)
Box(
modifier = Modifier.size(270.dp),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.size(236.dp)
.rotate(if (isSpinning) rotation else 0f)
) {
val sweep = 360f / segmentColors.size
segmentColors.forEachIndexed { index, color ->
drawArc(
color = color,
startAngle = -90f + (index * sweep),
sweepAngle = sweep,
useCenter = true
)
}
drawCircle(
color = Color.White.copy(alpha = 0.78f),
radius = size.minDimension * 0.24f
)
drawCircle(
color = Color.White.copy(alpha = 0.84f),
style = Stroke(width = 7.dp.toPx())
)
}
Canvas(
modifier = Modifier
.align(Alignment.TopCenter)
.size(width = 34.dp, height = 28.dp)
) {
val path = Path().apply {
moveTo(size.width / 2f, size.height)
lineTo(0f, 0f)
lineTo(size.width, 0f)
close()
}
drawPath(path = path, color = Color(0xFF261D2E))
}
Surface(
modifier = Modifier.size(94.dp),
shape = CircleShape,
color = Color.White,
shadowElevation = 10.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = if (spunAndReady) "Ready" else "Spin",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56306F),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Preview @Preview
@Composable @Composable
fun SpinWheelScreenPreview() { fun SpinWheelScreenPreview() {