feat: BrandMessageRotator — rotating privacy copy across screens (batch v0.2.9)

- Add CloserBrandCopy with 6 approved privacy messages
- Add BrandMessageRotator composable: animated fade rotation every 4.5s, reduced-motion support
- Integrate rotator into: OnboardingScreen (CTA + auth check), LoginScreen, LoadingState, AnswerRevealScreen (waiting card), WaitingForPartnerScreen, DesireSync/HowWell/ThisOrThat waiting screens
- Add CloserBrandCopyTest for uniqueness and length validation
- Update visual-identity.md with approved rotation and security-claim publishing rules
This commit is contained in:
null 2026-06-19 20:24:50 -05:00
parent 6828be72fc
commit 9a0b2b6a3d
12 changed files with 163 additions and 10 deletions

View File

@ -48,6 +48,7 @@ import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.ui.questions.displayCategoryName import app.closer.ui.questions.displayCategoryName
import app.closer.ui.questions.displayQuestionType import app.closer.ui.questions.displayQuestionType
import app.closer.ui.components.BrandMessageRotator
@Composable @Composable
fun AnswerRevealScreen( fun AnswerRevealScreen(
@ -461,6 +462,7 @@ private fun WaitingForPartnerCard() {
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
} }
} }
} }

View File

@ -59,6 +59,7 @@ 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
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.BrandMessageRotator
@Composable @Composable
fun LoginScreen( fun LoginScreen(
@ -110,7 +111,10 @@ fun LoginScreen(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(16.dp))
BrandMessageRotator(color = AuthMuted)
Spacer(Modifier.height(24.dp))
OutlinedTextField( OutlinedTextField(
value = state.email, value = state.email,

View File

@ -0,0 +1,13 @@
package app.closer.ui.brand
/** Public-facing privacy copy that is accurate for every app state and account generation. */
object CloserBrandCopy {
val privacyMessages: List<String> = listOf(
"Your relationship is yours, not ours.",
"Answer honestly. Reveal intentionally.",
"For conversations that belong to the two of you.",
"No audience. No public feed. Just the two of you.",
"Private by design.",
"A private space for two."
)
}

View File

@ -0,0 +1,76 @@
package app.closer.ui.components
import android.provider.Settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.closer.ui.brand.CloserBrandCopy
import kotlinx.coroutines.delay
@Composable
fun BrandMessageRotator(
modifier: Modifier = Modifier,
messages: List<String> = CloserBrandCopy.privacyMessages,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
style: TextStyle = MaterialTheme.typography.bodyMedium,
intervalMillis: Long = 4_500L
) {
if (messages.isEmpty()) return
val context = LocalContext.current
val reduceMotion = remember(context) {
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) == 0f
}.getOrDefault(false)
}
var index by remember(messages) { mutableIntStateOf(0) }
LaunchedEffect(messages, intervalMillis, reduceMotion) {
if (!reduceMotion && messages.size > 1) {
while (true) {
delay(intervalMillis)
index = (index + 1) % messages.size
}
}
}
AnimatedContent(
targetState = messages[index],
modifier = modifier.heightIn(min = 44.dp),
contentAlignment = Alignment.Center,
transitionSpec = {
fadeIn(tween(320)) togetherWith fadeOut(tween(220))
},
label = "closerBrandMessage"
) { message ->
Text(
text = message,
style = style,
color = color,
textAlign = TextAlign.Center,
maxLines = 3
)
}
}

View File

@ -64,6 +64,10 @@ fun LoadingState(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
BrandMessageRotator(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodySmall
)
} }
} }
} }

View File

@ -65,6 +65,7 @@ import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
@ -553,6 +554,7 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon:
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp) CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
OutlinedButton( OutlinedButton(
onClick = onBack, onClick = onBack,

View File

@ -32,6 +32,7 @@ import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType import app.closer.domain.model.GameType
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.CategoryGlyph import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.BrandMessageRotator
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -138,6 +139,10 @@ fun WaitingForPartnerScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp) modifier = Modifier.padding(top = 16.dp)
) )
BrandMessageRotator(
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodySmall
)
} }
else -> { else -> {
CategoryGlyph( CategoryGlyph(
@ -161,6 +166,11 @@ fun WaitingForPartnerScreen(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
BrandMessageRotator(
modifier = Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.bodySmall
)
Button( Button(
onClick = { onNavigate(AppRoute.PLAY) }, onClick = { onNavigate(AppRoute.PLAY) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()

View File

@ -73,6 +73,7 @@ import app.closer.data.remote.FirestoreHowWellDataSource
import app.closer.data.remote.HowWellAnswers import app.closer.data.remote.HowWellAnswers
import app.closer.data.remote.HowWellRawAnswer import app.closer.data.remote.HowWellRawAnswer
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.ResultGlyph import app.closer.ui.components.ResultGlyph
import app.closer.ui.components.StatusGlyph import app.closer.ui.components.StatusGlyph
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
@ -622,6 +623,7 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp) CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
OutlinedButton( OutlinedButton(
onClick = onBack, onClick = onBack,

View File

@ -55,6 +55,7 @@ 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.components.BrandMessageRotator
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -80,11 +81,18 @@ fun OnboardingScreen(
.background(AuthBackgroundBrush) .background(AuthBackgroundBrush)
) { ) {
if (state.isCheckingAuth) { if (state.isCheckingAuth) {
CircularProgressIndicator( Column(
modifier = Modifier.size(36.dp).align(Alignment.Center), modifier = Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
color = AuthPrimary, horizontalAlignment = Alignment.CenterHorizontally,
strokeWidth = 3.dp verticalArrangement = Arrangement.spacedBy(20.dp)
) ) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = AuthPrimary,
strokeWidth = 3.dp
)
BrandMessageRotator(color = AuthMuted)
}
} else { } else {
val pagerState = rememberPagerState(pageCount = { CTA_PAGE + 1 }) val pagerState = rememberPagerState(pageCount = { CTA_PAGE + 1 })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -254,11 +262,9 @@ private fun CtaSlide(onNavigate: (String) -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( BrandMessageRotator(
text = "Ready when you are.",
style = MaterialTheme.typography.bodyLarge,
color = AuthMuted, color = AuthMuted,
textAlign = TextAlign.Center style = MaterialTheme.typography.bodyLarge
) )
} }

View File

@ -73,6 +73,7 @@ import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.BrandMessageRotator
import android.content.Context import android.content.Context
import android.provider.Settings import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -909,6 +910,7 @@ private fun WaitingForRevealScreen(
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp) CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp)
BrandMessageRotator(style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))

View File

@ -0,0 +1,16 @@
package app.closer.ui.brand
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CloserBrandCopyTest {
@Test
fun `privacy messages are unique and display ready`() {
val messages = CloserBrandCopy.privacyMessages
assertTrue(messages.isNotEmpty())
assertEquals(messages.size, messages.distinct().size)
assertTrue(messages.all { it.isNotBlank() && it.length <= 64 })
}
}

View File

@ -36,6 +36,22 @@ mode.
- Prefer calm, specific language. Avoid promises to “fix” a relationship, competitive streak copy, - Prefer calm, specific language. Avoid promises to “fix” a relationship, competitive streak copy,
urgency, or public/social framing. urgency, or public/social framing.
## Rotating privacy messages
Approved production rotation:
- **Your relationship is yours, not ours.**
- **Answer honestly. Reveal intentionally.**
- **For conversations that belong to the two of you.**
- **No audience. No public feed. Just the two of you.**
- **Private by design.**
- **A private space for two.**
Do not publish universal “not even Closer can read it,” “no plaintext,” or “all responses are
end-to-end encrypted” claims until every legacy couple has migrated and every answer-bearing game
path fails closed when its couple key is unavailable. Security claims must describe deployed behavior,
not only the intended architecture.
## Asset rules ## Asset rules
- Store graphics and screenshots should use the same purple/pink palette as the product. - Store graphics and screenshots should use the same purple/pink palette as the product.