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>
This commit is contained in:
null 2026-07-11 19:43:22 -05:00
parent c13dbc5bfd
commit 94360a5438
2 changed files with 356 additions and 306 deletions

View File

@ -92,10 +92,12 @@ 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
@ -426,312 +428,6 @@ private fun HomeContent(
}
}
@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))

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