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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
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.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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(36.dp).align(Alignment.Center),
|
modifier = Modifier.size(36.dp),
|
||||||
color = AuthPrimary,
|
color = AuthPrimary,
|
||||||
strokeWidth = 3.dp
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
- 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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue