Compare commits

...

10 Commits

Author SHA1 Message Date
null 415f8d6866 refactor(home): extract StreakMilestoneDialog + drop dead MomentCueCard
Step 9/9 of the UI split. StreakMilestoneDialog -> components/ (kept internal;
ArtPreviewScreen call updated to the new package). MomentCueCard was dead (no
callers) — removed. Orphaned imports cleaned. compileDebugKotlin green.
HomeScreen.kt now ~620 lines (from 1921).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:34:37 -05:00
null 5e266a95fa merge(home): HomeScreen UI decomposition (8 cluster files) into dev
Behavior-preserving split of HomeScreen.kt (1921 -> 712 lines) into
ui/home/components/: HomeActionStyle, HomeStateCards, HomeCategoryPreview,
HomePendingCards, HomeActionFeed, HomeStatusStrip, HomePrimaryCards,
HomeHeaderSection. Each step compile-gated. ViewModel extraction + tests still
pending (tracked separately).
2026-07-11 19:59:45 -05:00
null 94360a5438 refactor(home): extract header + partner sheet -> components/HomeHeaderSection.kt
Step 8/9. Verbatim move (HomeHeader + PartnerQuickActionsSheet public, rest private;
@OptIn(ExperimentalMaterial3Api) + .semantics a11y + full state param preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:43:22 -05:00
null c13dbc5bfd refactor(home): extract primary/activation cards -> components/HomePrimaryCards.kt
Step 7/9. Verbatim move (activation + primary hero public, benefit-pill private).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:40:08 -05:00
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
11 changed files with 1580 additions and 1326 deletions

View File

@ -45,7 +45,7 @@ fun ArtPreviewScreen(onNavigate: (String) -> Unit = {}) {
var showStreak by remember { mutableStateOf(false) }
if (showStreak) {
app.closer.ui.home.StreakMilestoneDialog(
app.closer.ui.home.components.StreakMilestoneDialog(
milestone = 7,
partnerName = "Sofia",
onDismiss = { showStreak = false }

File diff suppressed because it is too large Load Diff

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,354 @@
package app.closer.ui.home.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.core.navigation.AppRoute
import app.closer.crypto.FieldEncryptor
import app.closer.ui.home.DailyQuestionState
import app.closer.ui.home.HomeUiState
import app.closer.ui.theme.CloserPalette
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
/** Home's header row (title + streak + partner presence bubble) and the partner quick-actions bottom
* sheet. Extracted from HomeScreen.kt. */
@Composable
fun HomeHeader(
partnerName: String?,
partnerPhotoUrl: String?,
streakCount: Int,
isPaired: Boolean,
unreadActivityCount: Int = 0,
onTogether: () -> Unit = {}
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount nights")
}
// Partner/together entry point with an unread badge.
Box {
PartnerHeaderBubble(
partnerName = partnerName,
partnerPhotoUrl = partnerPhotoUrl,
isPaired = isPaired,
onClick = onTogether
)
if (unreadActivityCount > 0) {
// Surface-ringed dot so it stays legible over the avatar photo or the gradient ring.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(14.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(CloserPalette.PinkBright)
)
}
}
}
}
Text(
text = if (!isPaired)
"Set up your shared space, then keep exploring at your own pace."
else if (partnerName != null)
"Connected with $partnerName. Here's what matters tonight."
else
"Open the app, see what matters, and take one small step toward closeness.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PartnerHeaderBubble(
partnerName: String?,
partnerPhotoUrl: String?,
isPaired: Boolean,
onClick: () -> Unit
) {
// Brand gradient ring around the avatar — signals "your partner" + that it's tappable.
val ringBrush = Brush.linearGradient(
listOf(CloserPalette.PurpleRich, CloserPalette.PinkBright)
)
val label = when {
!isPaired -> "Invite your partner"
!partnerName.isNullOrBlank() -> "Open $partnerName's space"
else -> "Open your shared space"
}
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(ringBrush)
.clickable(onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(CloserPalette.PurpleSoft),
contentAlignment = Alignment.Center
) {
when {
!isPaired -> HomeGlyphIcon(
resId = R.drawable.glyph_couple,
contentDescription = null,
tint = CloserPalette.PurpleRich,
modifier = Modifier.size(24.dp)
)
!partnerPhotoUrl.isNullOrBlank() -> SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(partnerPhotoUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape),
// While loading or on failure, keep the partner's initials centered in view
// (never a blank/grey circle).
loading = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
},
error = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
}
)
else -> PartnerInitials(partnerName = partnerName)
}
}
}
}
@Composable
private fun PartnerInitials(partnerName: String?) {
Text(
text = partnerInitials(partnerName),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.PurpleRich,
maxLines = 1
)
}
private fun partnerInitials(name: String?): String {
val clean = name?.trim().orEmpty()
if (clean.isBlank()) return "2"
val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() }
val initials = if (words.size >= 2) {
"${words[0].first()}${words[1].first()}"
} else {
clean.take(2)
}
return initials.uppercase()
}
/**
* Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap
* actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PartnerQuickActionsSheet(
state: HomeUiState,
onDismiss: () -> Unit,
onNavigate: (String) -> Unit,
onThinkingOfYou: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
// A locked/blank name (E2EE key missing on this device) must never surface ciphertext/placeholder.
val name = state.partnerName
?.takeIf { it.isNotBlank() && it != FieldEncryptor.LOCKED_PLACEHOLDER }
?: "Your partner"
val glance = remember(state.streakCount, state.togetherSince) {
buildList {
if (state.streakCount > 0) {
add("💜 ${state.streakCount} ${if (state.streakCount == 1) "night" else "nights"}")
}
if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}")
}.joinToString(" · ")
}
// Living "today" status — reflects where the couple is in the daily ritual. Hidden when there's no
// question assigned (no misleading "still open").
val todayStatus = state.dailyQuestion?.let {
when (state.dailyQuestionState) {
DailyQuestionState.BOTH_ANSWERED, DailyQuestionState.REVEALED -> "You've both answered today 💜"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "They answered — your turn"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Waiting on their answer"
DailyQuestionState.UNANSWERED -> "Tonight's question is still open"
}
}
val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = openPartnerPage)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
PartnerHeaderBubble(
partnerName = name,
partnerPhotoUrl = state.partnerPhotoUrl,
isPaired = true,
onClick = openPartnerPage
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = name,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (glance.isNotBlank()) {
Text(
text = glance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
todayStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction(
R.drawable.glyph_paired_cards,
"Together",
trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" }
) { onNavigate(AppRoute.ACTIVITY); onDismiss() }
PartnerSheetAction(R.drawable.glyph_memory_capsule, "Our memories") { onNavigate(AppRoute.MEMORY_LANE); onDismiss() }
PartnerSheetAction(R.drawable.glyph_settings, "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() }
}
}
}
@Composable
private fun PartnerSheetAction(
@DrawableRes iconRes: Int,
label: String,
enabled: Boolean = true,
trailing: String? = null,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 8.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = iconRes,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
)
if (trailing != null) {
Surface(shape = CircleShape, color = CloserPalette.PinkBright) {
Text(
text = trailing,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
}
}
private fun formatMonthYear(millis: Long): String =
java.text.SimpleDateFormat("MMM yyyy", java.util.Locale.getDefault()).format(java.util.Date(millis))

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,397 @@
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.height
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.domain.model.Question
import app.closer.ui.components.BrandIllustration
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.home.DailyQuestionState
import app.closer.ui.home.HomeAction
import app.closer.ui.home.HomeActionTarget
import app.closer.ui.home.HomeActionTone
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.isCloserDarkTheme
/** Home's hero cards the unpaired partner-activation card and the primary daily-question/action
* card (with its per-state title/body/CTA overrides). Extracted from HomeScreen.kt. */
@Composable
fun PartnerActivationCard(
onInvite: () -> Unit,
onAcceptInvite: () -> Unit
) {
val isDark = isCloserDarkTheme()
val inviteTone = HomeActionTone.Invite.actionColors()
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.94f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
CloserPalette.PurpleSoft,
CloserPalette.PinkMist.copy(alpha = 0.84f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("1 of 2 connected")
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.76f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = R.drawable.glyph_lock,
contentDescription = null,
tint = inviteTone.deep,
modifier = Modifier.size(14.dp)
)
Text(
text = "Private invite",
style = MaterialTheme.typography.labelSmall,
color = inviteTone.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
BrandIllustration(
res = R.drawable.illustration_partner_activation,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(142.dp)
.clip(RoundedCornerShape(22.dp))
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "A private space for two",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ActivationBenefitPill("Private reveals", Modifier.weight(1f))
ActivationBenefitPill("Shared streak", Modifier.weight(1f))
ActivationBenefitPill("Games for two", Modifier.weight(1f))
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
CloserActionButton(
label = "Invite partner",
onClick = onInvite,
modifier = Modifier.fillMaxWidth(),
containerColor = inviteTone.accent,
contentColor = inviteTone.onAccent
)
CloserActionButton(
label = "Enter code",
onClick = onAcceptInvite,
modifier = Modifier.fillMaxWidth(),
style = CloserButtonStyle.Secondary,
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
contentColor = inviteTone.deep
)
}
}
}
}
@Composable
private fun ActivationBenefitPill(
label: String,
modifier: Modifier = Modifier
) {
val colors = HomeActionTone.Invite.actionColors()
Surface(
modifier = modifier,
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.66f)
) {
Text(
text = label,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 9.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelSmall,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun PrimaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
dailyQuestionState: DailyQuestionState,
dailyQuestion: Question?
) {
val colors = action.tone.actionColors()
val isDark = isCloserDarkTheme()
// Daily-question art stays people-forward while keeping the two states distinct.
val artRes = if (action.target == HomeActionTarget.DailyQuestion) {
when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> R.drawable.illustration_tonight_partner_prompt
DailyQuestionState.BOTH_ANSWERED -> R.drawable.illustration_daily_reveal_ready
else -> homePrimaryArt(action.target)
}
} else {
homePrimaryArt(action.target)
}
// For daily-question actions, route the CTA through the explicit state handlers
// so the same button label maps to the correct next step (answer, remind,
// reveal, or follow-up) even if the action target is still DailyQuestion.
val ctaClick = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> onReminder
DailyQuestionState.BOTH_ANSWERED -> onReveal
DailyQuestionState.REVEALED -> onFollowUp
else -> { { onAction(action) } }
}
else -> { { onAction(action) } }
}
val titleOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Tonight's question is ready."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "You showed up tonight. Waiting for your partner."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your partner answered. Your turn."
DailyQuestionState.BOTH_ANSWERED -> "Your reveal is waiting"
DailyQuestionState.REVEALED -> "You opened a conversation tonight."
}
else -> action.title
}
val bodyOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED ->
dailyQuestion?.text ?: "Answer tonight's question privately, then choose when to share."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING ->
"Your answer is private until they answer too. No pressure — the reveal waits for both of you."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING ->
"Answer to unlock the reveal. Your response stays private until you are ready."
DailyQuestionState.BOTH_ANSWERED ->
"You both answered — open it together when you're ready."
DailyQuestionState.REVEALED ->
"You revealed an answer together. What comes next is up to both of you."
}
else -> action.body
}
val timeBudgetLabel = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestion?.type) {
"scale" -> "~1 min"
"single_choice" -> "~2 min"
"this_or_that" -> "~2 min"
else -> "~3 min"
}
else -> null
}
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.92f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
colors.soft.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
colors.soft,
CloserPalette.PinkMist.copy(alpha = 0.72f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill(action.eyebrow)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
timeBudgetLabel?.let { label ->
HomePill(label)
}
action.metric?.let { HomePill(it) }
}
}
artRes?.let { res ->
BrandIllustration(
res = res,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(22.dp))
)
}
if (artRes != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(CloserRadii.Tile),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
HomeGlyphIcon(
resId = homeActionGlyph(action.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
CloserActionButton(
label = action.cta,
onClick = ctaClick,
modifier = Modifier.fillMaxWidth(),
containerColor = colors.accent,
contentColor = colors.onAccent
)
}
}
}

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
)
}
}
}
}
}

View File

@ -0,0 +1,85 @@
package app.closer.ui.home.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.closer.R
import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.CloserActionButton
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsSoft
/** One-time streak-milestone celebration dialog. Extracted from HomeScreen.kt; kept `internal`
* because ui/debug/ArtPreviewScreen also renders it. */
@Composable
internal fun StreakMilestoneDialog(
milestone: Int,
partnerName: String?,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) {
Surface(
shape = RoundedCornerShape(28.dp),
color = SettingsSoft,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.illustration_streak_milestone),
contentDescription = null,
modifier = Modifier.size(156.dp)
)
Spacer(Modifier.height(18.dp))
Text(
text = if (milestone == 1) "Your first night together" else "$milestone nights together",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = SettingsInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
val nights = if (milestone == 1) "your first night" else "$milestone nights running"
Text(
text = partnerName?.takeIf { it.isNotBlank() }
?.let { "You and $it keep showing up for each other — $nights. The small moments are adding up to something." }
?: "You keep showing up — $nights. The small moments are adding up to something.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CloserActionButton(
label = "Keep going together",
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
)
}
}
CelebrationOverlay(visible = true, intensity = 1.4f)
}
}
}