From 9a0b2b6a3d94437a9a918d69947fc4bd3a6e3fe8 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 20:24:50 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20BrandMessageRotator=20=E2=80=94=20rotat?= =?UTF-8?q?ing=20privacy=20copy=20across=20screens=20(batch=20v0.2.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../closer/ui/answers/AnswerRevealScreen.kt | 2 + .../java/app/closer/ui/auth/LoginScreen.kt | 6 +- .../app/closer/ui/brand/CloserBrandCopy.kt | 13 ++++ .../ui/components/BrandMessageRotator.kt | 76 +++++++++++++++++++ .../app/closer/ui/components/LoadingState.kt | 4 + .../closer/ui/desiresync/DesireSyncScreen.kt | 2 + .../ui/games/WaitingForPartnerScreen.kt | 10 +++ .../app/closer/ui/howwell/HowWellScreen.kt | 2 + .../closer/ui/onboarding/OnboardingScreen.kt | 24 +++--- .../closer/ui/thisorthat/ThisOrThatScreen.kt | 2 + .../closer/ui/brand/CloserBrandCopyTest.kt | 16 ++++ docs/brand/visual-identity.md | 16 ++++ 12 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/brand/CloserBrandCopy.kt create mode 100644 app/src/main/java/app/closer/ui/components/BrandMessageRotator.kt create mode 100644 app/src/test/java/app/closer/ui/brand/CloserBrandCopyTest.kt diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index b1ebd5ac..88815ef0 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -48,6 +48,7 @@ import app.closer.domain.model.LocalAnswer import app.closer.domain.model.Question import app.closer.ui.questions.displayCategoryName import app.closer.ui.questions.displayQuestionType +import app.closer.ui.components.BrandMessageRotator @Composable fun AnswerRevealScreen( @@ -461,6 +462,7 @@ private fun WaitingForPartnerCard() { maxLines = 3, overflow = TextOverflow.Ellipsis ) + BrandMessageRotator(style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt index ebc4fc02..411d5ab0 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute +import app.closer.ui.components.BrandMessageRotator @Composable fun LoginScreen( @@ -110,7 +111,10 @@ fun LoginScreen( textAlign = TextAlign.Center ) - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(16.dp)) + BrandMessageRotator(color = AuthMuted) + + Spacer(Modifier.height(24.dp)) OutlinedTextField( value = state.email, diff --git a/app/src/main/java/app/closer/ui/brand/CloserBrandCopy.kt b/app/src/main/java/app/closer/ui/brand/CloserBrandCopy.kt new file mode 100644 index 00000000..97ba5ce6 --- /dev/null +++ b/app/src/main/java/app/closer/ui/brand/CloserBrandCopy.kt @@ -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 = 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." + ) +} diff --git a/app/src/main/java/app/closer/ui/components/BrandMessageRotator.kt b/app/src/main/java/app/closer/ui/components/BrandMessageRotator.kt new file mode 100644 index 00000000..65f57cc5 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/BrandMessageRotator.kt @@ -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 = 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 + ) + } +} diff --git a/app/src/main/java/app/closer/ui/components/LoadingState.kt b/app/src/main/java/app/closer/ui/components/LoadingState.kt index ca369b9b..39335e00 100644 --- a/app/src/main/java/app/closer/ui/components/LoadingState.kt +++ b/app/src/main/java/app/closer/ui/components/LoadingState.kt @@ -64,6 +64,10 @@ fun LoadingState( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) + BrandMessageRotator( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodySmall + ) } } } diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 1f4eb27d..a9a1cbce 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -65,6 +65,7 @@ import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.usecase.GameSessionManager import dagger.hilt.android.qualifiers.ApplicationContext +import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.StatusGlyph import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush @@ -553,6 +554,7 @@ private fun DSWaitingScreen(partnerName: String, onBack: () -> Unit, onAbandon: ) Spacer(Modifier.height(4.dp)) CircularProgressIndicator(color = CloserPalette.Romantic, strokeWidth = 3.dp) + BrandMessageRotator(style = MaterialTheme.typography.bodySmall) Spacer(Modifier.weight(1f)) OutlinedButton( onClick = onBack, diff --git a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt index 328b926d..646a8eee 100644 --- a/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt +++ b/app/src/main/java/app/closer/ui/games/WaitingForPartnerScreen.kt @@ -32,6 +32,7 @@ import app.closer.core.navigation.AppRoute import app.closer.domain.model.GameType import app.closer.domain.usecase.GameSessionManager import app.closer.ui.components.CategoryGlyph +import app.closer.ui.components.BrandMessageRotator import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -138,6 +139,10 @@ fun WaitingForPartnerScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp) ) + BrandMessageRotator( + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodySmall + ) } else -> { CategoryGlyph( @@ -161,6 +166,11 @@ fun WaitingForPartnerScreen( textAlign = TextAlign.Center ) + BrandMessageRotator( + modifier = Modifier.padding(bottom = 16.dp), + style = MaterialTheme.typography.bodySmall + ) + Button( onClick = { onNavigate(AppRoute.PLAY) }, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 0540a65e..2c65d258 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -73,6 +73,7 @@ import app.closer.data.remote.FirestoreHowWellDataSource import app.closer.data.remote.HowWellAnswers import app.closer.data.remote.HowWellRawAnswer import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.components.BrandMessageRotator import app.closer.ui.components.ResultGlyph import app.closer.ui.components.StatusGlyph import app.closer.ui.theme.CloserPalette @@ -622,6 +623,7 @@ private fun HowWellWaitingScreen(amSubject: Boolean, partnerName: String, onBack ) Spacer(Modifier.height(4.dp)) CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp) + BrandMessageRotator(style = MaterialTheme.typography.bodySmall) Spacer(Modifier.weight(1f)) OutlinedButton( onClick = onBack, diff --git a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt index 86790017..ce0a1fed 100644 --- a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt @@ -55,6 +55,7 @@ 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 @@ -80,11 +81,18 @@ fun OnboardingScreen( .background(AuthBackgroundBrush) ) { if (state.isCheckingAuth) { - CircularProgressIndicator( - modifier = Modifier.size(36.dp).align(Alignment.Center), - color = AuthPrimary, - strokeWidth = 3.dp - ) + 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() @@ -254,11 +262,9 @@ private fun CtaSlide(onNavigate: (String) -> Unit) { textAlign = TextAlign.Center ) Spacer(Modifier.height(12.dp)) - Text( - text = "Ready when you are.", - style = MaterialTheme.typography.bodyLarge, + BrandMessageRotator( color = AuthMuted, - textAlign = TextAlign.Center + style = MaterialTheme.typography.bodyLarge ) } diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 04297e0b..6392f20e 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -73,6 +73,7 @@ import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionSessionRepository import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.components.BrandMessageRotator import android.content.Context import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext @@ -909,6 +910,7 @@ private fun WaitingForRevealScreen( ) Spacer(Modifier.height(4.dp)) CircularProgressIndicator(color = CloserPalette.PurpleDeep, strokeWidth = 3.dp) + BrandMessageRotator(style = MaterialTheme.typography.bodySmall) Spacer(Modifier.weight(1f)) diff --git a/app/src/test/java/app/closer/ui/brand/CloserBrandCopyTest.kt b/app/src/test/java/app/closer/ui/brand/CloserBrandCopyTest.kt new file mode 100644 index 00000000..8117d96a --- /dev/null +++ b/app/src/test/java/app/closer/ui/brand/CloserBrandCopyTest.kt @@ -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 }) + } +} diff --git a/docs/brand/visual-identity.md b/docs/brand/visual-identity.md index ec69a370..3a423ff3 100644 --- a/docs/brand/visual-identity.md +++ b/docs/brand/visual-identity.md @@ -36,6 +36,22 @@ mode. - Prefer calm, specific language. Avoid promises to “fix” a relationship, competitive streak copy, 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 - Store graphics and screenshots should use the same purple/pink palette as the product.