diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index e3759211..99e2de18 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -87,6 +87,23 @@ 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.HomeHeader +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.PartnerQuickActionsSheet +import app.closer.ui.home.components.PartnerActivationCard +import app.closer.ui.home.components.PrimaryHomeActionCard +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,900 +428,8 @@ 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?, - 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 -private 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)) - -@Composable -private 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 -private 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 - ) - } - } -} - -@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, - 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 +468,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, - 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, - 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() { diff --git a/app/src/main/java/app/closer/ui/home/components/HomeActionFeed.kt b/app/src/main/java/app/closer/ui/home/components/HomeActionFeed.kt new file mode 100644 index 00000000..8c4b1ff6 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeActionFeed.kt @@ -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, + 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) + ) + } + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeActionStyle.kt b/app/src/main/java/app/closer/ui/home/components/HomeActionStyle.kt new file mode 100644 index 00000000..9889d30b --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeActionStyle.kt @@ -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 + ) +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeCategoryPreview.kt b/app/src/main/java/app/closer/ui/home/components/HomeCategoryPreview.kt new file mode 100644 index 00000000..9791dd5c --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeCategoryPreview.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt b/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt new file mode 100644 index 00000000..19e61401 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt @@ -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)) diff --git a/app/src/main/java/app/closer/ui/home/components/HomePendingCards.kt b/app/src/main/java/app/closer/ui/home/components/HomePendingCards.kt new file mode 100644 index 00000000..8f877a79 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomePendingCards.kt @@ -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, + 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) + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomePrimaryCards.kt b/app/src/main/java/app/closer/ui/home/components/HomePrimaryCards.kt new file mode 100644 index 00000000..1961db71 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomePrimaryCards.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeStateCards.kt b/app/src/main/java/app/closer/ui/home/components/HomeStateCards.kt new file mode 100644 index 00000000..36ac4148 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeStateCards.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeStatusStrip.kt b/app/src/main/java/app/closer/ui/home/components/HomeStatusStrip.kt new file mode 100644 index 00000000..44792909 --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/components/HomeStatusStrip.kt @@ -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 + ) + } + } + } + } +}