feat: answer reveal, auth screens, challenges, onboarding, pairing, paywall, wheel, settings, components

This commit is contained in:
null 2026-06-22 19:18:49 -05:00
parent b42544bafb
commit 125a24eb85
31 changed files with 339 additions and 175 deletions

View File

@ -25,7 +25,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
@ -141,7 +141,7 @@ private fun AnswerRevealContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
CloserHeartLoader(size = 32.dp)
Text("Loading reveal")
}
}
@ -451,7 +451,7 @@ private fun ReleasingKeyState() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
CloserHeartLoader(size = 32.dp)
Text(
text = "Opening reveal…",
style = MaterialTheme.typography.bodyLarge,

View File

@ -20,7 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -157,11 +157,7 @@ fun ForgotPasswordScreen(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = AuthOnPrimary,
strokeWidth = 2.dp
)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Send reset email", style = MaterialTheme.typography.labelLarge)
}
}

View File

@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -58,6 +57,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader
@Composable
fun LoginScreen(
@ -176,7 +176,7 @@ fun LoginScreen(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Sign in", style = MaterialTheme.typography.labelLarge)
}

View File

@ -20,7 +20,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -179,7 +179,7 @@ fun SignUpScreen(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Create account", style = MaterialTheme.typography.labelLarge)
}

View File

@ -29,7 +29,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -241,7 +241,7 @@ fun ConnectionChallengesScreen(
@Composable
private fun ChallengesLoadingScreen() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CloserPalette.PurpleDeep)
CloserHeartLoader()
}
}

View File

@ -1,29 +1,35 @@
package app.closer.ui.components
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import androidx.compose.animation.core.LinearEasing
import android.provider.Settings
import androidx.compose.animation.core.FastOutSlowInEasing
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.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@ -31,8 +37,6 @@ fun LoadingState(
message: String = "Loading…",
modifier: Modifier = Modifier
) {
val shimmer = closerSkeletonBrush()
CloserCard(
modifier = modifier.fillMaxWidth(),
containerColor = closerCardColor(alpha = 0.8f)
@ -41,23 +45,10 @@ fun LoadingState(
modifier = Modifier
.fillMaxWidth()
.padding(CloserSpacing.Xxxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
) {
SkeletonLine(
brush = shimmer,
modifier = Modifier.fillMaxWidth(0.42f),
height = 18.dp
)
SkeletonLine(
brush = shimmer,
modifier = Modifier.fillMaxWidth(),
height = 13.dp
)
SkeletonLine(
brush = shimmer,
modifier = Modifier.fillMaxWidth(0.72f),
height = 13.dp
)
CloserHeartLoader()
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
@ -73,39 +64,90 @@ fun LoadingState(
}
@Composable
private fun closerSkeletonBrush(): Brush {
val transition = rememberInfiniteTransition(label = "closerSkeleton")
val shimmerOffset = transition.animateFloat(
initialValue = -320f,
targetValue = 640f,
fun CloserHeartLoader(
modifier: Modifier = Modifier,
size: Dp = 76.dp
) {
val context = LocalContext.current
val reducedMotion = remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) == 0f
}
val transition = rememberInfiniteTransition(label = "closerHeartLoader")
val animatedFill = transition.animateFloat(
initialValue = 0.08f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "closerSkeletonOffset"
label = "closerHeartFill"
)
return Brush.linearGradient(
colors = listOf(
CloserPalette.PurpleMist.copy(alpha = 0.55f),
CloserPalette.PurpleSoft.copy(alpha = 0.95f),
CloserPalette.PinkMist.copy(alpha = 0.72f)
val animatedPulse = transition.animateFloat(
initialValue = 0.96f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 900, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
start = Offset(shimmerOffset.value, 0f),
end = Offset(shimmerOffset.value + 280f, 0f)
label = "closerHeartPulse"
)
}
@Composable
private fun SkeletonLine(
brush: Brush,
modifier: Modifier = Modifier,
height: androidx.compose.ui.unit.Dp
) {
Box(
val fillProgress = if (reducedMotion) 1f else animatedFill.value
val pulse = if (reducedMotion) 1f else animatedPulse.value
val shadowPath = remember {
PathParser().parsePathString(
"M54,89C48,82 25,65 20,50C15,35 23,22 37,22C45,22 51,26 54,33C57,26 63,22 71,22C85,22 93,35 88,50C83,65 60,82 54,89Z"
).toPath()
}
val leftPath = remember {
PathParser().parsePathString(
"M54,85C49,79 27,62 22,48C17,35 24,24 37,24C45,24 51,28 54,35Z"
).toPath()
}
val rightPath = remember {
PathParser().parsePathString(
"M54,85C59,79 81,62 86,48C91,35 84,24 71,24C63,24 57,28 54,35Z"
).toPath()
}
val leftHighlight = remember {
PathParser().parsePathString(
"M27,42C28,32 34,27 42,27C48,27 52,30 54,35L54,41C47,36 37,36 27,42Z"
).toPath()
}
val rightHighlight = remember {
PathParser().parsePathString(
"M54,35C57,30 62,27 69,27C78,27 84,32 85,42C75,36 65,36 54,41Z"
).toPath()
}
Canvas(
modifier = modifier
.height(height)
.clip(RoundedCornerShape(CloserRadii.Pill))
.background(brush)
)
.size(size)
.graphicsLayer {
scaleX = pulse
scaleY = pulse
}
.clearAndSetSemantics {}
) {
val scaleX = this.size.width / 108f
val scaleY = this.size.height / 108f
withTransform({
scale(scaleX = scaleX, scaleY = scaleY, pivot = Offset.Zero)
}) {
drawPath(shadowPath, color = Color(0xFF24122F).copy(alpha = 0.10f))
drawPath(leftPath, color = Color(0xFFF7C8E4).copy(alpha = 0.22f))
drawPath(rightPath, color = Color(0xFFD9B8FF).copy(alpha = 0.22f))
clipRect(top = 108f * (1f - fillProgress), bottom = 108f) {
drawPath(leftPath, color = Color(0xFFF7C8E4))
drawPath(rightPath, color = Color(0xFFD9B8FF))
drawPath(leftHighlight, color = Color(0xFFFFF4FA).copy(alpha = 0.68f))
drawPath(rightHighlight, color = Color(0xFFF3E8FF).copy(alpha = 0.52f))
}
}
}
}

View File

@ -33,7 +33,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
@ -370,9 +370,8 @@ fun DesireSyncScreen(
.background(closerBackgroundBrush())
) {
when (state.phase) {
DesireSyncPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.Romantic
DesireSyncPhase.LOADING -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
@ -553,7 +552,7 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp)
CloserHeartLoader(size = 48.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f))
OutlinedButton(
@ -1020,9 +1019,8 @@ fun DSReplayScreen(
.background(closerBackgroundBrush())
) {
when (phase) {
is DSReplayPhase.Loading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.Romantic
is DSReplayPhase.Loading -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
is DSReplayPhase.Error -> Column(
modifier = Modifier

View File

@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -130,10 +130,7 @@ fun WaitingForPartnerScreen(
) {
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary
)
CloserHeartLoader(size = 64.dp)
Text(
text = "Loading...",
style = MaterialTheme.typography.bodyLarge,

View File

@ -34,7 +34,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
@ -414,9 +414,8 @@ fun HowWellScreen(
.background(closerBackgroundBrush())
) {
when (state.phase) {
HowWellPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
HowWellPhase.LOADING -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
@ -622,7 +621,7 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
CloserHeartLoader(size = 48.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f))
OutlinedButton(
@ -1202,9 +1201,8 @@ fun HowWellReplayScreen(
.background(closerBackgroundBrush())
) {
when (phase) {
is HWReplayPhase.Loading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
is HWReplayPhase.Loading -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
is HWReplayPhase.Error -> Column(
modifier = Modifier

View File

@ -33,7 +33,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
@ -231,7 +231,7 @@ fun MemoryLaneScreen(
) {
when (state.phase) {
MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CloserPalette.PurpleDeep)
CloserHeartLoader()
}
MemoryLanePhase.LIST -> CapsuleListScreen(
capsules = state.capsules,
@ -494,7 +494,7 @@ private fun CapsuleCreateScreen(
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
if (state.isSaving) CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
if (state.isSaving) CloserHeartLoader(size = 22.dp)
else Text("Seal the capsule 📦", color = Color.White, style = MaterialTheme.typography.labelLarge)
}

View File

@ -34,7 +34,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -255,7 +255,7 @@ private fun NameStep(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Continue", style = MaterialTheme.typography.labelLarge)
}
}
@ -312,7 +312,7 @@ private fun SexStep(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Continue", style = MaterialTheme.typography.labelLarge)
}
}
@ -440,7 +440,7 @@ private fun PhotoStep(
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Continue", style = MaterialTheme.typography.labelLarge)
}
}

View File

@ -24,7 +24,6 @@ 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
@ -55,6 +54,7 @@ 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.components.CloserHeartLoader
import app.closer.ui.theme.CloserPalette
import kotlinx.coroutines.launch
@ -85,11 +85,7 @@ fun OnboardingScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = AuthPrimary,
strokeWidth = 3.dp
)
CloserHeartLoader()
BrandMessageRotator(color = AuthMuted)
}
} else {

View File

@ -20,7 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -98,7 +98,7 @@ fun YourProgressScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
CloserHeartLoader()
}
}
state.error != null -> {

View File

@ -26,7 +26,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -166,11 +166,7 @@ fun AcceptInviteScreen(
contentColor = SettingsOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = SettingsOnPrimary,
strokeWidth = 2.dp
)
if (state.isLoading) CloserHeartLoader(size = 22.dp)
else Text("Continue", style = MaterialTheme.typography.labelLarge)
}

View File

@ -29,7 +29,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -125,7 +125,7 @@ fun CreateInviteScreen(
) {
if (state.isLoading) {
Spacer(Modifier.height(160.dp))
CircularProgressIndicator(modifier = Modifier.size(40.dp))
CloserHeartLoader(size = 64.dp)
} else if (state.inviteCode != null) {
Spacer(Modifier.height(24.dp))

View File

@ -16,7 +16,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@ -38,6 +37,7 @@ import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.components.StatusGlyph
import app.closer.ui.settings.SettingsBackgroundBrush
import app.closer.ui.settings.SettingsInk
@ -181,7 +181,7 @@ fun EncryptionUpgradeScreen(
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = SettingsPrimary)
CloserHeartLoader()
Spacer(Modifier.height(16.dp))
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
}

View File

@ -20,7 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -107,7 +107,7 @@ fun InviteConfirmScreen(
) {
if (state.isLoading) {
Spacer(Modifier.height(160.dp))
CircularProgressIndicator(modifier = Modifier.size(40.dp))
CloserHeartLoader(size = 64.dp)
} else {
Spacer(Modifier.height(24.dp))
@ -156,11 +156,7 @@ fun InviteConfirmScreen(
contentColor = SettingsOnPrimary
)
) {
if (state.isConfirming) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = SettingsOnPrimary,
strokeWidth = 2.dp
)
if (state.isConfirming) CloserHeartLoader(size = 22.dp)
else Text("Pair up", style = MaterialTheme.typography.labelLarge)
}

View File

@ -20,7 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
@ -141,11 +141,7 @@ fun RecoveryScreen(
)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = SettingsOnPrimary,
strokeWidth = 2.dp
)
CloserHeartLoader(size = 22.dp)
} else {
Text("Unlock answers", style = MaterialTheme.typography.labelLarge)
}

View File

@ -32,7 +32,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
@ -133,10 +133,7 @@ fun PaywallScreen(
BenefitsCard()
if (uiState.purchaseState is BillingState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = CloserPalette.PurpleRich
)
CloserHeartLoader(size = 40.dp)
}
ActionButtons(

View File

@ -22,7 +22,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -401,7 +401,7 @@ private fun CategoryLoadingCard() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
CloserHeartLoader(size = 32.dp)
Text(
text = "Loading prompts",
style = MaterialTheme.typography.bodyMedium,

View File

@ -26,7 +26,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -424,7 +424,7 @@ private fun LoadingPackCard() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
CloserHeartLoader(size = 32.dp)
Text(
text = "Loading question packs",
style = MaterialTheme.typography.bodyMedium,

View File

@ -33,7 +33,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -251,7 +251,7 @@ fun DeleteAccountScreen(
)
) {
if (state.isReauthing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White, strokeWidth = 2.dp)
CloserHeartLoader(size = 18.dp)
} else {
Text("Confirm", color = Color.White)
}
@ -328,7 +328,7 @@ fun DeleteAccountScreen(
) {
if (state.isDeleting) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
CloserHeartLoader(size = 20.dp)
Text("Deleting…", color = Color.White)
}
} else {

View File

@ -34,7 +34,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -175,7 +175,7 @@ fun EditProfileContent(
) {
if (state.isLoading) {
Spacer(Modifier.height(64.dp))
CircularProgressIndicator(color = SettingsPrimary)
CloserHeartLoader()
} else {
val imageUri = state.photoUri ?: state.photoUrl.takeIf { it.isNotBlank() }
Box(
@ -356,7 +356,7 @@ fun EditProfileContent(
contentColor = SettingsOnPrimary
)
) {
if (state.isSaving) CircularProgressIndicator(color = SettingsOnPrimary, strokeWidth = 2.dp)
if (state.isSaving) CloserHeartLoader(size = 22.dp)
else Text("Save changes", style = MaterialTheme.typography.labelLarge)
}

View File

@ -32,7 +32,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -190,7 +190,7 @@ fun RelationshipSettingsScreen(
) {
if (state.isLeaving) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
CloserHeartLoader(size = 20.dp)
Text("Leaving…", color = Color.White)
}
} else {

View File

@ -35,7 +35,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -226,7 +226,7 @@ fun SettingsScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
CloserHeartLoader()
}
} else {
var showBaselineOutcomeDialog by remember { mutableStateOf(false) }
@ -499,11 +499,7 @@ fun SettingsScreen(
contentColor = SettingsDanger
)
) {
if (state.isSigningOut) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = SettingsDanger,
strokeWidth = 2.dp
)
if (state.isSigningOut) CloserHeartLoader(size = 22.dp)
else Text("Sign out", style = MaterialTheme.typography.labelLarge)
}

View File

@ -37,7 +37,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
@ -417,9 +417,8 @@ fun ThisOrThatScreen(
.background(closerBackgroundBrush())
) {
when (state.phase) {
TotPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
TotPhase.LOADING -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
TotPhase.ERROR -> ErrorState(
message = state.error ?: "Something didn't load. Go back and try again.",
@ -447,9 +446,8 @@ fun ThisOrThatScreen(
TotPhase.PLAYING -> {
val question = state.questions.getOrNull(state.currentIndex)
if (question == null) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
} else {
val config = question.answerConfig as? ThisOrThatAnswerConfigImpl
@ -909,7 +907,7 @@ private fun WaitingForRevealScreen(
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
CloserHeartLoader(size = 48.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f))
@ -1272,9 +1270,8 @@ fun ThisOrThatReplayScreen(
.background(closerBackgroundBrush())
) {
when (phase) {
is TotReplayPhase.Loading -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
is TotReplayPhase.Loading -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
is TotReplayPhase.Error -> Column(
modifier = Modifier

View File

@ -22,7 +22,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -104,7 +104,7 @@ private fun CategoryPickerContent(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(color = CloserPalette.PurpleDeep)
CloserHeartLoader(size = 28.dp)
Text(
"Loading categories…",
style = MaterialTheme.typography.bodyMedium,

View File

@ -32,7 +32,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -262,7 +262,7 @@ private fun SpinWheelContent(
}
}
}
state.isLoading -> CircularProgressIndicator(color = CloserPalette.PurpleDeep)
state.isLoading -> CloserHeartLoader()
else -> {
Text(
text = "Spin to discover a random category and 10 questions",

View File

@ -26,7 +26,7 @@ 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 app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
@ -197,9 +197,8 @@ fun WheelCompleteScreen(
.background(closerBackgroundBrush())
) {
when (state.phase) {
WheelRevealPhase.LOADING -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CloserPalette.PurpleDeep
WheelRevealPhase.LOADING -> CloserHeartLoader(
modifier = Modifier.align(Alignment.Center)
)
WheelRevealPhase.WAITING -> WheelWaitingContent(
partnerName = state.partnerName,
@ -254,7 +253,7 @@ private fun WheelWaitingContent(
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
CloserHeartLoader(size = 48.dp)
Spacer(Modifier.weight(1f))

View File

@ -26,7 +26,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -114,7 +114,7 @@ private fun WheelSessionContent(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color(0xFF56306F))
CloserHeartLoader()
}
return@Column
}

View File

@ -7,9 +7,7 @@ struct LoadingView: View {
var body: some View {
VStack(spacing: CloserSpacing.lg) {
ProgressView()
.tint(.closerPrimary)
.scaleEffect(1.2)
CloserHeartLoader()
Text(message)
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
@ -18,6 +16,168 @@ struct LoadingView: View {
}
}
struct CloserHeartLoader: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fillProgress: CGFloat = 0.08
@State private var isPulsing = false
var size: CGFloat = 76
var body: some View {
ZStack {
CloserHeartShape()
.fill(
LinearGradient(
colors: [
Color(hex: "F7C8E4").opacity(0.22),
Color(hex: "D9B8FF").opacity(0.22)
],
startPoint: .leading,
endPoint: .trailing
)
)
HStack(spacing: 0) {
Color(hex: "F7C8E4")
Color(hex: "D9B8FF")
}
.mask(CloserHeartShape())
.mask(alignment: .bottom) {
Rectangle()
.frame(height: size * (reduceMotion ? 1 : fillProgress))
}
CloserHeartHighlightShape(side: .left)
.fill(Color(hex: "FFF4FA").opacity(reduceMotion ? 0.68 : 0.68 * fillProgress))
CloserHeartHighlightShape(side: .right)
.fill(Color(hex: "F3E8FF").opacity(reduceMotion ? 0.52 : 0.52 * fillProgress))
}
.frame(width: size, height: size)
.scaleEffect(reduceMotion ? 1 : (isPulsing ? 1.04 : 0.96))
.accessibilityHidden(true)
.onAppear {
guard !reduceMotion else {
fillProgress = 1
isPulsing = false
return
}
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: false)) {
fillProgress = 1
}
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
.onChange(of: reduceMotion) { _, newValue in
if newValue {
fillProgress = 1
isPulsing = false
}
}
}
}
private struct CloserHeartShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: point(54, 85, in: rect))
path.addCurve(
to: point(22, 48, in: rect),
control1: point(49, 79, in: rect),
control2: point(27, 62, in: rect)
)
path.addCurve(
to: point(37, 24, in: rect),
control1: point(17, 35, in: rect),
control2: point(24, 24, in: rect)
)
path.addCurve(
to: point(54, 35, in: rect),
control1: point(45, 24, in: rect),
control2: point(51, 28, in: rect)
)
path.addCurve(
to: point(71, 24, in: rect),
control1: point(57, 28, in: rect),
control2: point(63, 24, in: rect)
)
path.addCurve(
to: point(86, 48, in: rect),
control1: point(84, 24, in: rect),
control2: point(91, 35, in: rect)
)
path.addCurve(
to: point(54, 85, in: rect),
control1: point(81, 62, in: rect),
control2: point(59, 79, in: rect)
)
path.closeSubpath()
return path
}
}
private struct CloserHeartHighlightShape: Shape {
enum Side {
case left
case right
}
let side: Side
func path(in rect: CGRect) -> Path {
var path = Path()
switch side {
case .left:
path.move(to: point(27, 42, in: rect))
path.addCurve(
to: point(42, 27, in: rect),
control1: point(28, 32, in: rect),
control2: point(34, 27, in: rect)
)
path.addCurve(
to: point(54, 35, in: rect),
control1: point(48, 27, in: rect),
control2: point(52, 30, in: rect)
)
path.addLine(to: point(54, 41, in: rect))
path.addCurve(
to: point(27, 42, in: rect),
control1: point(47, 36, in: rect),
control2: point(37, 36, in: rect)
)
path.closeSubpath()
case .right:
path.move(to: point(54, 35, in: rect))
path.addCurve(
to: point(69, 27, in: rect),
control1: point(57, 30, in: rect),
control2: point(62, 27, in: rect)
)
path.addCurve(
to: point(85, 42, in: rect),
control1: point(78, 27, in: rect),
control2: point(84, 32, in: rect)
)
path.addCurve(
to: point(54, 41, in: rect),
control1: point(75, 36, in: rect),
control2: point(65, 36, in: rect)
)
path.closeSubpath()
}
return path
}
}
private func point(_ x: CGFloat, _ y: CGFloat, in rect: CGRect) -> CGPoint {
CGPoint(
x: rect.minX + rect.width * (x / 108),
y: rect.minY + rect.height * (y / 108)
)
}
// MARK: - Error View
struct ErrorView: View {