fix(home): update HomeScreen and HomeViewModel
This commit is contained in:
parent
0e9606366b
commit
56f2d8c045
|
|
@ -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 = "Tonight’s 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 today’s 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(
|
||||
|
|
|
|||
|
|
@ -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() } }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue