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