fix(home): update HomeScreen and HomeViewModel

This commit is contained in:
null 2026-06-16 03:25:03 -05:00
parent 28f4be0286
commit 2b8db4288c
2 changed files with 442 additions and 382 deletions

View File

@ -1,12 +1,5 @@
package com.couplesconnect.app.ui.home package com.couplesconnect.app.ui.home
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -19,18 +12,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -41,7 +32,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -51,11 +41,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.ui.answers.revealSummary
import com.couplesconnect.app.ui.components.SpecialDatesSection
import com.couplesconnect.app.ui.questions.displayCategoryName import com.couplesconnect.app.ui.questions.displayCategoryName
@Composable @Composable
@ -93,7 +80,7 @@ private fun HomeContent(
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.linearGradient( Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)), listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero, start = Offset.Zero,
end = Offset.Infinite end = Offset.Infinite
) )
@ -117,37 +104,42 @@ private fun HomeContent(
state.isLoading -> LoadingHomeCard() state.isLoading -> LoadingHomeCard()
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh) state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
else -> { else -> {
TodayOverviewCard( val onActionSelected: (HomeAction) -> Unit = { action ->
question = state.dailyQuestion, when (action.target) {
HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion()
HomeActionTarget.AnswerHistory -> onHistory()
HomeActionTarget.QuestionPacks -> {
action.categoryId?.let(onCategory) ?: onPacks()
}
HomeActionTarget.Settings -> onSettings()
}
}
state.primaryAction?.let { action ->
PrimaryHomeActionCard(
action = action,
stats = state.answerStats, stats = state.answerStats,
onDailyQuestion = onDailyQuestion, streakCount = state.streakCount,
onHistory = onHistory, onAction = onActionSelected
onPacks = onPacks
) )
SpecialDatesSection(compact = true) }
LatestAnswerCard(
latest = state.answerStats.latest, ActionFeedSection(
onHistory = onHistory actions = state.secondaryActions,
onAction = onActionSelected
) )
MomentCueCard()
CategoryPreviewGrid( CategoryPreviewGrid(
categories = state.categories, categories = state.categories,
onCategory = onCategory, onCategory = onCategory,
onPacks = onPacks onPacks = onPacks
) )
SettingsStrip(onSettings = onSettings)
} }
} }
} }
if (!state.isPaired && !state.isLoading) {
PulsingInviteFab(
onClick = onInvite,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(24.dp)
)
}
} }
} }
@ -163,20 +155,20 @@ private fun HomeHeader(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Tonights connection", text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F), color = Color(0xFF27211F),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (streakCount > 0) { if (streakCount > 0) {
HomePill("$streakCount day streak") HomePill("$streakCount nights showing up")
} }
} }
Text( Text(
text = if (partnerName != null) text = if (partnerName != null)
"Connected with $partnerName. Keep the conversation going." "Connected with $partnerName. One clear next step, then the rest can stay quiet."
else else
"A quiet home for todays prompt, saved reflections, and the next conversation worth opening.", "Open the app, see what matters, and take one small step toward closeness.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642) color = Color(0xFF4E4642)
) )
@ -184,115 +176,30 @@ private fun HomeHeader(
} }
@Composable @Composable
private fun PulsingInviteFab( private fun PrimaryHomeActionCard(
onClick: () -> Unit, action: HomeAction,
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "fab_pulse")
val ring1Scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 2.2f,
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "ring1"
)
val ring1Alpha by infiniteTransition.animateFloat(
initialValue = 0.6f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "ring1a"
)
val ring2Scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 2.2f,
animationSpec = infiniteRepeatable(
animation = tween(1400, delayMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "ring2"
)
val ring2Alpha by infiniteTransition.animateFloat(
initialValue = 0.6f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(1400, delayMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "ring2a"
)
val fabScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.08f,
animationSpec = infiniteRepeatable(
animation = tween(700, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "fabscale"
)
Box(
modifier = modifier.size(72.dp),
contentAlignment = Alignment.Center
) {
// Expanding ring 1
Box(
modifier = Modifier
.size(56.dp)
.scale(ring1Scale)
.background(
color = Color(0xFFB98AF4).copy(alpha = ring1Alpha),
shape = CircleShape
)
)
// Expanding ring 2 (offset start)
Box(
modifier = Modifier
.size(56.dp)
.scale(ring2Scale)
.background(
color = Color(0xFFB98AF4).copy(alpha = ring2Alpha),
shape = CircleShape
)
)
// FAB
FloatingActionButton(
onClick = onClick,
modifier = Modifier.size(56.dp).scale(fabScale),
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236),
shape = CircleShape
) {
Icon(
Icons.Filled.Add,
contentDescription = "Invite partner",
modifier = Modifier.size(28.dp)
)
}
}
}
@Composable
private fun TodayOverviewCard(
question: Question?,
stats: HomeAnswerStats, stats: HomeAnswerStats,
onDailyQuestion: () -> Unit, streakCount: Int,
onHistory: () -> Unit, onAction: (HomeAction) -> Unit
onPacks: () -> Unit
) { ) {
val colors = action.tone.actionColors()
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.dp), shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.92f)),
elevation = CardDefaults.cardElevation(defaultElevation = 14.dp) elevation = CardDefaults.cardElevation(defaultElevation = 18.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier
.background(
Brush.linearGradient(
listOf(Color.White, colors.soft, Color(0xFFFFF7FB)),
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Row( Row(
@ -300,104 +207,104 @@ private fun TodayOverviewCard(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
HomePill("Daily ritual") HomePill(action.eyebrow)
question?.let { HomePill(it.category.displayCategoryName()) } action.metric?.let { HomePill(it) }
} }
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text( Text(
text = question?.text ?: "Your next question is ready.", text = action.title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
fontWeight = FontWeight.SemiBold, color = Color(0xFF261D2E),
color = Color(0xFF27211F),
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
color = Color(0xFFF8F0FF)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = "Next best action", text = action.body,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF6B4A86), color = Color(0xFF4D4354),
fontWeight = FontWeight.SemiBold maxLines = 4,
overflow = TextOverflow.Ellipsis
) )
Text(
text = if (stats.private > 0) "${stats.private} private" else "${stats.total} saved",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF6B4A86)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OverviewMetric(
label = "Saved",
value = stats.total.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
OverviewMetric(
label = "Revealed",
value = stats.revealed.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
OverviewMetric(
label = "Private",
value = stats.private.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
}
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { HomePulseStrip(stats = stats, streakCount = streakCount)
Button( Button(
onClick = onDailyQuestion, onClick = { onAction(action) },
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4), containerColor = colors.accent,
contentColor = Color(0xFF271236) contentColor = colors.onAccent
) )
) { ) {
Text("Answer") Text(action.cta)
}
OutlinedButton(
onClick = onPacks,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Packs")
}
} }
} }
} }
} }
@Composable @Composable
private fun OverviewMetric( private fun HomePulseStrip(
stats: HomeAnswerStats,
streakCount: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
PulseMetric(
label = "Saved",
value = stats.total.toString(),
modifier = Modifier.weight(1f)
)
PulseMetric(
label = "Private",
value = stats.private.toString(),
modifier = Modifier.weight(1f)
)
PulseMetric(
label = "Nights",
value = streakCount.toString(),
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun PulseMetric(
label: String, label: String,
value: String, value: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
onClick: () -> Unit
) { ) {
Card( Surface(
onClick = onClick,
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.72f)), color = Color.White.copy(alpha = 0.66f)
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
@ -405,14 +312,14 @@ private fun OverviewMetric(
) { ) {
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF5F3A87), color = Color(0xFF56336F),
maxLines = 1 maxLines = 1
) )
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642), color = Color(0xFF5A5060),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -421,161 +328,175 @@ private fun OverviewMetric(
} }
@Composable @Composable
private fun DailyQuestionCard( private fun ActionFeedSection(
question: Question?, actions: List<HomeAction>,
onDailyQuestion: () -> Unit, onAction: (HomeAction) -> Unit
onPacks: () -> Unit
) { ) {
Card( if (actions.isEmpty()) return
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.dp), Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("Daily ritual")
question?.let { HomePill(it.category.displayCategoryName()) }
}
Text( Text(
text = question?.text ?: "Your next question is ready.", text = "After that",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
fontWeight = FontWeight.SemiBold, color = Color(0xFF2C2233)
color = Color(0xFF27211F)
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { actions.forEach { action ->
Button( SecondaryHomeActionCard(action = action, onAction = onAction)
onClick = onDailyQuestion,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
)
) {
Text("Open")
}
OutlinedButton(
onClick = onPacks,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Packs")
}
}
} }
} }
} }
@Composable @Composable
private fun AnswerStatsRow( private fun SecondaryHomeActionCard(
stats: HomeAnswerStats, action: HomeAction,
onHistory: () -> Unit onAction: (HomeAction) -> Unit
) { ) {
Row( val colors = action.tone.actionColors()
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
label = "Saved",
value = stats.total.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
StatCard(
label = "Revealed",
value = stats.revealed.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
StatCard(
label = "Private",
value = stats.private.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
}
}
@Composable
private fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Card( Card(
onClick = onClick, onClick = { onAction(action) },
modifier = modifier, modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)), colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
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(15.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(20.dp)
)
}
Column( Column(
modifier = Modifier.padding(14.dp), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text( Text(
text = value, text = action.eyebrow,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF5F3A87)
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642), color = colors.deep,
fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text(
text = action.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.body,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5A5060),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Surface(
shape = RoundedCornerShape(14.dp),
color = colors.soft
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = action.cta,
tint = colors.deep,
modifier = Modifier
.padding(9.dp)
.size(18.dp)
)
}
} }
} }
} }
@Composable @Composable
private fun LatestAnswerCard( private fun MomentCueCard() {
latest: LocalAnswer?, Surface(
onHistory: () -> Unit
) {
Card(
onClick = onHistory,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp), shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)) color = Color(0xFFFFF8FC)
) { ) {
Column( Column(
modifier = Modifier.padding(18.dp), modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(7.dp)
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomePill("Latest reflection") HomePill("Special dates")
latest?.let { HomePill(if (it.isRevealed) "Revealed" else "Private") } HomePill("Quiet reminder")
} }
Text( Text(
text = latest?.questionText ?: "Your reflections will appear here after you answer a prompt.", text = "Keep important days close without turning them into chores.",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
fontWeight = FontWeight.SemiBold, color = Color(0xFF261D2E)
color = Color(0xFF27211F),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
latest?.let {
Text( Text(
text = if (it.isRevealed) it.revealSummary() else "Saved privately. Reveal it when the moment feels right.", text = "Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4E4642), color = Color(0xFF5A5060)
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
} }
} }
private data class HomeActionColors(
val soft: Color,
val accent: Color,
val deep: Color,
val onAccent: Color = Color(0xFF24122F)
)
private fun HomeActionTone.actionColors(): HomeActionColors =
when (this) {
HomeActionTone.Invite -> HomeActionColors(
soft = Color(0xFFF4E8FF),
accent = Color(0xFFB98AF4),
deep = Color(0xFF56306F)
)
HomeActionTone.Daily -> HomeActionColors(
soft = Color(0xFFFFE9F4),
accent = Color(0xFFE7A2D1),
deep = Color(0xFF6D2B55)
)
HomeActionTone.Reflection -> HomeActionColors(
soft = Color(0xFFF0E8FF),
accent = Color(0xFFA98FE8),
deep = Color(0xFF4B3279)
)
HomeActionTone.Ritual -> HomeActionColors(
soft = Color(0xFFEAF6F0),
accent = Color(0xFF9BC9AE),
deep = Color(0xFF28533A)
)
HomeActionTone.Starter -> HomeActionColors(
soft = Color(0xFFFFF0E6),
accent = Color(0xFFEFC39D),
deep = Color(0xFF673D20)
)
HomeActionTone.Pack -> HomeActionColors(
soft = Color(0xFFEAF0FF),
accent = Color(0xFF99AEE8),
deep = Color(0xFF2D407A)
)
HomeActionTone.Utility -> HomeActionColors(
soft = Color(0xFFF0EDF3),
accent = Color(0xFFB7AFC0),
deep = Color(0xFF423849)
)
} }
@Composable @Composable
@ -584,6 +505,9 @@ private fun CategoryPreviewGrid(
onCategory: (String) -> Unit, onCategory: (String) -> Unit,
onPacks: () -> Unit onPacks: () -> Unit
) { ) {
val featuredCategories = categories.take(2)
if (featuredCategories.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -591,20 +515,20 @@ private fun CategoryPreviewGrid(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Question packs", text = "More doorways",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
color = Color(0xFF27211F), color = Color(0xFF2C2233),
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
OutlinedButton( OutlinedButton(
onClick = onPacks, onClick = onPacks,
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text("All") Text("All packs")
} }
} }
categories.chunked(2).forEach { rowItems -> featuredCategories.chunked(2).forEach { rowItems ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
rowItems.forEach { item -> rowItems.forEach { item ->
CategoryMiniCard( CategoryMiniCard(
@ -655,17 +579,6 @@ private fun CategoryMiniCard(
} }
} }
@Composable
private fun SettingsStrip(onSettings: () -> Unit) {
OutlinedButton(
onClick = onSettings,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Text("Settings")
}
}
@Composable @Composable
private fun LoadingHomeCard() { private fun LoadingHomeCard() {
Card( Card(

View File

@ -27,7 +27,37 @@ data class HomeAnswerStats(
val total: Int = 0, val total: Int = 0,
val revealed: Int = 0, val revealed: Int = 0,
val private: Int = 0, val private: Int = 0,
val latest: LocalAnswer? = null val latest: LocalAnswer? = null,
val answeredQuestionIds: Set<String> = emptySet()
)
enum class HomeActionTarget {
InvitePartner,
DailyQuestion,
AnswerHistory,
QuestionPacks,
Settings
}
enum class HomeActionTone {
Invite,
Daily,
Reflection,
Ritual,
Starter,
Pack,
Utility
}
data class HomeAction(
val eyebrow: String,
val title: String,
val body: String,
val cta: String,
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null
) )
data class HomeUiState( data class HomeUiState(
@ -38,7 +68,9 @@ data class HomeUiState(
val answerStats: HomeAnswerStats = HomeAnswerStats(), val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null, val partnerName: String? = null,
val streakCount: Int = 0, val streakCount: Int = 0,
val isPaired: Boolean = false val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList()
) )
@HiltViewModel @HiltViewModel
@ -84,14 +116,14 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
streakCount = couple?.streakCount ?: 0, streakCount = couple?.streakCount ?: 0,
isPaired = couple != null isPaired = couple != null
) ).withHomeActions()
} }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
error = e.message ?: "Could not load your dashboard." error = e.message ?: "Could not load your dashboard."
) ).withHomeActions()
} }
} }
} }
@ -107,11 +139,126 @@ class HomeViewModel @Inject constructor(
total = answers.size, total = answers.size,
revealed = answers.count { answer -> answer.isRevealed }, revealed = answers.count { answer -> answer.isRevealed },
private = answers.count { answer -> !answer.isRevealed }, private = answers.count { answer -> !answer.isRevealed },
latest = sorted.firstOrNull() latest = sorted.firstOrNull(),
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
) )
).withHomeActions()
}
}
}
}
private fun HomeUiState.withHomeActions(): HomeUiState {
if (isLoading || error != null) {
return copy(primaryAction = null, secondaryActions = emptyList())
}
val primary = buildPrimaryAction()
return copy(
primaryAction = primary,
secondaryActions = buildSecondaryActions(primary)
)
}
private fun HomeUiState.buildPrimaryAction(): HomeAction {
val dailyQuestionIsUnanswered = dailyQuestion
?.id
?.let { questionId -> questionId !in answerStats.answeredQuestionIds }
?: false
return when {
!isPaired -> HomeAction(
eyebrow = "Next best action",
title = "Invite your partner into tonight.",
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
dailyQuestionIsUnanswered -> HomeAction(
eyebrow = "Tonight's prompt",
title = dailyQuestion?.text ?: "Answer tonight's question.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer now",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
answerStats.private > 0 -> HomeAction(
eyebrow = "Saved privately",
title = "You have ${answerStats.private} reflection${if (answerStats.private == 1) "" else "s"} waiting.",
body = "Review what you saved and choose whether tonight is the right time to open one up.",
cta = "Review reflections",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection,
metric = "${answerStats.revealed} revealed"
)
streakCount > 0 -> HomeAction(
eyebrow = "Shared ritual",
title = "$streakCount night${if (streakCount == 1) "" else "s"} showing up.",
body = "Keep it light: answer one prompt, revisit a saved reflection, or choose a pack that fits tonight.",
cta = "Keep going",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Ritual,
metric = "${answerStats.total} saved"
)
else -> HomeAction(
eyebrow = "Gentle start",
title = "Start with one question worth answering.",
body = "A small prompt is enough. Build the habit around attention, not pressure.",
cta = "Start tonight",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Starter
) )
} }
} }
private fun HomeUiState.buildSecondaryActions(primary: HomeAction): List<HomeAction> {
val actions = mutableListOf<HomeAction>()
answerStats.latest?.let { latest ->
if (primary.target != HomeActionTarget.AnswerHistory) {
actions += HomeAction(
eyebrow = if (latest.isRevealed) "Revealed" else "Private",
title = "Return to your latest reflection.",
body = latest.questionText,
cta = "Open history",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection
)
} }
} }
categories.firstOrNull()?.let { category ->
actions += HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
actions += HomeAction(
eyebrow = "Tune the ritual",
title = "Adjust your space.",
body = "Manage reminders, partner state, privacy, and account details when you need to.",
cta = "Settings",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
return actions.take(3)
}
private fun String.toHomeLabel(): String =
split("_", "-")
.filter { part -> part.isNotBlank() }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
} }