432 lines
16 KiB
Kotlin
432 lines
16 KiB
Kotlin
package app.closer.ui.onboarding
|
|
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.aspectRatio
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
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.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.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.alpha
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.draw.shadow
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import app.closer.R
|
|
import app.closer.core.navigation.AppRoute
|
|
import app.closer.ui.auth.AuthBackgroundBrush
|
|
import app.closer.ui.auth.AuthInk
|
|
import app.closer.ui.auth.AuthMuted
|
|
import app.closer.ui.auth.AuthOnPrimary
|
|
import app.closer.ui.auth.AuthPrimary
|
|
import app.closer.ui.auth.AuthPrimaryDeep
|
|
import app.closer.ui.components.BrandMessageRotator
|
|
import app.closer.ui.theme.CloserPalette
|
|
import kotlinx.coroutines.launch
|
|
|
|
private const val CTA_PAGE = 3
|
|
|
|
@Composable
|
|
fun OnboardingScreen(
|
|
onNavigate: (String) -> Unit = {},
|
|
viewModel: OnboardingViewModel = hiltViewModel()
|
|
) {
|
|
val state by viewModel.uiState.collectAsState()
|
|
|
|
LaunchedEffect(state.navigateTo) {
|
|
state.navigateTo?.let { dest ->
|
|
onNavigate(dest)
|
|
viewModel.onNavigated()
|
|
}
|
|
}
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(AuthBackgroundBrush)
|
|
) {
|
|
if (state.isCheckingAuth) {
|
|
Column(
|
|
modifier = Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
|
) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(36.dp),
|
|
color = AuthPrimary,
|
|
strokeWidth = 3.dp
|
|
)
|
|
BrandMessageRotator(color = AuthMuted)
|
|
}
|
|
} else {
|
|
val pagerState = rememberPagerState(pageCount = { CTA_PAGE + 1 })
|
|
val scope = rememberCoroutineScope()
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.safeDrawingPadding()
|
|
.navigationBarsPadding()
|
|
) {
|
|
// Skip button — visible on value slides only
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(end = 16.dp, top = 8.dp),
|
|
contentAlignment = Alignment.CenterEnd
|
|
) {
|
|
if (pagerState.currentPage < CTA_PAGE) {
|
|
TextButton(
|
|
onClick = {
|
|
scope.launch { pagerState.animateScrollToPage(CTA_PAGE) }
|
|
}
|
|
) {
|
|
Text(
|
|
"Skip",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = AuthMuted
|
|
)
|
|
}
|
|
} else {
|
|
Spacer(Modifier.height(40.dp))
|
|
}
|
|
}
|
|
|
|
HorizontalPager(
|
|
state = pagerState,
|
|
modifier = Modifier.weight(1f)
|
|
) { page ->
|
|
when (page) {
|
|
0 -> ValueSlide(
|
|
visual = { AnswerPreviewVisual() },
|
|
headline = "Answer honestly",
|
|
body = "Every day, both of you answer the same question — separately, on your own devices."
|
|
)
|
|
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
|
|
) {
|
|
PageDots(current = pagerState.currentPage, total = CTA_PAGE)
|
|
Button(
|
|
onClick = {
|
|
scope.launch {
|
|
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
|
}
|
|
},
|
|
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))
|
|
BrandMessageRotator(
|
|
color = AuthMuted,
|
|
style = MaterialTheme.typography.bodyLarge
|
|
)
|
|
}
|
|
|
|
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() {
|
|
Image(
|
|
painter = painterResource(R.drawable.illustration_couple_onboarding),
|
|
contentDescription = null,
|
|
contentScale = ContentScale.Fit,
|
|
modifier = Modifier
|
|
.fillMaxWidth(0.8f)
|
|
.aspectRatio(2f / 3f)
|
|
.clip(RoundedCornerShape(28.dp))
|
|
)
|
|
}
|
|
|
|
@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))
|
|
)
|
|
}
|
|
}
|
|
}
|