feat: settings polish, privacy strings, home partner state, proguard rules

This commit is contained in:
null 2026-06-21 09:49:02 -05:00
parent 0a377ecdda
commit dff86eb089
10 changed files with 734 additions and 109 deletions

View File

@ -43,6 +43,14 @@
# ── Prevent stripping of BuildConfig ───────────────────────────────────────── # ── Prevent stripping of BuildConfig ─────────────────────────────────────────
-keep class app.closer.BuildConfig { *; } -keep class app.closer.BuildConfig { *; }
# ── Strip debug/verbose logs from release builds ─────────────────────────────
# Log.w / Log.e / Log.i are kept useful for Crashlytics context.
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static boolean isLoggable(java.lang.String, int);
}
# ── Suppress warnings for optional dependencies ────────────────────────────── # ── Suppress warnings for optional dependencies ──────────────────────────────
-dontwarn org.conscrypt.** -dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**

View File

@ -12,8 +12,7 @@ object ExternalLinks {
const val TERMS_OF_SERVICE = "https://closer.app/terms" const val TERMS_OF_SERVICE = "https://closer.app/terms"
// TODO: Update placeholder URL before production. // TODO: Update placeholder URL before production.
const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms" const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms"
// TODO: Update placeholder URL before production. const val SUPPORT = "https://closer.app/support"
const val SUPPORT = "https://couplesconnect.app/support"
fun openUrl(context: Context, url: String) { fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))

View File

@ -53,6 +53,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.clickable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -169,7 +170,8 @@ fun HomeScreen(
onReminder = viewModel::sendGentleReminder, onReminder = viewModel::sendGentleReminder,
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } }, onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } }, onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
onRefresh = viewModel::loadHome onRefresh = viewModel::loadHome,
onPartner = { if (state.isPaired) onNavigate(AppRoute.PARTNER_HOME) }
) )
} }
@ -215,7 +217,8 @@ private fun HomeContent(
onReminder: () -> Unit, onReminder: () -> Unit,
onReveal: () -> Unit, onReveal: () -> Unit,
onFollowUp: () -> Unit, onFollowUp: () -> Unit,
onRefresh: () -> Unit onRefresh: () -> Unit,
onPartner: () -> Unit = {}
) { ) {
val callbacks = remember( val callbacks = remember(
onDailyQuestion, onReminder, onReveal, onFollowUp, onDailyQuestion, onReminder, onReveal, onFollowUp,
@ -276,7 +279,8 @@ private fun HomeContent(
StreakCard( StreakCard(
streakCount = state.streakCount, streakCount = state.streakCount,
partnerName = state.partnerName partnerName = state.partnerName,
onPartner = onPartner
) )
when { when {
@ -337,6 +341,7 @@ private fun HomeContent(
private fun StreakCard( private fun StreakCard(
streakCount: Int, streakCount: Int,
partnerName: String?, partnerName: String?,
onPartner: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val copy = when (streakCount) { val copy = when (streakCount) {
@ -393,9 +398,10 @@ private fun StreakCard(
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = CloserPalette.PurpleDeep,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onPartner)
) )
} }
} }

View File

@ -1,35 +1,455 @@
package app.closer.ui.home package app.closer.ui.home
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.FinishedEmptyStateAction import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.ui.components.FinishedEmptyStateScreen import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
data class PartnerHomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
val partnerName: String? = null,
val streakCount: Int = 0,
val hasPartnerAnsweredToday: Boolean = false,
val coupleId: String? = null,
val dailyQuestionId: String? = null,
val isSendingReminder: Boolean = false,
val reminderSentEvent: Boolean = false,
val reminderError: String? = null,
)
@HiltViewModel
class PartnerHomeViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val answerDataSource: FirestoreAnswerDataSource,
private val db: FirebaseFirestore,
private val functions: FirebaseFunctions,
) : ViewModel() {
private val _uiState = MutableStateFlow(PartnerHomeUiState())
val uiState: StateFlow<PartnerHomeUiState> = _uiState.asStateFlow()
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
init {
load()
}
private fun load() {
viewModelScope.launch {
val uid = authRepository.currentUserId
if (uid == null) {
_uiState.update { it.copy(isLoading = false, error = "Not signed in.") }
return@launch
}
runCatching {
val couple = coupleRepository.getCoupleForUser(uid)
if (couple == null) {
_uiState.update { it.copy(isLoading = false, error = "No partner linked yet.") }
return@launch
}
val partnerId = couple.userIds.firstOrNull { it != uid }
val partnerName = partnerId?.let { pid ->
runCatching { userRepository.getUser(pid)?.displayName }
.onFailure { Log.w(TAG, "Could not load partner name", it) }
.getOrNull()
}
val dailyAssignment = runCatching {
answerDataSource.getDailyQuestionAssignment(couple.id)
}.getOrNull()
_uiState.update {
it.copy(
isLoading = false,
partnerName = partnerName,
streakCount = couple.streakCount,
coupleId = couple.id,
dailyQuestionId = dailyAssignment?.questionId,
)
}
observePartnerAnswer(couple.id, couple.userIds, dailyAssignment?.questionId)
}.onFailure { e ->
Log.e(TAG, "Load failed", e)
_uiState.update { it.copy(isLoading = false, error = "Could not load partner info.") }
}
}
}
private fun observePartnerAnswer(coupleId: String, userIds: List<String>, questionId: String?) {
partnerAnswerListener?.remove()
val uid = authRepository.currentUserId ?: return
val partnerId = userIds.firstOrNull { it != uid } ?: return
val qId = questionId ?: return
val today = FirestoreAnswerDataSource.todayLocalDateString()
partnerAnswerListener = db.collection("couples").document(coupleId)
.collection("daily_question").document(today)
.collection("answers").document(partnerId)
.addSnapshotListener { snapshot, error ->
if (error != null) {
Log.w(TAG, "Partner answer listener error", error)
return@addSnapshotListener
}
_uiState.update { it.copy(hasPartnerAnsweredToday = snapshot?.exists() == true) }
}
}
fun sendReminder() {
if (_uiState.value.isSendingReminder) return
_uiState.update { it.copy(isSendingReminder = true, reminderError = null) }
viewModelScope.launch {
runCatching {
functions.getHttpsCallable("sendGentleReminderCallable").call().await()
}.onSuccess {
_uiState.update { it.copy(isSendingReminder = false, reminderSentEvent = true) }
}.onFailure { e ->
Log.w(TAG, "Reminder failed", e)
_uiState.update { it.copy(isSendingReminder = false, reminderError = "Could not send reminder.") }
}
}
}
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
fun consumeReminderError() = _uiState.update { it.copy(reminderError = null) }
override fun onCleared() {
super.onCleared()
partnerAnswerListener?.remove()
}
private companion object {
const val TAG = "PartnerHomeVM"
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PartnerHomeScreen( fun PartnerHomeScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: PartnerHomeViewModel = hiltViewModel()
) { ) {
FinishedEmptyStateScreen( val state by viewModel.uiState.collectAsState()
eyebrow = "Partner space", val snackbar = remember { SnackbarHostState() }
title = "Connect your shared home",
body = "Invite your partner to unlock shared prompts, paired games, and a relationship rhythm built for both of you.", LaunchedEffect(state.reminderSentEvent) {
glyphCategoryId = "people", if (state.reminderSentEvent) {
primaryAction = FinishedEmptyStateAction("Invite partner", AppRoute.CREATE_INVITE), snackbar.showSnackbar("Reminder sent to ${state.partnerName ?: "your partner"}.")
secondaryAction = FinishedEmptyStateAction("Back home", AppRoute.HOME), viewModel.consumeReminderSentEvent()
accent = CloserPalette.PurpleDeep, }
details = listOf( }
"Pair once, then share prompts without repeating setup.", LaunchedEffect(state.reminderError) {
"Keep shared activity distinct from private reflections.", state.reminderError?.let {
"Return to the couple space when both of you are ready." snackbar.showSnackbar(it)
), viewModel.consumeReminderError()
onNavigate = onNavigate }
) }
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(closerBackgroundBrush()),
topBar = {
TopAppBar(
title = {
Text(
text = state.partnerName ?: "Your Partner",
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
when {
state.isLoading -> LoadingState(modifier = Modifier.fillMaxSize().padding(padding))
state.error != null -> ErrorState(
message = state.error!!,
modifier = Modifier.fillMaxSize().padding(padding)
)
else -> PartnerHomeContent(
state = state,
onSendReminder = viewModel::sendReminder,
onNavigate = onNavigate,
modifier = Modifier.padding(padding)
)
}
}
} }
@Preview
@Composable @Composable
fun PartnerHomeScreenPreview() { private fun PartnerHomeContent(
PartnerHomeScreen() state: PartnerHomeUiState,
onSendReminder: () -> Unit,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
PartnerIdentityCard(name = state.partnerName, streakCount = state.streakCount)
PartnerActivityCard(
partnerName = state.partnerName,
hasAnsweredToday = state.hasPartnerAnsweredToday,
isSendingReminder = state.isSendingReminder,
onSendReminder = onSendReminder
)
if (state.dailyQuestionId != null) {
Button(
onClick = { onNavigate(AppRoute.DAILY_QUESTION) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep,
contentColor = Color.White
)
) {
Text("View today's question", fontWeight = FontWeight.SemiBold)
}
}
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun PartnerIdentityCard(
name: String?,
streakCount: Int,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = closerCardColor(alpha = 0.9f),
shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = CircleShape,
color = CloserPalette.PurpleDeep.copy(alpha = 0.14f),
modifier = Modifier.size(56.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = (name?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp
),
color = CloserPalette.PurpleDeep
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = name ?: "Your partner",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = CloserPalette.PinkAccentDeep,
modifier = Modifier.size(15.dp)
)
Text(
text = when (streakCount) {
0 -> "Start a streak together"
1 -> "1 day streak"
else -> "$streakCount day streak"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun PartnerActivityCard(
partnerName: String?,
hasAnsweredToday: Boolean,
isSendingReminder: Boolean,
onSendReminder: () -> Unit,
modifier: Modifier = Modifier
) {
val firstName = partnerName?.substringBefore(" ") ?: "Your partner"
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = closerCardColor(alpha = 0.9f),
shadowElevation = 2.dp
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = CircleShape,
color = if (hasAnsweredToday) {
CloserPalette.PurpleDeep.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = if (hasAnsweredToday) Icons.Filled.Check else Icons.Filled.HourglassEmpty,
contentDescription = null,
tint = if (hasAnsweredToday) CloserPalette.PurpleDeep else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = if (hasAnsweredToday) "$firstName answered" else "$firstName hasn't answered yet",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (hasAnsweredToday) "Today's question is complete" else "Waiting for today's question",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (!hasAnsweredToday) {
Button(
onClick = onSendReminder,
enabled = !isSendingReminder,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep.copy(alpha = 0.1f),
contentColor = CloserPalette.PurpleDeep,
disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.06f),
disabledContentColor = CloserPalette.PurpleDeep.copy(alpha = 0.4f)
)
) {
Icon(
Icons.Filled.NotificationsNone,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.size(6.dp))
Text(
text = if (isSendingReminder) "Sending…" else "Send a gentle nudge",
fontWeight = FontWeight.SemiBold
)
}
}
}
}
} }

View File

@ -44,12 +44,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -63,6 +65,7 @@ fun AccountScreen(
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val phrasecopiedMsg = stringResource(R.string.account_recovery_phrase_copied)
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
@ -70,12 +73,12 @@ fun AccountScreen(
modifier = Modifier.background(SettingsBackgroundBrush), modifier = Modifier.background(SettingsBackgroundBrush),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Account", color = SettingsInk) }, title = { Text(stringResource(R.string.account_title), color = SettingsInk) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigate("back") }) { IconButton(onClick = { onNavigate("back") }) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(R.string.action_back),
tint = SettingsInk tint = SettingsInk
) )
} }
@ -125,7 +128,7 @@ fun AccountScreen(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
text = "Recovery phrase", text = stringResource(R.string.account_recovery_phrase_title),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = SettingsInk, color = SettingsInk,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -134,13 +137,13 @@ fun AccountScreen(
IconButton( IconButton(
onClick = { onClick = {
clipboard.setText(AnnotatedString(phrase)) clipboard.setText(AnnotatedString(phrase))
scope.launch { snackbar.showSnackbar("Recovery phrase copied") } scope.launch { snackbar.showSnackbar(phrasecopiedMsg) }
}, },
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
Icons.Filled.ContentCopy, Icons.Filled.ContentCopy,
contentDescription = "Copy phrase", contentDescription = stringResource(R.string.account_recovery_phrase_copy_desc),
tint = SettingsMuted, tint = SettingsMuted,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
@ -159,7 +162,7 @@ fun AccountScreen(
.padding(horizontal = 12.dp, vertical = 10.dp) .padding(horizontal = 12.dp, vertical = 10.dp)
) )
Text( Text(
text = "Keep this safe. Either partner can use it to restore access on a new device.", text = stringResource(R.string.account_recovery_phrase_footer),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsMuted color = SettingsMuted
) )
@ -175,7 +178,7 @@ fun AccountScreen(
) { ) {
AccountRow( AccountRow(
icon = Icons.Filled.Delete, icon = Icons.Filled.Delete,
label = "Delete account", label = stringResource(R.string.action_delete_account),
tint = SettingsDanger, tint = SettingsDanger,
onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) } onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) }
) )

View File

@ -35,8 +35,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.R
import app.closer.domain.repository.ThemeMode import app.closer.domain.repository.ThemeMode
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -52,12 +54,12 @@ fun AppearanceScreen(
modifier = Modifier.background(SettingsBackgroundBrush), modifier = Modifier.background(SettingsBackgroundBrush),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Appearance", color = SettingsInk) }, title = { Text(stringResource(R.string.appearance_title), color = SettingsInk) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigate("back") }) { IconButton(onClick = { onNavigate("back") }) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(R.string.action_back),
tint = SettingsInk tint = SettingsInk
) )
} }
@ -77,7 +79,7 @@ fun AppearanceScreen(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "Theme", text = stringResource(R.string.appearance_theme_section),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
@ -90,22 +92,22 @@ fun AppearanceScreen(
) { ) {
Column { Column {
ThemeOptionRow( ThemeOptionRow(
label = "Device default", label = stringResource(R.string.appearance_theme_device_default),
description = "Match your device's light or dark setting", description = stringResource(R.string.appearance_theme_device_default_desc),
selected = state.themeMode == ThemeMode.DEVICE, selected = state.themeMode == ThemeMode.DEVICE,
onClick = { viewModel.setThemeMode(ThemeMode.DEVICE) } onClick = { viewModel.setThemeMode(ThemeMode.DEVICE) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
ThemeOptionRow( ThemeOptionRow(
label = "Light", label = stringResource(R.string.appearance_theme_light),
description = "Always use light mode", description = stringResource(R.string.appearance_theme_light_desc),
selected = state.themeMode == ThemeMode.LIGHT, selected = state.themeMode == ThemeMode.LIGHT,
onClick = { viewModel.setThemeMode(ThemeMode.LIGHT) } onClick = { viewModel.setThemeMode(ThemeMode.LIGHT) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
ThemeOptionRow( ThemeOptionRow(
label = "Dark", label = stringResource(R.string.appearance_theme_dark),
description = "Always use dark mode", description = stringResource(R.string.appearance_theme_dark_desc),
selected = state.themeMode == ThemeMode.DARK, selected = state.themeMode == ThemeMode.DARK,
onClick = { viewModel.setThemeMode(ThemeMode.DARK) } onClick = { viewModel.setThemeMode(ThemeMode.DARK) }
) )
@ -115,7 +117,7 @@ fun AppearanceScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = "Changes apply instantly across the whole app.", text = stringResource(R.string.appearance_footer),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)

View File

@ -46,10 +46,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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.R
data class NotificationSettingsUiState( data class NotificationSettingsUiState(
val dailyReminderEnabled: Boolean = true, val dailyReminderEnabled: Boolean = true,
@ -121,12 +123,12 @@ fun NotificationSettingsScreen(
modifier = Modifier.background(SettingsBackgroundBrush), modifier = Modifier.background(SettingsBackgroundBrush),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Notifications", color = SettingsInk) }, title = { Text(stringResource(R.string.notifications_title), color = SettingsInk) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigate("back") }) { IconButton(onClick = { onNavigate("back") }) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(R.string.action_back),
tint = SettingsInk tint = SettingsInk
) )
} }
@ -146,7 +148,7 @@ fun NotificationSettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "Reminders", text = stringResource(R.string.notifications_reminders_section),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
@ -159,28 +161,28 @@ fun NotificationSettingsScreen(
) { ) {
Column { Column {
NotifToggleRow( NotifToggleRow(
label = "Daily question", label = stringResource(R.string.notifications_daily_question),
description = "A gentle nudge when today's question is ready", description = "A gentle nudge when today's question is ready",
checked = state.dailyReminderEnabled, checked = state.dailyReminderEnabled,
onCheckedChange = viewModel::toggleDailyReminder onCheckedChange = viewModel::toggleDailyReminder
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
NotifToggleRow( NotifToggleRow(
label = "Partner answered", label = stringResource(R.string.notifications_partner_answered),
description = "Let me know when they're ready to reveal", description = "Let me know when they're ready to reveal",
checked = state.partnerAnsweredEnabled, checked = state.partnerAnsweredEnabled,
onCheckedChange = viewModel::togglePartnerAnswered onCheckedChange = viewModel::togglePartnerAnswered
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
NotifToggleRow( NotifToggleRow(
label = "New chat message", label = stringResource(R.string.notifications_chat_message),
description = "Notify me when my partner sends a message", description = "Notify me when my partner sends a message",
checked = state.chatMessageEnabled, checked = state.chatMessageEnabled,
onCheckedChange = viewModel::toggleChatMessage onCheckedChange = viewModel::toggleChatMessage
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
NotifToggleRow( NotifToggleRow(
label = "Shared rhythm reminder", label = stringResource(R.string.notifications_streak_reminder),
description = "Remind me to keep our shared rhythm going", description = "Remind me to keep our shared rhythm going",
checked = state.streakReminderEnabled, checked = state.streakReminderEnabled,
onCheckedChange = viewModel::toggleStreakReminder onCheckedChange = viewModel::toggleStreakReminder
@ -191,7 +193,7 @@ fun NotificationSettingsScreen(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = "Quiet hours", text = stringResource(R.string.notifications_quiet_hours),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
@ -204,8 +206,8 @@ fun NotificationSettingsScreen(
) { ) {
Column { Column {
NotifToggleRow( NotifToggleRow(
label = "Quiet hours", label = stringResource(R.string.notifications_quiet_hours),
description = "Pause all notifications from 10 PM to 8 AM", description = stringResource(R.string.notifications_quiet_hours_desc),
checked = state.quietHoursEnabled, checked = state.quietHoursEnabled,
onCheckedChange = viewModel::toggleQuietHours onCheckedChange = viewModel::toggleQuietHours
) )
@ -215,7 +217,7 @@ fun NotificationSettingsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = "Notifications from Closer are gentle invitations, not alerts. You're always in control of when they arrive.", text = stringResource(R.string.notifications_footer),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsMuted, color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)

View File

@ -41,9 +41,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.core.navigation.ExternalLinks import app.closer.core.navigation.ExternalLinks
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
@ -59,12 +61,12 @@ fun PrivacyScreen(
modifier = Modifier.background(SettingsBackgroundBrush), modifier = Modifier.background(SettingsBackgroundBrush),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Privacy & Terms", color = SettingsInk) }, title = { Text(stringResource(R.string.privacy_title), color = SettingsInk) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigate("back") }) { IconButton(onClick = { onNavigate("back") }) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = stringResource(R.string.action_back),
tint = SettingsInk tint = SettingsInk
) )
} }
@ -84,7 +86,7 @@ fun PrivacyScreen(
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
Text( Text(
text = "Closer is built on one rule: answers stay private until both of you have answered. Here's exactly what that means.", text = stringResource(R.string.privacy_rule_intro),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = SettingsMuted color = SettingsMuted
) )
@ -93,7 +95,7 @@ fun PrivacyScreen(
PrivacySectionHeader( PrivacySectionHeader(
icon = Icons.Default.CheckCircle, icon = Icons.Default.CheckCircle,
iconTint = CloserPalette.PurpleDeep, iconTint = CloserPalette.PurpleDeep,
title = "What your partner can see" title = stringResource(R.string.privacy_section_partner_visible)
) )
Card( Card(
@ -103,33 +105,33 @@ fun PrivacyScreen(
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
PrivacyRow( PrivacyRow(
title = "Daily question answers", title = stringResource(R.string.privacy_daily_answers),
body = "Only after you've both answered. Until then, your partner sees \"waiting for you\" — not your answer." body = stringResource(R.string.privacy_only_after_both_answered)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Game results", title = stringResource(R.string.privacy_game_results),
body = "Revealed together at the end of a round — This or That matches, How Well Do You Know Me scores, and Desire Sync overlaps." body = stringResource(R.string.privacy_game_results_revealed)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Desire Sync: shared yes answers only", title = stringResource(R.string.privacy_desire_mutual),
body = "Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed." body = stringResource(R.string.privacy_desire_mutual_body)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Discussion messages and reactions", title = stringResource(R.string.privacy_messages),
body = "Messages you send in question threads are visible to your partner in real time." body = stringResource(R.string.privacy_messages_visible)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Shared game history", title = stringResource(R.string.privacy_game_history),
body = "Both partners can replay past rounds from Past Games. The replay shows the same answers both of you gave." body = stringResource(R.string.privacy_game_history_body)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Streak and shared wins", title = stringResource(R.string.privacy_streak),
body = "Your couple's streak count and challenge completions are shared between both of you." body = stringResource(R.string.privacy_streak_body)
) )
} }
} }
@ -138,7 +140,7 @@ fun PrivacyScreen(
PrivacySectionHeader( PrivacySectionHeader(
icon = Icons.Default.Lock, icon = Icons.Default.Lock,
iconTint = CloserPalette.Romantic, iconTint = CloserPalette.Romantic,
title = "What stays private" title = stringResource(R.string.privacy_section_data)
) )
Card( Card(
@ -148,23 +150,23 @@ fun PrivacyScreen(
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
PrivacyRow( PrivacyRow(
title = "Answers before your partner answers", title = stringResource(R.string.privacy_answers_before_reveal),
body = "In the app, your answer stays hidden until both of you have answered." body = stringResource(R.string.privacy_answer_stays_hidden)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Unanswered desire prompts", title = stringResource(R.string.privacy_unanswered_desire),
body = "In Desire Sync, prompts where only one of you tapped yes are never surfaced to either person." body = stringResource(R.string.privacy_desire_one_sided)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Time capsule contents", title = stringResource(R.string.privacy_capsule),
body = "What's inside a capsule stays sealed until the unlock date both partners agreed on." body = stringResource(R.string.privacy_capsule_sealed)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "Notification settings", title = stringResource(R.string.notifications_title),
body = "Your notification preferences are yours alone. Your partner cannot see or change them." body = stringResource(R.string.notifications_footer)
) )
} }
} }
@ -173,7 +175,7 @@ fun PrivacyScreen(
PrivacySectionHeader( PrivacySectionHeader(
icon = Icons.Default.VisibilityOff, icon = Icons.Default.VisibilityOff,
iconTint = SettingsDanger, iconTint = SettingsDanger,
title = "Deleting your account" title = stringResource(R.string.account_delete_title)
) )
Card( Card(
@ -183,13 +185,13 @@ fun PrivacyScreen(
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
PrivacyRow( PrivacyRow(
title = "Immediate and permanent", title = stringResource(R.string.account_delete_immediate),
body = "Deleting your account removes your profile and sign-in instantly. Your partner is unpaired and can start fresh. This cannot be undone." body = stringResource(R.string.account_delete_body)
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
PrivacyRow( PrivacyRow(
title = "No data export", title = stringResource(R.string.privacy_no_export),
body = "Closer does not currently offer a data export. Your answers and history are deleted along with your account." body = stringResource(R.string.privacy_no_export_body)
) )
} }
} }
@ -198,7 +200,7 @@ fun PrivacyScreen(
// ── Legal docs ──────────────────────────────────────────────────── // ── Legal docs ────────────────────────────────────────────────────
Text( Text(
text = "Legal documents", text = stringResource(R.string.privacy_legal_documents),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = SettingsInk, color = SettingsInk,
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)
@ -211,25 +213,25 @@ fun PrivacyScreen(
) { ) {
Column { Column {
LegalLinkRow( LegalLinkRow(
label = "Privacy Policy", label = stringResource(R.string.privacy_policy),
description = "How we handle your data", description = "How we handle your data",
onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) } onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow( LegalLinkRow(
label = "Terms of Service", label = stringResource(R.string.privacy_terms),
description = "Your rights and our responsibilities", description = "Your rights and our responsibilities",
onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) } onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow( LegalLinkRow(
label = "Subscription Terms", label = stringResource(R.string.privacy_subscription_terms),
description = "Billing, renewals, and cancellations", description = "Billing, renewals, and cancellations",
onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) } onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) }
) )
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow( LegalLinkRow(
label = "Support", label = stringResource(R.string.privacy_support),
description = "Get help or contact us", description = "Get help or contact us",
onClick = { uriHandler.openUri(ExternalLinks.SUPPORT) } onClick = { uriHandler.openUri(ExternalLinks.SUPPORT) }
) )

View File

@ -1,4 +1,141 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Closer</string> <string name="app_name">Closer</string>
<!-- ── Common actions ─────────────────────────────────────────── -->
<string name="action_back">Back</string>
<string name="action_cancel">Cancel</string>
<string name="action_continue">Continue</string>
<string name="action_copy">Copy</string>
<string name="action_share">Share</string>
<string name="action_save">Save</string>
<string name="action_confirm">Confirm</string>
<string name="action_ok">OK</string>
<string name="action_done">Done</string>
<string name="action_retry">Retry</string>
<string name="action_sign_out">Sign out</string>
<string name="action_delete_account">Delete account</string>
<string name="action_invite_partner">Invite partner</string>
<string name="action_back_home">Back home</string>
<string name="action_create_plan">Create Plan</string>
<!-- ── Settings nav labels ────────────────────────────────────── -->
<string name="settings_account">Account</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_privacy">Privacy &amp; Terms</string>
<string name="settings_subscription">Subscription</string>
<string name="settings_answer_history">Answer History</string>
<!-- ── Appearance screen ──────────────────────────────────────── -->
<string name="appearance_title">Appearance</string>
<string name="appearance_theme_section">Theme</string>
<string name="appearance_theme_device_default">Device default</string>
<string name="appearance_theme_device_default_desc">Follow the system light/dark setting</string>
<string name="appearance_theme_light">Light</string>
<string name="appearance_theme_light_desc">Always use the light theme</string>
<string name="appearance_theme_dark">Dark</string>
<string name="appearance_theme_dark_desc">Always use the dark theme</string>
<string name="appearance_footer">Changes apply instantly across the whole app.</string>
<!-- ── Notification settings ──────────────────────────────────── -->
<string name="notifications_title">Notification settings</string>
<string name="notifications_reminders_section">Reminders</string>
<string name="notifications_daily_question">Daily question</string>
<string name="notifications_partner_answered">Partner answered</string>
<string name="notifications_chat_message">New chat message</string>
<string name="notifications_streak_reminder">Shared rhythm reminder</string>
<string name="notifications_quiet_hours">Quiet hours</string>
<string name="notifications_quiet_hours_desc">10 PM 8 AM, no notifications</string>
<string name="notifications_footer">Your notification preferences are yours alone. Your partner cannot see or change them.</string>
<!-- ── Account screen ─────────────────────────────────────────── -->
<string name="account_title">Account</string>
<string name="account_recovery_phrase_title">Recovery phrase</string>
<string name="account_recovery_phrase_copy_desc">Copy phrase</string>
<string name="account_recovery_phrase_copied">Recovery phrase copied</string>
<string name="account_recovery_phrase_footer">Keep this safe. Either partner can use it to restore access on a new device.</string>
<string name="account_delete_title">Deleting your account</string>
<string name="account_delete_body">Deleting your account removes your profile and sign-in instantly. Your partner is unpaired and can start fresh. This cannot be undone.</string>
<string name="account_delete_immediate">Immediate and permanent</string>
<!-- ── Privacy screen ─────────────────────────────────────────── -->
<string name="privacy_title">Privacy &amp; Terms</string>
<string name="privacy_section_data">What stays private</string>
<string name="privacy_section_partner_visible">What your partner can see</string>
<string name="privacy_answers_before_reveal">Answers before your partner answers</string>
<string name="privacy_answer_stays_hidden">In the app, your answer stays hidden until both of you have answered.</string>
<string name="privacy_only_after_both_answered">Only after you\'ve both answered. Until then, your partner sees \"Waiting for you to answer first.\"</string>
<string name="privacy_unanswered_desire">Unanswered desire prompts</string>
<string name="privacy_desire_one_sided">In Desire Sync, prompts where only one of you tapped yes are never surfaced to either person.</string>
<string name="privacy_messages">Discussion messages and reactions</string>
<string name="privacy_messages_visible">Messages you send in question threads are visible to your partner in real time.</string>
<string name="privacy_game_results">Game results</string>
<string name="privacy_game_results_revealed">Revealed together at the end of a round — This or That matches, How Well Do You Know Me scores, and Desire Sync overlaps.</string>
<string name="privacy_game_history">Shared game history</string>
<string name="privacy_game_history_body">Both partners can replay past rounds from Past Games. The replay shows the same answers both of you gave.</string>
<string name="privacy_daily_answers">Daily question answers</string>
<string name="privacy_capsule">Time capsule contents</string>
<string name="privacy_capsule_sealed">What\'s inside a capsule stays sealed until the unlock date both partners agreed on.</string>
<string name="privacy_desire_mutual">Desire Sync: shared yes answers only</string>
<string name="privacy_desire_mutual_body">Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed.</string>
<string name="privacy_progress">Your Progress</string>
<string name="privacy_streak">Streak and shared wins</string>
<string name="privacy_streak_body">Your couple\'s streak count and challenge completions are shared between both of you.</string>
<string name="privacy_no_export">No data export</string>
<string name="privacy_no_export_body">Closer does not currently offer a data export. Your answers and history are deleted along with your account.</string>
<string name="privacy_rule_intro">Closer is built on one rule: answers stay private until both of you have answered. Here\'s exactly what that means.</string>
<string name="privacy_legal_section">Legal</string>
<string name="privacy_legal_documents">Legal documents</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="privacy_terms">Terms of Service</string>
<string name="privacy_subscription_terms">Subscription Terms</string>
<string name="privacy_support">Support</string>
<!-- ── Pairing — Create invite ────────────────────────────────── -->
<string name="pairing_create_title">Invite your person</string>
<string name="pairing_create_subtitle">Share this code with your partner so they can connect with you.</string>
<string name="pairing_create_expiry">Code expires in 24 hours</string>
<string name="pairing_create_share_title">Share invite code</string>
<string name="pairing_create_already_has_code">Partner already has a code? Accept instead</string>
<string name="pairing_create_no_code">No invite code yet</string>
<string name="pairing_create_no_code_body">Tap back and try creating an invite again.</string>
<string name="pairing_code_copied">Code copied!</string>
<!-- ── Pairing — Accept invite ────────────────────────────────── -->
<string name="pairing_accept_title">Enter the code</string>
<string name="pairing_accept_subtitle">Ask your partner to share their 6-character invite code.</string>
<string name="pairing_accept_create_instead">Need to create an invite instead?</string>
<!-- ── Pairing — Confirm invite ───────────────────────────────── -->
<string name="pairing_confirm_body">Once you confirm, you\'ll be connected and can start exploring questions together.</string>
<string name="pairing_confirm_action">Pair up</string>
<string name="pairing_confirm_wrong_code">That\'s not right — enter a different code</string>
<string name="pairing_recovery_phrase_title">Recovery phrase</string>
<string name="pairing_recovery_phrase_body">Write this down and share it with your partner. You\'ll both need it to access your answers on a new phone.</string>
<!-- ── Home screen ─────────────────────────────────────────────── -->
<string name="home_for_tonight">For tonight</string>
<string name="home_streak_start">Start a new streak today</string>
<string name="home_streak_one_day">1 day streak</string>
<string name="home_streak_n_days">%d day streak</string>
<string name="home_moment_cue_title">This space is yours</string>
<string name="home_moment_cue_body">Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved.</string>
<string name="home_all_packs">All packs</string>
<!-- ── Partner home ───────────────────────────────────────────── -->
<string name="partner_home_today">Today</string>
<string name="partner_home_answered">answered</string>
<string name="partner_home_not_answered">hasn\'t answered yet</string>
<string name="partner_home_complete">Today\'s question is complete</string>
<string name="partner_home_waiting">Waiting for today\'s question</string>
<string name="partner_home_nudge">Send a gentle nudge</string>
<string name="partner_home_nudge_sending">Sending…</string>
<string name="partner_home_view_question">View today\'s question</string>
<string name="partner_home_streak_zero">Start a streak together</string>
<string name="partner_home_streak_one">1 day streak</string>
<string name="partner_home_streak_n">%d day streak</string>
<string name="partner_home_reminder_sent">Reminder sent to %s.</string>
<string name="partner_home_reminder_error">Could not send reminder.</string>
</resources> </resources>

View File

@ -1,6 +1,7 @@
# Closer — Private MVP QA Checklist # Closer — Private MVP QA Checklist
> Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass. > Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass.
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, and email invite removal.
--- ---
@ -70,10 +71,17 @@
- [ ] Success navigates to home. - [ ] Success navigates to home.
- [ ] "Not right — enter a different code" returns to accept screen. - [ ] "Not right — enter a different code" returns to accept screen.
- [ ] Error surfaced via snackbar. - [ ] Error surfaced via snackbar.
- [ ] Recovery phrase is **not** prompted during confirm — it is returned automatically from the server and stored silently.
- [ ] No recovery phrase input field visible on this screen.
### 2.4 Email invite (`EmailInviteScreen`) ### 2.4 Share invite (was: Email invite — removed)
- [ ] This is currently a `PlaceholderScreen`. Verify it renders and that primary/secondary actions navigate correctly.
- [ ] **Risk**: placeholder actions reference a hardcoded invite code `ABC123`; replace before public release. > `EmailInviteScreen` and the `EMAIL_INVITE` route have been deleted. Sharing is now handled entirely via the system share sheet in `CreateInviteScreen` / `CreateInviteView`. There is no separate email flow or backend email sending.
- [ ] **Android**: "Share" button on `CreateInviteScreen` opens system share sheet with invite message.
- [ ] **iOS**: "Share" button on `CreateInviteView` opens `UIActivityViewController` with the code.
- [ ] Share sheet offers SMS, email, Signal, WhatsApp, etc. — no Closer-specific channel required.
- [ ] Settings → Connection "Invite Partner" row navigates to `CreateInviteView` (not email view).
### 2.5 Relationship settings ### 2.5 Relationship settings
- [ ] `SettingsScreen` partner card opens `RELATIONSHIP_SETTINGS` when paired. - [ ] `SettingsScreen` partner card opens `RELATIONSHIP_SETTINGS` when paired.
@ -252,7 +260,9 @@
- [ ] Partner card shows paired state and partner name, or invite prompt. - [ ] Partner card shows paired state and partner name, or invite prompt.
- [ ] Tapping profile card opens `ACCOUNT`. - [ ] Tapping profile card opens `ACCOUNT`.
- [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired). - [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired).
- [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen.
- [ ] Notifications, Subscription, Privacy & Terms rows open correct screens. - [ ] Notifications, Subscription, Privacy & Terms rows open correct screens.
- [ ] No "Email Invite" or "Invite by Email" row present anywhere in settings.
- [ ] Legal links open external URLs. - [ ] Legal links open external URLs.
- [ ] Delete account row opens `DELETE_ACCOUNT`. - [ ] Delete account row opens `DELETE_ACCOUNT`.
- [ ] Sign out button works and shows loading state. - [ ] Sign out button works and shows loading state.
@ -260,19 +270,34 @@
### 9.2 Account (`AccountScreen`) ### 9.2 Account (`AccountScreen`)
- [ ] "Local profile" card shown for signed-out state. - [ ] "Local profile" card shown for signed-out state.
- [ ] Disabled rows visually greyed out. - [ ] Disabled rows visually greyed out.
- [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button).
- [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar.
- [ ] Recovery phrase card absent when not paired (no phrase to show).
- [ ] Delete account row navigates to `DELETE_ACCOUNT`. - [ ] Delete account row navigates to `DELETE_ACCOUNT`.
- [ ] Back navigation works. - [ ] Back navigation works.
### 9.3 Notifications (`NotificationSettingsScreen`) ### 9.3 Notifications (`NotificationSettingsScreen`)
- [ ] All four toggles reflect persisted state.
- [ ] Toggling each calls repository and updates UI. - [ ] **Five toggles** present: Daily question, Partner answered, New chat message, Shared rhythm reminder, Quiet hours.
- [ ] All toggles reflect persisted `AppStorage` / DataStore state on load.
- [ ] Toggling "Partner answered" or "New chat message" writes `notifPartnerAnswered` / `notifChatMessage` to Firestore user doc (verify in Firebase console).
- [ ] Daily reminder and streak reminder toggles persist locally only (no Firestore write required).
- [ ] Quiet hours description accurate (10 PM 8 AM). - [ ] Quiet hours description accurate (10 PM 8 AM).
- [ ] **iOS parity**: same two server-synced toggles (Partner answered, New chat message) present in iOS Notification Settings and sync to Firestore.
### 9.4 Appearance (`AppearanceScreen`)
- [ ] Three theme options: Device default, Light, Dark.
- [ ] Selecting a theme applies immediately across the app — no restart required.
- [ ] Selection persists after closing and reopening the app.
- [ ] Back navigation returns to Settings.
- [ ] "Device default" follows system dark/light mode correctly.
### 9.4 Privacy (`PrivacyScreen`) ### 9.4 Privacy (`PrivacyScreen`)
- [ ] External links open in browser. - [ ] External links open in browser.
- [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback. - [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback.
- [ ] Back navigation works. - [ ] Back navigation works.
- [ ] **Note**: Support URL is `https://couplesconnect.app/support` (legacy domain); should be migrated to `closer.app` before release. - [ ] Support URL resolves correctly — now `https://closer.app/support`.
### 9.5 Subscription (`SubscriptionScreen`) ### 9.5 Subscription (`SubscriptionScreen`)
- [ ] Renders placeholder with paywall and settings actions. - [ ] Renders placeholder with paywall and settings actions.
@ -313,6 +338,19 @@
- [ ] Token updates sent to backend/user document. - [ ] Token updates sent to backend/user document.
- [ ] Handles sign-out / re-sign-in correctly. - [ ] Handles sign-out / re-sign-in correctly.
### 11.3 Partner answered notification (`onAnswerWritten` CF)
- [ ] Push received on partner's device when you submit a daily question answer.
- [ ] No push received when "Partner answered" toggle is off in Notification Settings.
- [ ] Push not sent to the answering user themselves — only the partner.
### 11.4 Chat message notification (`onMessageWritten` CF)
- [ ] Push received on partner's device when a message is sent in a question thread.
- [ ] No push received when "New chat message" toggle is off in Notification Settings.
- [ ] Push not sent to the message author themselves.
- [ ] **Deployment note**: `onMessageWritten` is a new function — must run `firebase deploy --only functions` before this test is possible.
--- ---
## 12. General UX / Edge Cases ## 12. General UX / Edge Cases
@ -345,17 +383,25 @@
These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required. These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required.
| # | Area | Issue | Severity | | # | Area | Issue | Severity | Status |
|---|------|-------|----------| | --- | --- | --- | --- | --- |
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | | 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue**`DatePickerDialog` and `TimeInput` are already implemented and wired |
| 2 | Special dates | Hardcoded names/dates in `SpecialDatesSection` | High | | 2 | Special dates | Hardcoded names/dates in `SpecialDatesSection` | High | **Not an issue**`SpecialDatesSection` is dead code, never rendered; home shows honest placeholder copy |
| 3 | Email invite | Placeholder screen with hardcoded `ABC123` code | Medium | | 3 | Email invite | Placeholder screen with hardcoded `ABC123` code | Medium | **Fixed** — screen deleted; share sheet is the flow |
| 4 | Subscription | Placeholder screen, not real management | Medium | | 4 | Subscription | Placeholder screen, not real management | Medium | Open |
| 5 | Partner home | Placeholder screen only | Medium | | 5 | Partner home | Placeholder screen only | Medium | **Fixed** — real `PartnerHomeViewModel` + screen with partner identity card, today activity status, send-nudge button, and navigation wired from HomeScreen streak card tap |
| 6 | Settings | Account rows disabled ("Auth coming soon", "Export coming soon") | Medium | | 6 | Settings | Account rows disabled ("Auth coming soon", "Export coming soon") | Medium | **Not an issue**`AccountScreen` no longer has those rows; only Delete account row present |
| 7 | External links | Support URL points to `couplesconnect.app/support` | Low | | 7 | External links | Support URL points to `couplesconnect.app/support` | Low | **Fixed** — updated to `https://closer.app/support` in `ExternalLinks.kt` |
| 8 | Strings | 100+ hardcoded display strings; should move to `strings.xml` for localization | Low | | 8 | Strings | 100+ hardcoded display strings; should move to `strings.xml` for localization | Low | **Partial**`strings.xml` built with 90+ entries; settings cluster (Appearance, Notifications, Account, Privacy) updated to use `stringResource()`; remaining screens (Home, Pairing, Daily Question, etc.) still hardcoded |
| 9 | Logging | `android.util.Log.e` used in `QuestionJsonParser` — acceptable for errors, but confirm no verbose/debug logs remain in release builds | Low | | 9 | Logging | `android.util.Log.e` used in `QuestionJsonParser` — confirm no verbose/debug logs in release builds | Low | **Fixed** — no sensitive data in any log; `Log.d`/`Log.v` stripped in release via `-assumenosideeffects` ProGuard rule |
| 10 | Pairing security | Direct Firestore fallback in `createInvite` bypassed server-side rules | High | **Fixed** — fallback removed; CF is the only path |
| 11 | Pairing security | No rate limiting on `acceptInviteCallable` — 6-char codes are enumerable | High | **Fixed** — 10 attempts/hour per user; `invite_attempts` TTL via Firestore field override |
| 12 | Pairing security | `recoveryPhrase` left in plaintext on invite doc post-accept | High | **Fixed** — wiped via `FieldValue.delete()` in accept batch |
| 13 | Pairing security | `encryptionVersion` hardcoded to 2 even when no E2EE fields present (iOS) | High | **Fixed** — derived from key presence: 2 if E2EE, 0 if plaintext |
| 14 | Notifications | No push sent when partner answers or sends a chat message | Medium | **Fixed**`onAnswerWritten` gated on prefs; `onMessageWritten` CF added |
| 15 | Notifications | Notification prefs were local-only; server CFs had no way to respect them | Medium | **Fixed** — prefs synced to Firestore user doc on toggle (Android + iOS) |
| 16 | Functions | `invite_attempts` subcollection had no cleanup — would grow forever | Medium | **Fixed**`expiresAt` TTL field added; `firestore.indexes.json` configures auto-delete |
| 17 | iOS deploy | `onMessageWritten` CF not yet deployed — iOS chat notifications not active until `firebase deploy --only functions` is run | Medium | Open — deploy required |
--- ---