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.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)
}
}
}

View File

@ -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,

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,
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.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,

View File

@ -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()

View File

@ -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,

View File

@ -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
)
}

View File

@ -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))

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,
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.