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:
parent
6828be72fc
commit
9a0b2b6a3d
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue