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:
parent
c13dbc5bfd
commit
94360a5438
|
|
@ -92,10 +92,12 @@ import app.closer.ui.home.components.CategoryPreviewGrid
|
||||||
import app.closer.ui.home.components.EmptyHomeContent
|
import app.closer.ui.home.components.EmptyHomeContent
|
||||||
import app.closer.ui.home.components.ErrorHomeCard
|
import app.closer.ui.home.components.ErrorHomeCard
|
||||||
import app.closer.ui.home.components.HomeActionColors
|
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.HomeGlyphIcon
|
||||||
import app.closer.ui.home.components.HomePill
|
import app.closer.ui.home.components.HomePill
|
||||||
import app.closer.ui.home.components.HomeStatusStrip
|
import app.closer.ui.home.components.HomeStatusStrip
|
||||||
import app.closer.ui.home.components.LoadingHomeCard
|
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.PartnerActivationCard
|
||||||
import app.closer.ui.home.components.PrimaryHomeActionCard
|
import app.closer.ui.home.components.PrimaryHomeActionCard
|
||||||
import app.closer.ui.home.components.WaitingForYouSection
|
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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
Loading…
Reference in New Issue