fix(home): update HomeScreen and HomeViewModel

This commit is contained in:
null 2026-06-16 03:25:03 -05:00
parent 0e9606366b
commit 56f2d8c045
2 changed files with 442 additions and 382 deletions

View File

@ -1,12 +1,5 @@
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.layout.Arrangement
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -41,7 +32,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
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.hilt.navigation.compose.hiltViewModel
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.QuestionCategory
import com.couplesconnect.app.ui.answers.revealSummary
import com.couplesconnect.app.ui.components.SpecialDatesSection
import com.couplesconnect.app.ui.questions.displayCategoryName
@Composable
@ -93,7 +80,7 @@ private fun HomeContent(
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
@ -117,37 +104,42 @@ private fun HomeContent(
state.isLoading -> LoadingHomeCard()
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
else -> {
TodayOverviewCard(
question = state.dailyQuestion,
stats = state.answerStats,
onDailyQuestion = onDailyQuestion,
onHistory = onHistory,
onPacks = onPacks
)
SpecialDatesSection(compact = true)
LatestAnswerCard(
latest = state.answerStats.latest,
onHistory = onHistory
val onActionSelected: (HomeAction) -> Unit = { action ->
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,
streakCount = state.streakCount,
onAction = onActionSelected
)
}
ActionFeedSection(
actions = state.secondaryActions,
onAction = onActionSelected
)
MomentCueCard()
CategoryPreviewGrid(
categories = state.categories,
onCategory = onCategory,
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
) {
Text(
text = "Tonights connection",
text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount day streak")
HomePill("$streakCount nights showing up")
}
}
Text(
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
"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,
color = Color(0xFF4E4642)
)
@ -184,115 +176,30 @@ private fun HomeHeader(
}
@Composable
private fun PulsingInviteFab(
onClick: () -> Unit,
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?,
private fun PrimaryHomeActionCard(
action: HomeAction,
stats: HomeAnswerStats,
onDailyQuestion: () -> Unit,
onHistory: () -> Unit,
onPacks: () -> Unit
streakCount: Int,
onAction: (HomeAction) -> Unit
) {
val colors = action.tone.actionColors()
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)),
elevation = CardDefaults.cardElevation(defaultElevation = 14.dp)
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.92f)),
elevation = CardDefaults.cardElevation(defaultElevation = 18.dp)
) {
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)
) {
Row(
@ -300,104 +207,104 @@ private fun TodayOverviewCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("Daily ritual")
question?.let { HomePill(it.category.displayCategoryName()) }
HomePill(action.eyebrow)
action.metric?.let { HomePill(it) }
}
Text(
text = question?.text ?: "Your next question is ready.",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
color = Color(0xFFF8F0FF)
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Surface(
shape = RoundedCornerShape(20.dp),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Next best action",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF6B4A86),
fontWeight = FontWeight.SemiBold
)
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
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 = action.title,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.body,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4D4354),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onDailyQuestion,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF271236)
HomePulseStrip(stats = stats, streakCount = streakCount)
Button(
onClick = { onAction(action) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.onAccent
)
) {
Text("Answer")
}
OutlinedButton(
onClick = onPacks,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Packs")
}
) {
Text(action.cta)
}
}
}
}
@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,
value: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.72f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.66f)
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
@ -405,14 +312,14 @@ private fun OverviewMetric(
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF5F3A87),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56336F),
maxLines = 1
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642),
color = Color(0xFF5A5060),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -421,169 +328,186 @@ private fun OverviewMetric(
}
@Composable
private fun DailyQuestionCard(
question: Question?,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit
private fun ActionFeedSection(
actions: List<HomeAction>,
onAction: (HomeAction) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.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 = question?.text ?: "Your next question is ready.",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
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")
}
}
if (actions.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "After that",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF2C2233)
)
actions.forEach { action ->
SecondaryHomeActionCard(action = action, onAction = onAction)
}
}
}
@Composable
private fun AnswerStatsRow(
stats: HomeAnswerStats,
onHistory: () -> Unit
private fun SecondaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit
) {
Row(
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
)
}
}
val colors = action.tone.actionColors()
@Composable
private fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
onClick = { onAction(action) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = value,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF5F3A87)
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun LatestAnswerCard(
latest: LocalAnswer?,
onHistory: () -> Unit
) {
Card(
onClick = onHistory,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomePill("Latest reflection")
latest?.let { HomePill(if (it.isRevealed) "Revealed" else "Private") }
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)
)
}
Text(
text = latest?.questionText ?: "Your reflections will appear here after you answer a prompt.",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
latest?.let {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = if (it.isRevealed) it.revealSummary() else "Saved privately. Reveal it when the moment feels right.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
text = action.eyebrow,
style = MaterialTheme.typography.labelMedium,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = action.title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = 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
private fun MomentCueCard() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color(0xFFFFF8FC)
) {
Column(
modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(7.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomePill("Special dates")
HomePill("Quiet reminder")
}
Text(
text = "Keep important days close without turning them into chores.",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF5A5060)
)
}
}
}
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
private fun CategoryPreviewGrid(
categories: List<HomeCategorySummary>,
onCategory: (String) -> Unit,
onPacks: () -> Unit
) {
val featuredCategories = categories.take(2)
if (featuredCategories.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -591,20 +515,20 @@ private fun CategoryPreviewGrid(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Question packs",
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
text = "More doorways",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF2C2233),
fontWeight = FontWeight.SemiBold
)
OutlinedButton(
onClick = onPacks,
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)) {
rowItems.forEach { item ->
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
private fun LoadingHomeCard() {
Card(

View File

@ -27,7 +27,37 @@ data class HomeAnswerStats(
val total: Int = 0,
val revealed: 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(
@ -38,7 +68,9 @@ data class HomeUiState(
val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null,
val streakCount: Int = 0,
val isPaired: Boolean = false
val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList()
)
@HiltViewModel
@ -84,14 +116,14 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName,
streakCount = couple?.streakCount ?: 0,
isPaired = couple != null
)
).withHomeActions()
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
error = e.message ?: "Could not load your dashboard."
)
).withHomeActions()
}
}
}
@ -107,11 +139,126 @@ class HomeViewModel @Inject constructor(
total = answers.size,
revealed = 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() } }
}