feat: update AnswerHistory, BucketList, DateMatches, Onboarding, LocalQuestionContent, WheelHistory screens

This commit is contained in:
null 2026-06-19 03:53:43 -05:00
parent 803b681d06
commit 164e0e47ca
6 changed files with 413 additions and 120 deletions

View File

@ -159,9 +159,9 @@ private fun AnswerHistoryContent(
if (state.answers.isEmpty()) { if (state.answers.isEmpty()) {
item { item {
EmptyState( EmptyState(
title = "No answers saved yet", title = "Nothing here yet",
body = "Answer a daily question or choose a prompt from a pack, and it will appear here.", body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
actionLabel = "Daily question", actionLabel = "Today's question",
onAction = onDailyQuestion onAction = onDailyQuestion
) )
} }
@ -178,8 +178,8 @@ private fun AnswerHistoryContent(
if (visibleAnswers.isEmpty()) { if (visibleAnswers.isEmpty()) {
item { item {
EmptyState( EmptyState(
title = "Nothing in this view", title = "Nothing in this filter",
body = "Switch filters to see the rest of your saved answers." body = "Your answers are here — just in the other tab. Switch between Private and Revealed to see them."
) )
} }
} }

View File

@ -256,7 +256,9 @@ private fun BucketListItems(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) { if (items.isEmpty()) {
EmptyState( app.closer.ui.components.EmptyState(
title = "Your shared list is empty",
body = "Add something you've been dreaming about doing together — big or small. Tap + to start.",
modifier = Modifier.padding(top = 80.dp) modifier = Modifier.padding(top = 80.dp)
) )
return return
@ -370,45 +372,6 @@ private fun CategoryBadge(
} }
} }
@Composable
private fun EmptyState(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFB98AF4).copy(alpha = 0.16f),
modifier = Modifier.size(64.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color(0xFF56306F),
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No bucket list items yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Tap the + button to add your first dream date!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
@Composable @Composable
private fun AddItemDialog( private fun AddItemDialog(
title: String, title: String,

View File

@ -124,11 +124,11 @@ private fun DateMatchesContent(
state.mutualMatches.isEmpty() && state.maybeMatches.isEmpty() -> item { state.mutualMatches.isEmpty() && state.maybeMatches.isEmpty() -> item {
EmptyState( EmptyState(
title = "No matches yet", title = "No mutual matches yet",
body = buildString { body = buildString {
append("Start swiping on date ideas. When you and ") append("Swipe through date ideas — when you and ")
append(state.partnerName ?: "your partner") append(state.partnerName ?: "your partner")
append(" both love the same idea, it will appear here.") append(" both love the same one, it shows up here as a match.")
}, },
modifier = Modifier.padding(top = 80.dp) modifier = Modifier.padding(top = 80.dp)
) )

View File

@ -1,35 +1,49 @@
package app.closer.ui.onboarding package app.closer.ui.onboarding
import androidx.compose.foundation.background import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
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
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
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.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -41,6 +55,10 @@ import app.closer.ui.auth.AuthMuted
import app.closer.ui.auth.AuthOnPrimary import app.closer.ui.auth.AuthOnPrimary
import app.closer.ui.auth.AuthPrimary import app.closer.ui.auth.AuthPrimary
import app.closer.ui.auth.AuthPrimaryDeep import app.closer.ui.auth.AuthPrimaryDeep
import app.closer.ui.theme.CloserPalette
import kotlinx.coroutines.launch
private const val CTA_PAGE = 3
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
@ -63,90 +81,402 @@ fun OnboardingScreen(
) { ) {
if (state.isCheckingAuth) { if (state.isCheckingAuth) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier.size(36.dp).align(Alignment.Center),
.size(36.dp)
.align(Alignment.Center),
color = AuthPrimary, color = AuthPrimary,
strokeWidth = 3.dp strokeWidth = 3.dp
) )
} else { } else {
val pagerState = rememberPagerState(pageCount = { CTA_PAGE + 1 })
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.safeDrawingPadding() .safeDrawingPadding()
.padding(horizontal = 32.dp), .navigationBarsPadding()
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) { ) {
Spacer(Modifier.height(1.dp)) // Skip button — visible on value slides only
Box(
Column(horizontalAlignment = Alignment.CenterHorizontally) { modifier = Modifier
Spacer(Modifier.height(80.dp)) .fillMaxWidth()
Box( .padding(end = 16.dp, top = 8.dp),
modifier = Modifier contentAlignment = Alignment.CenterEnd
.size(96.dp) ) {
.shadow( if (pagerState.currentPage < CTA_PAGE) {
elevation = 14.dp, TextButton(
shape = RoundedCornerShape(24.dp), onClick = {
clip = false scope.launch { pagerState.animateScrollToPage(CTA_PAGE) }
}
) {
Text(
"Skip",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted
) )
.clip(RoundedCornerShape(24.dp)) }
) { } else {
Image( Spacer(Modifier.height(40.dp))
painter = painterResource(R.drawable.ic_launcher_background),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.matchParentSize()
.alpha(0.96f)
)
} }
Spacer(Modifier.height(28.dp))
Text(
text = "Closer",
style = MaterialTheme.typography.displayMedium,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Questions that bring couples closer,\none answer at a time.",
style = MaterialTheme.typography.bodyLarge,
color = AuthMuted,
textAlign = TextAlign.Center
)
} }
Column( HorizontalPager(
horizontalAlignment = Alignment.CenterHorizontally, state = pagerState,
modifier = Modifier.padding(bottom = 48.dp) modifier = Modifier.weight(1f)
) { ) { page ->
Button( when (page) {
onClick = { onNavigate(AppRoute.SIGN_UP) }, 0 -> ValueSlide(
modifier = Modifier.fillMaxWidth().height(56.dp), visual = { AnswerPreviewVisual() },
colors = ButtonDefaults.buttonColors( headline = "Answer honestly",
containerColor = AuthPrimary, body = "Every day, both of you answer the same question — separately, on your own devices."
contentColor = AuthOnPrimary
) )
1 -> ValueSlide(
visual = { NoPeekingVisual() },
headline = "No peeking",
body = "Answers stay hidden until you've both finished. Then they reveal side by side."
)
2 -> ValueSlide(
visual = { GrowerVisual() },
headline = "Grow closer",
body = "Every answer opens a real conversation. Not a quiz — a moment."
)
else -> CtaSlide(onNavigate = onNavigate)
}
}
// Page dots + Next button
if (pagerState.currentPage < CTA_PAGE) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Get started", style = MaterialTheme.typography.labelLarge) PageDots(current = pagerState.currentPage, total = CTA_PAGE)
} Button(
Spacer(Modifier.height(12.dp)) onClick = {
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) { scope.launch {
Text( pagerState.animateScrollToPage(pagerState.currentPage + 1)
"I already have an account", }
style = MaterialTheme.typography.bodyMedium, },
color = AuthPrimaryDeep shape = RoundedCornerShape(16.dp),
) colors = ButtonDefaults.buttonColors(
containerColor = AuthPrimary,
contentColor = AuthOnPrimary
)
) {
Text(
if (pagerState.currentPage == CTA_PAGE - 1) "Get started" else "Next",
style = MaterialTheme.typography.labelLarge
)
}
} }
} else {
Spacer(Modifier.height(20.dp))
} }
} }
} }
} }
} }
// ── Slides ────────────────────────────────────────────────────────────────────
@Composable
private fun ValueSlide(
visual: @Composable () -> Unit,
headline: String,
body: String
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(Modifier.height(16.dp))
visual()
Spacer(Modifier.height(36.dp))
Text(
text = headline,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyLarge,
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(16.dp))
}
}
@Composable
private fun CtaSlide(onNavigate: (String) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Spacer(Modifier.height(1.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(40.dp))
Box(
modifier = Modifier
.size(96.dp)
.shadow(elevation = 14.dp, shape = RoundedCornerShape(24.dp), clip = false)
.clip(RoundedCornerShape(24.dp))
) {
Image(
painter = painterResource(R.drawable.ic_launcher_background),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.matchParentSize().alpha(0.96f)
)
}
Spacer(Modifier.height(28.dp))
Text(
text = "Closer",
style = MaterialTheme.typography.displayMedium,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Ready when you are.",
style = MaterialTheme.typography.bodyLarge,
color = AuthMuted,
textAlign = TextAlign.Center
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(bottom = 48.dp)
) {
Button(
onClick = { onNavigate(AppRoute.SIGN_UP) },
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AuthPrimary,
contentColor = AuthOnPrimary
)
) {
Text("Create account", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(12.dp))
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
Text(
"I already have an account",
style = MaterialTheme.typography.bodyMedium,
color = AuthPrimaryDeep
)
}
}
}
}
// ── Visuals ───────────────────────────────────────────────────────────────────
@Composable
private fun AnswerPreviewVisual() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Mock question card
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What's something you want more of in your relationship?",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF1A1A2E),
textAlign = TextAlign.Center
)
// Two mock answer bubbles
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MockAnswerBubble("Quality time", selected = true, modifier = Modifier.weight(1f))
MockAnswerBubble("Adventure", selected = false, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MockAnswerBubble("Deep talks", selected = false, modifier = Modifier.weight(1f))
MockAnswerBubble("Playfulness", selected = false, modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun MockAnswerBubble(label: String, selected: Boolean, modifier: Modifier = Modifier) {
val bg by animateColorAsState(
if (selected) CloserPalette.PurpleDeep else Color(0xFFF4F0FF),
animationSpec = tween(200)
)
val fg by animateColorAsState(
if (selected) Color.White else Color(0xFF56306F),
animationSpec = tween(200)
)
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = bg
) {
Text(
text = label,
modifier = Modifier.padding(vertical = 10.dp, horizontal = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = fg,
textAlign = TextAlign.Center,
maxLines = 1
)
}
}
@Composable
private fun NoPeekingVisual() {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RevealCard(
label = "You",
preview = "Quality time",
revealed = false,
color = CloserPalette.PurpleSoft
)
Text("", style = MaterialTheme.typography.headlineLarge, color = AuthMuted)
RevealCard(
label = "Partner",
preview = "Deep talks",
revealed = false,
color = CloserPalette.PinkMist
)
}
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = CloserPalette.PurpleDeep.copy(alpha = 0.1f)
) {
Text(
text = "Sealed until you both answer",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = CloserPalette.PurpleDeep
)
}
}
@Composable
private fun RevealCard(label: String, preview: String, revealed: Boolean, color: Color) {
Card(
modifier = Modifier.width(120.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = color),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = CloserPalette.PurpleDeep.copy(alpha = 0.7f)
)
if (revealed) {
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = CloserPalette.PurpleDeep,
textAlign = TextAlign.Center
)
} else {
// Blurred / hidden lines
repeat(3) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(10.dp)
.clip(RoundedCornerShape(8.dp))
.background(CloserPalette.PurpleDeep.copy(alpha = 0.15f))
)
}
}
}
}
}
@Composable
private fun GrowerVisual() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GrowthPill("Daily question", CloserPalette.PurpleSoft, CloserPalette.PurpleDeep)
GrowthPill("Games", CloserPalette.PinkSoft, CloserPalette.Romantic)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GrowthPill("Desire Sync", CloserPalette.PinkMist, CloserPalette.Romantic)
GrowthPill("Memory Lane", CloserPalette.PurpleGlow, CloserPalette.PurpleDeep)
}
GrowthPill("Question threads", Color(0xFFE8F5E9), Color(0xFF2E7D32))
}
}
@Composable
private fun GrowthPill(label: String, bg: Color, fg: Color) {
Surface(shape = RoundedCornerShape(50.dp), color = bg) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Medium),
color = fg
)
}
}
// ── Page dots ─────────────────────────────────────────────────────────────────
@Composable
private fun PageDots(current: Int, total: Int) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
repeat(total) { i ->
val active = i == current
Box(
modifier = Modifier
.size(if (active) 20.dp else 6.dp, 6.dp)
.clip(CircleShape)
.background(if (active) AuthPrimary else AuthMuted.copy(alpha = 0.3f))
)
}
}
}

View File

@ -93,8 +93,8 @@ fun LocalQuestionContent(
onRetry = onRefresh onRetry = onRefresh
) )
state.question == null -> EmptyState( state.question == null -> EmptyState(
title = "This question is not available", title = "This one's not available",
body = "Choose another prompt and keep the conversation moving gently." body = "Try a different prompt — there are plenty more in your packs."
) )
else -> { else -> {
val question = state.question val question = state.question

View File

@ -113,9 +113,9 @@ fun WheelHistoryScreen(
} }
state.sessions.isEmpty() -> item { state.sessions.isEmpty() -> item {
EmptyState( EmptyState(
title = "No sessions yet", title = "No games played yet",
body = "Completed games will appear here.", body = "Finish a round of This or That, How Well, Desire Sync, or Spin the Wheel — your results will show up here so you can revisit them together.",
actionLabel = "Play now", actionLabel = "Play a game",
onAction = { onNavigate(AppRoute.PLAY) } onAction = { onNavigate(AppRoute.PLAY) }
) )
} }