Compare commits

...

6 Commits

Author SHA1 Message Date
null d324efe182 refactor(home): extract status strip -> components/HomeStatusStrip.kt
Step 6/9. Verbatim move (strip public, chip private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:54 -05:00
null 48197ffd1e refactor(home): extract action feed -> components/HomeActionFeed.kt
Step 5/9. Verbatim move (section public, card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:31:42 -05:00
null 88460922c3 refactor(home): extract pending cards -> components/HomePendingCards.kt
Step 4/9. Verbatim move (section public, card-view private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:30:25 -05:00
null 07c810c9a1 refactor(home): extract category preview -> components/HomeCategoryPreview.kt
Step 3/9. Verbatim move (grid public, mini-card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:29:06 -05:00
null 18ad31506e refactor(home): extract loading/error/empty -> components/HomeStateCards.kt
Step 2/9. Verbatim move, public entries. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:27:52 -05:00
null 5f39194c00 refactor(home): extract shared card kit -> components/HomeActionStyle.kt
Step 1/9 of the HomeScreen decomposition. Moves the 6 shared style decls
(homeActionGlyph, HomeGlyphIcon, homePrimaryArt, HomeActionColors,
HomeActionTone.actionColors, HomePill) verbatim into ui/home/components/, made
public per the components/ visibility idiom. No behavior change. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:26:08 -05:00
7 changed files with 738 additions and 568 deletions

View File

@ -87,6 +87,19 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.ui.home.components.ActionFeedSection
import app.closer.ui.home.components.CategoryPreviewGrid
import app.closer.ui.home.components.EmptyHomeContent
import app.closer.ui.home.components.ErrorHomeCard
import app.closer.ui.home.components.HomeActionColors
import app.closer.ui.home.components.HomeGlyphIcon
import app.closer.ui.home.components.HomePill
import app.closer.ui.home.components.HomeStatusStrip
import app.closer.ui.home.components.LoadingHomeCard
import app.closer.ui.home.components.WaitingForYouSection
import app.closer.ui.home.components.actionColors
import app.closer.ui.home.components.homeActionGlyph
import app.closer.ui.home.components.homePrimaryArt
@Composable
fun HomeScreen(
@ -411,136 +424,6 @@ private fun HomeContent(
}
}
@DrawableRes
private fun homeActionGlyph(target: HomeActionTarget): Int = when (target) {
HomeActionTarget.InvitePartner -> R.drawable.glyph_couple
HomeActionTarget.DailyQuestion -> R.drawable.glyph_daily_card
HomeActionTarget.AnswerHistory -> R.drawable.glyph_paired_cards
HomeActionTarget.QuestionPacks -> R.drawable.glyph_question_packs
HomeActionTarget.Settings -> R.drawable.glyph_settings
HomeActionTarget.AnswerReveal -> R.drawable.glyph_sealed_answer
HomeActionTarget.Game -> R.drawable.glyph_closer_heart_keyhole
HomeActionTarget.Challenge -> R.drawable.glyph_connection_challenge
HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart
HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule
HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay
HomeActionTarget.WeeklyRecap -> R.drawable.glyph_paired_cards
}
@Composable
private fun HomeGlyphIcon(
@DrawableRes resId: Int,
contentDescription: String?,
tint: Color,
modifier: Modifier = Modifier
) {
Icon(
imageVector = ImageVector.vectorResource(resId),
contentDescription = contentDescription,
tint = tint,
modifier = modifier
)
}
@Composable
private fun HomeStatusStrip(
streakCount: Int,
privateCount: Int,
partnerName: String?,
dailyQuestionState: DailyQuestionState,
onPartner: () -> Unit = {},
modifier: Modifier = Modifier
) {
val streakLabel = when (streakCount) {
0 -> "Start streak"
1 -> "1 night"
else -> "$streakCount nights"
}
val tonightLabel = when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Question ready"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Answer saved"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your turn"
DailyQuestionState.BOTH_ANSWERED -> "Reveal ready"
DailyQuestionState.REVEALED -> "Revealed"
}
val partnerLabel = partnerName?.takeIf { it.isNotBlank() } ?: "Paired"
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusChip(
icon = R.drawable.glyph_streak,
label = streakLabel,
modifier = Modifier.weight(1f)
)
StatusChip(
icon = R.drawable.glyph_privacy_lock,
label = "Just for two",
modifier = Modifier.weight(1f),
secondaryLabel = "$privateCount saved"
)
StatusChip(
icon = R.drawable.glyph_closer_heart_keyhole,
label = partnerLabel,
modifier = Modifier.weight(1f),
onClick = onPartner.takeIf { partnerName != null },
secondaryLabel = tonightLabel
)
}
}
@Composable
private fun StatusChip(
@DrawableRes icon: Int,
label: String,
modifier: Modifier = Modifier,
secondaryLabel: String? = null,
onClick: (() -> Unit)? = null
) {
val chipModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
Surface(
modifier = chipModifier,
shape = RoundedCornerShape(CloserRadii.Tile),
color = closerCardColor(alpha = 0.78f),
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = icon,
contentDescription = null,
tint = CloserPalette.PinkAccentDeep,
modifier = Modifier.size(17.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
secondaryLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
private fun HomeHeader(
partnerName: String?,
@ -1201,110 +1084,6 @@ private fun PrimaryHomeActionCard(
}
}
@DrawableRes
private fun homePrimaryArt(target: HomeActionTarget): Int? = when (target) {
HomeActionTarget.DailyQuestion,
HomeActionTarget.AnswerReveal -> R.drawable.illustration_home_tonight_ritual
HomeActionTarget.Challenge -> R.drawable.illustration_connection_challenges_header
else -> null
}
@Composable
private fun ActionFeedSection(
actions: List<HomeAction>,
onAction: (HomeAction) -> Unit
) {
if (actions.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "More ways to connect",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
actions.forEach { action ->
SecondaryHomeActionCard(action = action, onAction = onAction)
}
}
}
@Composable
private fun SecondaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit
) {
val colors = action.tone.actionColors()
CloserClickableCard(
onClick = { onAction(action) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = MaterialTheme.colorScheme.surface,
elevation = CloserElevations.Card
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(colors.soft, RoundedCornerShape(CloserRadii.Button)),
contentAlignment = Alignment.Center
) {
HomeGlyphIcon(
resId = homeActionGlyph(action.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = action.eyebrow,
style = MaterialTheme.typography.labelMedium,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Surface(
shape = RoundedCornerShape(CloserRadii.Button),
color = colors.soft
) {
HomeGlyphIcon(
resId = R.drawable.glyph_forward,
contentDescription = action.cta,
tint = colors.deep,
modifier = Modifier
.padding(9.dp)
.size(18.dp)
)
}
}
}
}
@Composable
private fun MomentCueCard() {
@ -1343,340 +1122,6 @@ private fun MomentCueCard() {
}
}
private data class HomeActionColors(
val soft: Color,
val accent: Color,
val deep: Color,
val onAccent: Color
)
@Composable
private fun HomeActionTone.actionColors(): HomeActionColors {
val scheme = MaterialTheme.colorScheme
if (isCloserDarkTheme()) {
return when (this) {
HomeActionTone.Daily,
HomeActionTone.Ritual,
HomeActionTone.Starter,
HomeActionTone.Pack -> HomeActionColors(
soft = scheme.secondaryContainer,
accent = scheme.secondary,
deep = scheme.secondary,
onAccent = scheme.onSecondary
)
HomeActionTone.Reflection -> HomeActionColors(
soft = scheme.tertiaryContainer,
accent = scheme.tertiary,
deep = scheme.tertiary,
onAccent = scheme.onTertiary
)
else -> HomeActionColors(
soft = scheme.primaryContainer,
accent = scheme.primary,
deep = scheme.primary,
onAccent = scheme.onPrimary
)
}
}
return when (this) {
HomeActionTone.Invite -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
HomeActionTone.Daily -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Reflection -> HomeActionColors(
soft = CloserPalette.PurpleGlow,
accent = CloserPalette.PurpleRich,
deep = CloserPalette.PurpleDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Ritual -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Starter -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Pack -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Utility -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
HomeActionTone.Pending -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
}
}
@Composable
private fun CategoryPreviewGrid(
categories: List<HomeCategorySummary>,
onCategory: (String) -> Unit,
onPacks: () -> Unit
) {
val featuredCategories = categories.take(2)
if (featuredCategories.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "More doorways",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
CloserActionButton(
label = "All packs",
onClick = onPacks,
style = CloserButtonStyle.Secondary
)
}
featuredCategories.chunked(2).forEach { rowItems ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
rowItems.forEach { item ->
CategoryMiniCard(
item = item,
modifier = Modifier.weight(1f),
onClick = { onCategory(item.category.id) }
)
}
if (rowItems.size == 1) {
Box(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CategoryMiniCard(
item: HomeCategorySummary,
modifier: Modifier,
onClick: () -> Unit
) {
CloserClickableCard(
onClick = onClick,
modifier = modifier,
containerColor = closerCardColor(alpha = 0.82f),
shape = RoundedCornerShape(CloserRadii.Card),
elevation = CloserElevations.Card
) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryGlyph(
categoryId = item.category.id,
iconName = item.category.iconName,
size = 42.dp,
iconSize = 20.dp
)
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${item.questionCount} questions",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun WaitingForYouSection(
actions: List<PendingActionCard>,
onAction: (PendingActionCard) -> Unit,
onJoinGame: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Also waiting",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
actions.take(3).forEach { card ->
PendingActionCardView(
card = card,
onClick = {
if (card.target == HomeActionTarget.Game) onJoinGame() else onAction(card)
}
)
}
}
}
@Composable
private fun PendingActionCardView(
card: PendingActionCard,
onClick: () -> Unit
) {
val colors = HomeActionTone.Pending.actionColors()
CloserClickableCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = MaterialTheme.colorScheme.surface,
elevation = CloserElevations.Card
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(colors.soft, RoundedCornerShape(CloserRadii.Button)),
contentAlignment = Alignment.Center
) {
HomeGlyphIcon(
resId = homeActionGlyph(card.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = card.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
card.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
HomeGlyphIcon(
resId = R.drawable.glyph_forward,
contentDescription = "Open",
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
}
}
@Composable
private fun LoadingHomeCard() {
LoadingState(message = "Opening your dashboard")
}
@Composable
private fun ErrorHomeCard(
message: String,
onRefresh: () -> Unit
) {
ErrorState(
title = "Home paused",
message = message,
retryLabel = "Retry",
onRetry = onRefresh
)
}
@Composable
private fun EmptyHomeContent(
dailyQuestion: Question?,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = closerCardColor(alpha = 0.82f),
elevation = CloserElevations.Card
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "You're all caught up",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Nothing needs your attention right now. Come back later or explore a pack together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CloserActionButton(
label = "Browse packs",
onClick = onPacks,
style = CloserButtonStyle.Secondary
)
}
}
}
}
@Composable
private fun HomePill(label: String) {
CloserPill(
label = label,
containerColor = if (isCloserDarkTheme()) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.78f)
} else {
CloserPalette.PinkMist.copy(alpha = 0.72f)
},
contentColor = MaterialTheme.colorScheme.onSurface
)
}
@Preview
@Composable
fun HomeScreenPreview() {

View File

@ -0,0 +1,124 @@
package app.closer.ui.home.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.ui.components.CloserClickableCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserRadii
import app.closer.ui.home.HomeAction
/** Home's "More ways to connect" secondary action feed. Extracted from HomeScreen.kt. */
@Composable
fun ActionFeedSection(
actions: List<HomeAction>,
onAction: (HomeAction) -> Unit
) {
if (actions.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "More ways to connect",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
actions.forEach { action ->
SecondaryHomeActionCard(action = action, onAction = onAction)
}
}
}
@Composable
private fun SecondaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit
) {
val colors = action.tone.actionColors()
CloserClickableCard(
onClick = { onAction(action) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = MaterialTheme.colorScheme.surface,
elevation = CloserElevations.Card
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(colors.soft, RoundedCornerShape(CloserRadii.Button)),
contentAlignment = Alignment.Center
) {
HomeGlyphIcon(
resId = homeActionGlyph(action.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = action.eyebrow,
style = MaterialTheme.typography.labelMedium,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Surface(
shape = RoundedCornerShape(CloserRadii.Button),
color = colors.soft
) {
HomeGlyphIcon(
resId = R.drawable.glyph_forward,
contentDescription = action.cta,
tint = colors.deep,
modifier = Modifier
.padding(9.dp)
.size(18.dp)
)
}
}
}
}

View File

@ -0,0 +1,162 @@
package app.closer.ui.home.components
import androidx.annotation.DrawableRes
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import app.closer.R
import app.closer.ui.components.CloserPill
import app.closer.ui.home.HomeActionTarget
import app.closer.ui.home.HomeActionTone
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.isCloserDarkTheme
/**
* Shared low-level style kit for the Home cards glyph/art resolution, the icon wrapper, the pill,
* and the per-tone color set. Extracted from HomeScreen.kt so every Home component cluster
* (`HomePrimaryCards`, `HomeStatusStrip`, `HomeActionFeed`, ) can reuse it. Pure/theme-only, no state.
*/
@DrawableRes
fun homeActionGlyph(target: HomeActionTarget): Int = when (target) {
HomeActionTarget.InvitePartner -> R.drawable.glyph_couple
HomeActionTarget.DailyQuestion -> R.drawable.glyph_daily_card
HomeActionTarget.AnswerHistory -> R.drawable.glyph_paired_cards
HomeActionTarget.QuestionPacks -> R.drawable.glyph_question_packs
HomeActionTarget.Settings -> R.drawable.glyph_settings
HomeActionTarget.AnswerReveal -> R.drawable.glyph_sealed_answer
HomeActionTarget.Game -> R.drawable.glyph_closer_heart_keyhole
HomeActionTarget.Challenge -> R.drawable.glyph_connection_challenge
HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart
HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule
HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay
HomeActionTarget.WeeklyRecap -> R.drawable.glyph_paired_cards
}
@Composable
fun HomeGlyphIcon(
@DrawableRes resId: Int,
contentDescription: String?,
tint: Color,
modifier: Modifier = Modifier
) {
Icon(
imageVector = ImageVector.vectorResource(resId),
contentDescription = contentDescription,
tint = tint,
modifier = modifier
)
}
@DrawableRes
fun homePrimaryArt(target: HomeActionTarget): Int? = when (target) {
HomeActionTarget.DailyQuestion,
HomeActionTarget.AnswerReveal -> R.drawable.illustration_home_tonight_ritual
HomeActionTarget.Challenge -> R.drawable.illustration_connection_challenges_header
else -> null
}
data class HomeActionColors(
val soft: Color,
val accent: Color,
val deep: Color,
val onAccent: Color
)
@Composable
fun HomeActionTone.actionColors(): HomeActionColors {
val scheme = MaterialTheme.colorScheme
if (isCloserDarkTheme()) {
return when (this) {
HomeActionTone.Daily,
HomeActionTone.Ritual,
HomeActionTone.Starter,
HomeActionTone.Pack -> HomeActionColors(
soft = scheme.secondaryContainer,
accent = scheme.secondary,
deep = scheme.secondary,
onAccent = scheme.onSecondary
)
HomeActionTone.Reflection -> HomeActionColors(
soft = scheme.tertiaryContainer,
accent = scheme.tertiary,
deep = scheme.tertiary,
onAccent = scheme.onTertiary
)
else -> HomeActionColors(
soft = scheme.primaryContainer,
accent = scheme.primary,
deep = scheme.primary,
onAccent = scheme.onPrimary
)
}
}
return when (this) {
HomeActionTone.Invite -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
HomeActionTone.Daily -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Reflection -> HomeActionColors(
soft = CloserPalette.PurpleGlow,
accent = CloserPalette.PurpleRich,
deep = CloserPalette.PurpleDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Ritual -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Starter -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Pack -> HomeActionColors(
soft = CloserPalette.PinkSoft,
accent = CloserPalette.PinkBright,
deep = CloserPalette.PinkAccentDeep,
onAccent = Color(0xFF24122F)
)
HomeActionTone.Utility -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
HomeActionTone.Pending -> HomeActionColors(
soft = CloserPalette.PurpleSoft,
accent = MaterialTheme.colorScheme.primary,
deep = CloserPalette.PurpleDeep,
onAccent = MaterialTheme.colorScheme.onPrimary
)
}
}
@Composable
fun HomePill(label: String) {
CloserPill(
label = label,
containerColor = if (isCloserDarkTheme()) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.78f)
} else {
CloserPalette.PinkMist.copy(alpha = 0.72f)
},
contentColor = MaterialTheme.colorScheme.onSurface
)
}

View File

@ -0,0 +1,115 @@
package app.closer.ui.home.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserClickableCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserRadii
import app.closer.ui.home.HomeCategorySummary
import app.closer.ui.questions.displayCategoryName
import app.closer.ui.theme.closerCardColor
/** Home's "More doorways" category preview grid. Extracted from HomeScreen.kt. */
@Composable
fun CategoryPreviewGrid(
categories: List<HomeCategorySummary>,
onCategory: (String) -> Unit,
onPacks: () -> Unit
) {
val featuredCategories = categories.take(2)
if (featuredCategories.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "More doorways",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
)
CloserActionButton(
label = "All packs",
onClick = onPacks,
style = CloserButtonStyle.Secondary
)
}
featuredCategories.chunked(2).forEach { rowItems ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
rowItems.forEach { item ->
CategoryMiniCard(
item = item,
modifier = Modifier.weight(1f),
onClick = { onCategory(item.category.id) }
)
}
if (rowItems.size == 1) {
Box(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CategoryMiniCard(
item: HomeCategorySummary,
modifier: Modifier,
onClick: () -> Unit
) {
CloserClickableCard(
onClick = onClick,
modifier = modifier,
containerColor = closerCardColor(alpha = 0.82f),
shape = RoundedCornerShape(CloserRadii.Card),
elevation = CloserElevations.Card
) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryGlyph(
categoryId = item.category.id,
iconName = item.category.iconName,
size = 42.dp,
iconSize = 20.dp
)
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${item.questionCount} questions",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@ -0,0 +1,116 @@
package app.closer.ui.home.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.ui.components.CloserClickableCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserRadii
import app.closer.ui.home.HomeActionTarget
import app.closer.ui.home.HomeActionTone
import app.closer.ui.home.PendingActionCard
/** Home's "Also waiting" pending-action list. Extracted from HomeScreen.kt. */
@Composable
fun WaitingForYouSection(
actions: List<PendingActionCard>,
onAction: (PendingActionCard) -> Unit,
onJoinGame: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Also waiting",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
actions.take(3).forEach { card ->
PendingActionCardView(
card = card,
onClick = {
if (card.target == HomeActionTarget.Game) onJoinGame() else onAction(card)
}
)
}
}
}
@Composable
private fun PendingActionCardView(
card: PendingActionCard,
onClick: () -> Unit
) {
val colors = HomeActionTone.Pending.actionColors()
CloserClickableCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = MaterialTheme.colorScheme.surface,
elevation = CloserElevations.Card
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(colors.soft, RoundedCornerShape(CloserRadii.Button)),
contentAlignment = Alignment.Center
) {
HomeGlyphIcon(
resId = homeActionGlyph(card.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = card.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
card.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
HomeGlyphIcon(
resId = R.drawable.glyph_forward,
contentDescription = "Open",
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@ -0,0 +1,82 @@
package app.closer.ui.home.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.closer.domain.model.Question
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserRadii
import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.theme.closerCardColor
/** Home's loading / error / all-caught-up states. Extracted from HomeScreen.kt. */
@Composable
fun LoadingHomeCard() {
LoadingState(message = "Opening your dashboard")
}
@Composable
fun ErrorHomeCard(
message: String,
onRefresh: () -> Unit
) {
ErrorState(
title = "Home paused",
message = message,
retryLabel = "Retry",
onRetry = onRefresh
)
}
@Composable
fun EmptyHomeContent(
dailyQuestion: Question?,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = closerCardColor(alpha = 0.82f),
elevation = CloserElevations.Card
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "You're all caught up",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Nothing needs your attention right now. Come back later or explore a pack together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CloserActionButton(
label = "Browse packs",
onClick = onPacks,
style = CloserButtonStyle.Secondary
)
}
}
}
}

View File

@ -0,0 +1,126 @@
package app.closer.ui.home.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.ui.components.CloserRadii
import app.closer.ui.home.DailyQuestionState
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
/** Home's 3-chip status strip (streak / private / partner-tonight). Extracted from HomeScreen.kt. */
@Composable
fun HomeStatusStrip(
streakCount: Int,
privateCount: Int,
partnerName: String?,
dailyQuestionState: DailyQuestionState,
onPartner: () -> Unit = {},
modifier: Modifier = Modifier
) {
val streakLabel = when (streakCount) {
0 -> "Start streak"
1 -> "1 night"
else -> "$streakCount nights"
}
val tonightLabel = when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Question ready"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Answer saved"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your turn"
DailyQuestionState.BOTH_ANSWERED -> "Reveal ready"
DailyQuestionState.REVEALED -> "Revealed"
}
val partnerLabel = partnerName?.takeIf { it.isNotBlank() } ?: "Paired"
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusChip(
icon = R.drawable.glyph_streak,
label = streakLabel,
modifier = Modifier.weight(1f)
)
StatusChip(
icon = R.drawable.glyph_privacy_lock,
label = "Just for two",
modifier = Modifier.weight(1f),
secondaryLabel = "$privateCount saved"
)
StatusChip(
icon = R.drawable.glyph_closer_heart_keyhole,
label = partnerLabel,
modifier = Modifier.weight(1f),
onClick = onPartner.takeIf { partnerName != null },
secondaryLabel = tonightLabel
)
}
}
@Composable
private fun StatusChip(
@DrawableRes icon: Int,
label: String,
modifier: Modifier = Modifier,
secondaryLabel: String? = null,
onClick: (() -> Unit)? = null
) {
val chipModifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier
Surface(
modifier = chipModifier,
shape = RoundedCornerShape(CloserRadii.Tile),
color = closerCardColor(alpha = 0.78f),
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = icon,
contentDescription = null,
tint = CloserPalette.PinkAccentDeep,
modifier = Modifier.size(17.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
secondaryLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}