From dff86eb0894e3b79321caad2ed0305f8ab04c20e Mon Sep 17 00:00:00 2001 From: null Date: Sun, 21 Jun 2026 09:49:02 -0500 Subject: [PATCH] feat: settings polish, privacy strings, home partner state, proguard rules --- app/proguard-rules.pro | 8 + .../closer/core/navigation/ExternalLinks.kt | 3 +- .../java/app/closer/ui/home/HomeScreen.kt | 16 +- .../app/closer/ui/home/PartnerHomeScreen.kt | 464 +++++++++++++++++- .../app/closer/ui/settings/AccountScreen.kt | 17 +- .../closer/ui/settings/AppearanceScreen.kt | 22 +- .../ui/settings/NotificationSettingsScreen.kt | 24 +- .../app/closer/ui/settings/PrivacyScreen.kt | 72 +-- app/src/main/res/values/strings.xml | 137 ++++++ docs/qa/private-mvp-checklist.md | 80 ++- 10 files changed, 734 insertions(+), 109 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 93fd25d6..2fbd2ec7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -43,6 +43,14 @@ # ── Prevent stripping of 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 ────────────────────────────── -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** diff --git a/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt b/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt index 5026ed6b..fc017135 100644 --- a/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt +++ b/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt @@ -12,8 +12,7 @@ object ExternalLinks { const val TERMS_OF_SERVICE = "https://closer.app/terms" // TODO: Update placeholder URL before production. const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms" - // TODO: Update placeholder URL before production. - const val SUPPORT = "https://couplesconnect.app/support" + const val SUPPORT = "https://closer.app/support" fun openUrl(context: Context, url: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index c3614173..5a774300 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.clickable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -169,7 +170,8 @@ fun HomeScreen( onReminder = viewModel::sendGentleReminder, onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } }, 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, onReveal: () -> Unit, onFollowUp: () -> Unit, - onRefresh: () -> Unit + onRefresh: () -> Unit, + onPartner: () -> Unit = {} ) { val callbacks = remember( onDailyQuestion, onReminder, onReveal, onFollowUp, @@ -276,7 +279,8 @@ private fun HomeContent( StreakCard( streakCount = state.streakCount, - partnerName = state.partnerName + partnerName = state.partnerName, + onPartner = onPartner ) when { @@ -337,6 +341,7 @@ private fun HomeContent( private fun StreakCard( streakCount: Int, partnerName: String?, + onPartner: () -> Unit = {}, modifier: Modifier = Modifier ) { val copy = when (streakCount) { @@ -393,9 +398,10 @@ private fun StreakCard( Text( text = it, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = CloserPalette.PurpleDeep, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable(onClick = onPartner) ) } } diff --git a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt index 1ce1374d..118f8a76 100644 --- a/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/PartnerHomeScreen.kt @@ -1,35 +1,455 @@ 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.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.ui.components.FinishedEmptyStateAction -import app.closer.ui.components.FinishedEmptyStateScreen +import app.closer.data.remote.FirestoreAnswerDataSource +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.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 = _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, 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 fun PartnerHomeScreen( - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: PartnerHomeViewModel = hiltViewModel() ) { - FinishedEmptyStateScreen( - eyebrow = "Partner space", - title = "Connect your shared home", - body = "Invite your partner to unlock shared prompts, paired games, and a relationship rhythm built for both of you.", - glyphCategoryId = "people", - primaryAction = FinishedEmptyStateAction("Invite partner", AppRoute.CREATE_INVITE), - secondaryAction = FinishedEmptyStateAction("Back home", AppRoute.HOME), - accent = CloserPalette.PurpleDeep, - details = listOf( - "Pair once, then share prompts without repeating setup.", - "Keep shared activity distinct from private reflections.", - "Return to the couple space when both of you are ready." - ), - onNavigate = onNavigate - ) + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.reminderSentEvent) { + if (state.reminderSentEvent) { + snackbar.showSnackbar("Reminder sent to ${state.partnerName ?: "your partner"}.") + viewModel.consumeReminderSentEvent() + } + } + LaunchedEffect(state.reminderError) { + state.reminderError?.let { + snackbar.showSnackbar(it) + viewModel.consumeReminderError() + } + } + + 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 -fun PartnerHomeScreenPreview() { - PartnerHomeScreen() +private fun PartnerHomeContent( + 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 + ) + } + } + } + } } diff --git a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt index da5037ea..9ed0979a 100644 --- a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt @@ -44,12 +44,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.R import app.closer.core.navigation.AppRoute import kotlinx.coroutines.launch @@ -63,6 +65,7 @@ fun AccountScreen( val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val clipboard = LocalClipboardManager.current + val phrasecopiedMsg = stringResource(R.string.account_recovery_phrase_copied) Scaffold( snackbarHost = { SnackbarHost(snackbar) }, @@ -70,12 +73,12 @@ fun AccountScreen( modifier = Modifier.background(SettingsBackgroundBrush), topBar = { TopAppBar( - title = { Text("Account", color = SettingsInk) }, + title = { Text(stringResource(R.string.account_title), color = SettingsInk) }, navigationIcon = { IconButton(onClick = { onNavigate("back") }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.action_back), tint = SettingsInk ) } @@ -125,7 +128,7 @@ fun AccountScreen( modifier = Modifier.size(20.dp) ) Text( - text = "Recovery phrase", + text = stringResource(R.string.account_recovery_phrase_title), style = MaterialTheme.typography.bodyLarge, color = SettingsInk, fontWeight = FontWeight.Medium, @@ -134,13 +137,13 @@ fun AccountScreen( IconButton( onClick = { clipboard.setText(AnnotatedString(phrase)) - scope.launch { snackbar.showSnackbar("Recovery phrase copied") } + scope.launch { snackbar.showSnackbar(phrasecopiedMsg) } }, modifier = Modifier.size(36.dp) ) { Icon( Icons.Filled.ContentCopy, - contentDescription = "Copy phrase", + contentDescription = stringResource(R.string.account_recovery_phrase_copy_desc), tint = SettingsMuted, modifier = Modifier.size(18.dp) ) @@ -159,7 +162,7 @@ fun AccountScreen( .padding(horizontal = 12.dp, vertical = 10.dp) ) 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, color = SettingsMuted ) @@ -175,7 +178,7 @@ fun AccountScreen( ) { AccountRow( icon = Icons.Filled.Delete, - label = "Delete account", + label = stringResource(R.string.action_delete_account), tint = SettingsDanger, onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) } ) diff --git a/app/src/main/java/app/closer/ui/settings/AppearanceScreen.kt b/app/src/main/java/app/closer/ui/settings/AppearanceScreen.kt index 1806ce7a..bd7d356d 100644 --- a/app/src/main/java/app/closer/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/AppearanceScreen.kt @@ -35,8 +35,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.R import app.closer.domain.repository.ThemeMode @OptIn(ExperimentalMaterial3Api::class) @@ -52,12 +54,12 @@ fun AppearanceScreen( modifier = Modifier.background(SettingsBackgroundBrush), topBar = { TopAppBar( - title = { Text("Appearance", color = SettingsInk) }, + title = { Text(stringResource(R.string.appearance_title), color = SettingsInk) }, navigationIcon = { IconButton(onClick = { onNavigate("back") }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.action_back), tint = SettingsInk ) } @@ -77,7 +79,7 @@ fun AppearanceScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Theme", + text = stringResource(R.string.appearance_theme_section), style = MaterialTheme.typography.labelLarge, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) @@ -90,22 +92,22 @@ fun AppearanceScreen( ) { Column { ThemeOptionRow( - label = "Device default", - description = "Match your device's light or dark setting", + label = stringResource(R.string.appearance_theme_device_default), + description = stringResource(R.string.appearance_theme_device_default_desc), selected = state.themeMode == ThemeMode.DEVICE, onClick = { viewModel.setThemeMode(ThemeMode.DEVICE) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) ThemeOptionRow( - label = "Light", - description = "Always use light mode", + label = stringResource(R.string.appearance_theme_light), + description = stringResource(R.string.appearance_theme_light_desc), selected = state.themeMode == ThemeMode.LIGHT, onClick = { viewModel.setThemeMode(ThemeMode.LIGHT) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) ThemeOptionRow( - label = "Dark", - description = "Always use dark mode", + label = stringResource(R.string.appearance_theme_dark), + description = stringResource(R.string.appearance_theme_dark_desc), selected = state.themeMode == ThemeMode.DARK, onClick = { viewModel.setThemeMode(ThemeMode.DARK) } ) @@ -115,7 +117,7 @@ fun AppearanceScreen( Spacer(Modifier.height(8.dp)) Text( - text = "Changes apply instantly across the whole app.", + text = stringResource(R.string.appearance_footer), style = MaterialTheme.typography.bodySmall, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp) diff --git a/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt index 77646018..d90ad830 100644 --- a/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/NotificationSettingsScreen.kt @@ -46,10 +46,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.R data class NotificationSettingsUiState( val dailyReminderEnabled: Boolean = true, @@ -121,12 +123,12 @@ fun NotificationSettingsScreen( modifier = Modifier.background(SettingsBackgroundBrush), topBar = { TopAppBar( - title = { Text("Notifications", color = SettingsInk) }, + title = { Text(stringResource(R.string.notifications_title), color = SettingsInk) }, navigationIcon = { IconButton(onClick = { onNavigate("back") }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.action_back), tint = SettingsInk ) } @@ -146,7 +148,7 @@ fun NotificationSettingsScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Reminders", + text = stringResource(R.string.notifications_reminders_section), style = MaterialTheme.typography.labelLarge, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) @@ -159,28 +161,28 @@ fun NotificationSettingsScreen( ) { Column { NotifToggleRow( - label = "Daily question", + label = stringResource(R.string.notifications_daily_question), description = "A gentle nudge when today's question is ready", checked = state.dailyReminderEnabled, onCheckedChange = viewModel::toggleDailyReminder ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) NotifToggleRow( - label = "Partner answered", + label = stringResource(R.string.notifications_partner_answered), description = "Let me know when they're ready to reveal", checked = state.partnerAnsweredEnabled, onCheckedChange = viewModel::togglePartnerAnswered ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) NotifToggleRow( - label = "New chat message", + label = stringResource(R.string.notifications_chat_message), description = "Notify me when my partner sends a message", checked = state.chatMessageEnabled, onCheckedChange = viewModel::toggleChatMessage ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) NotifToggleRow( - label = "Shared rhythm reminder", + label = stringResource(R.string.notifications_streak_reminder), description = "Remind me to keep our shared rhythm going", checked = state.streakReminderEnabled, onCheckedChange = viewModel::toggleStreakReminder @@ -191,7 +193,7 @@ fun NotificationSettingsScreen( Spacer(Modifier.height(4.dp)) Text( - text = "Quiet hours", + text = stringResource(R.string.notifications_quiet_hours), style = MaterialTheme.typography.labelLarge, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) @@ -204,8 +206,8 @@ fun NotificationSettingsScreen( ) { Column { NotifToggleRow( - label = "Quiet hours", - description = "Pause all notifications from 10 PM to 8 AM", + label = stringResource(R.string.notifications_quiet_hours), + description = stringResource(R.string.notifications_quiet_hours_desc), checked = state.quietHoursEnabled, onCheckedChange = viewModel::toggleQuietHours ) @@ -215,7 +217,7 @@ fun NotificationSettingsScreen( Spacer(Modifier.height(8.dp)) 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, color = SettingsMuted, modifier = Modifier.padding(horizontal = 4.dp) diff --git a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt index b7a7f50d..f63ae29f 100644 --- a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt @@ -41,9 +41,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import app.closer.R import app.closer.core.navigation.ExternalLinks import app.closer.ui.theme.CloserPalette @@ -59,12 +61,12 @@ fun PrivacyScreen( modifier = Modifier.background(SettingsBackgroundBrush), topBar = { TopAppBar( - title = { Text("Privacy & Terms", color = SettingsInk) }, + title = { Text(stringResource(R.string.privacy_title), color = SettingsInk) }, navigationIcon = { IconButton(onClick = { onNavigate("back") }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.action_back), tint = SettingsInk ) } @@ -84,7 +86,7 @@ fun PrivacyScreen( verticalArrangement = Arrangement.spacedBy(20.dp) ) { 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, color = SettingsMuted ) @@ -93,7 +95,7 @@ fun PrivacyScreen( PrivacySectionHeader( icon = Icons.Default.CheckCircle, iconTint = CloserPalette.PurpleDeep, - title = "What your partner can see" + title = stringResource(R.string.privacy_section_partner_visible) ) Card( @@ -103,33 +105,33 @@ fun PrivacyScreen( ) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { PrivacyRow( - title = "Daily question answers", - body = "Only after you've both answered. Until then, your partner sees \"waiting for you\" — not your answer." + title = stringResource(R.string.privacy_daily_answers), + body = stringResource(R.string.privacy_only_after_both_answered) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "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." + title = stringResource(R.string.privacy_game_results), + body = stringResource(R.string.privacy_game_results_revealed) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Desire Sync: shared yes answers only", - body = "Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed." + title = stringResource(R.string.privacy_desire_mutual), + body = stringResource(R.string.privacy_desire_mutual_body) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Discussion messages and reactions", - body = "Messages you send in question threads are visible to your partner in real time." + title = stringResource(R.string.privacy_messages), + body = stringResource(R.string.privacy_messages_visible) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Shared game history", - body = "Both partners can replay past rounds from Past Games. The replay shows the same answers both of you gave." + title = stringResource(R.string.privacy_game_history), + body = stringResource(R.string.privacy_game_history_body) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Streak and shared wins", - body = "Your couple's streak count and challenge completions are shared between both of you." + title = stringResource(R.string.privacy_streak), + body = stringResource(R.string.privacy_streak_body) ) } } @@ -138,7 +140,7 @@ fun PrivacyScreen( PrivacySectionHeader( icon = Icons.Default.Lock, iconTint = CloserPalette.Romantic, - title = "What stays private" + title = stringResource(R.string.privacy_section_data) ) Card( @@ -148,23 +150,23 @@ fun PrivacyScreen( ) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { PrivacyRow( - title = "Answers before your partner answers", - body = "In the app, your answer stays hidden until both of you have answered." + title = stringResource(R.string.privacy_answers_before_reveal), + body = stringResource(R.string.privacy_answer_stays_hidden) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Unanswered desire prompts", - body = "In Desire Sync, prompts where only one of you tapped yes are never surfaced to either person." + title = stringResource(R.string.privacy_unanswered_desire), + body = stringResource(R.string.privacy_desire_one_sided) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Time capsule contents", - body = "What's inside a capsule stays sealed until the unlock date both partners agreed on." + title = stringResource(R.string.privacy_capsule), + body = stringResource(R.string.privacy_capsule_sealed) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "Notification settings", - body = "Your notification preferences are yours alone. Your partner cannot see or change them." + title = stringResource(R.string.notifications_title), + body = stringResource(R.string.notifications_footer) ) } } @@ -173,7 +175,7 @@ fun PrivacyScreen( PrivacySectionHeader( icon = Icons.Default.VisibilityOff, iconTint = SettingsDanger, - title = "Deleting your account" + title = stringResource(R.string.account_delete_title) ) Card( @@ -183,13 +185,13 @@ fun PrivacyScreen( ) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { PrivacyRow( - title = "Immediate and permanent", - body = "Deleting your account removes your profile and sign-in instantly. Your partner is unpaired and can start fresh. This cannot be undone." + title = stringResource(R.string.account_delete_immediate), + body = stringResource(R.string.account_delete_body) ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) PrivacyRow( - title = "No data export", - body = "Closer does not currently offer a data export. Your answers and history are deleted along with your account." + title = stringResource(R.string.privacy_no_export), + body = stringResource(R.string.privacy_no_export_body) ) } } @@ -198,7 +200,7 @@ fun PrivacyScreen( // ── Legal docs ──────────────────────────────────────────────────── Text( - text = "Legal documents", + text = stringResource(R.string.privacy_legal_documents), style = MaterialTheme.typography.labelLarge, color = SettingsInk, modifier = Modifier.padding(horizontal = 4.dp) @@ -211,25 +213,25 @@ fun PrivacyScreen( ) { Column { LegalLinkRow( - label = "Privacy Policy", + label = stringResource(R.string.privacy_policy), description = "How we handle your data", onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) LegalLinkRow( - label = "Terms of Service", + label = stringResource(R.string.privacy_terms), description = "Your rights and our responsibilities", onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) LegalLinkRow( - label = "Subscription Terms", + label = stringResource(R.string.privacy_subscription_terms), description = "Billing, renewals, and cancellations", onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) } ) Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) LegalLinkRow( - label = "Support", + label = stringResource(R.string.privacy_support), description = "Get help or contact us", onClick = { uriHandler.openUri(ExternalLinks.SUPPORT) } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9bddc250..54410556 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,141 @@ Closer + + + Back + Cancel + Continue + Copy + Share + Save + Confirm + OK + Done + Retry + Sign out + Delete account + Invite partner + Back home + Create Plan + + + Account + Appearance + Notifications + Privacy & Terms + Subscription + Answer History + + + Appearance + Theme + Device default + Follow the system light/dark setting + Light + Always use the light theme + Dark + Always use the dark theme + Changes apply instantly across the whole app. + + + Notification settings + Reminders + Daily question + Partner answered + New chat message + Shared rhythm reminder + Quiet hours + 10 PM – 8 AM, no notifications + Your notification preferences are yours alone. Your partner cannot see or change them. + + + Account + Recovery phrase + Copy phrase + Recovery phrase copied + Keep this safe. Either partner can use it to restore access on a new device. + Deleting your account + Deleting your account removes your profile and sign-in instantly. Your partner is unpaired and can start fresh. This cannot be undone. + Immediate and permanent + + + Privacy & Terms + What stays private + What your partner can see + Answers before your partner answers + In the app, your answer stays hidden until both of you have answered. + Only after you\'ve both answered. Until then, your partner sees \"Waiting for you to answer first.\" + Unanswered desire prompts + In Desire Sync, prompts where only one of you tapped yes are never surfaced to either person. + Discussion messages and reactions + Messages you send in question threads are visible to your partner in real time. + Game results + Revealed together at the end of a round — This or That matches, How Well Do You Know Me scores, and Desire Sync overlaps. + Shared game history + Both partners can replay past rounds from Past Games. The replay shows the same answers both of you gave. + Daily question answers + Time capsule contents + What\'s inside a capsule stays sealed until the unlock date both partners agreed on. + Desire Sync: shared yes answers only + Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed. + Your Progress + Streak and shared wins + Your couple\'s streak count and challenge completions are shared between both of you. + No data export + Closer does not currently offer a data export. Your answers and history are deleted along with your account. + Closer is built on one rule: answers stay private until both of you have answered. Here\'s exactly what that means. + Legal + Legal documents + Privacy Policy + Terms of Service + Subscription Terms + Support + + + Invite your person + Share this code with your partner so they can connect with you. + Code expires in 24 hours + Share invite code + Partner already has a code? Accept instead + No invite code yet + Tap back and try creating an invite again. + Code copied! + + + Enter the code + Ask your partner to share their 6-character invite code. + Need to create an invite instead? + + + Once you confirm, you\'ll be connected and can start exploring questions together. + Pair up + That\'s not right — enter a different code + Recovery phrase + Write this down and share it with your partner. You\'ll both need it to access your answers on a new phone. + + + For tonight + Start a new streak today + 1 day streak + %d day streak + This space is yours + Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved. + All packs + + + Today + answered + hasn\'t answered yet + Today\'s question is complete + Waiting for today\'s question + Send a gentle nudge + Sending… + View today\'s question + Start a streak together + 1 day streak + %d day streak + Reminder sent to %s. + Could not send reminder. + diff --git a/docs/qa/private-mvp-checklist.md b/docs/qa/private-mvp-checklist.md index 68c1c174..690eccc2 100644 --- a/docs/qa/private-mvp-checklist.md +++ b/docs/qa/private-mvp-checklist.md @@ -1,6 +1,7 @@ # 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. +> 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. - [ ] "Not right — enter a different code" returns to accept screen. - [ ] 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`) -- [ ] 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. +### 2.4 Share invite (was: Email invite — removed) + +> `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 - [ ] `SettingsScreen` partner card opens `RELATIONSHIP_SETTINGS` when paired. @@ -252,7 +260,9 @@ - [ ] Partner card shows paired state and partner name, or invite prompt. - [ ] Tapping profile card opens `ACCOUNT`. - [ ] 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. +- [ ] No "Email Invite" or "Invite by Email" row present anywhere in settings. - [ ] Legal links open external URLs. - [ ] Delete account row opens `DELETE_ACCOUNT`. - [ ] Sign out button works and shows loading state. @@ -260,19 +270,34 @@ ### 9.2 Account (`AccountScreen`) - [ ] "Local profile" card shown for signed-out state. - [ ] 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`. - [ ] Back navigation works. ### 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). +- [ ] **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`) - [ ] External links open in browser. - [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback. - [ ] 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`) - [ ] Renders placeholder with paywall and settings actions. @@ -313,6 +338,19 @@ - [ ] Token updates sent to backend/user document. - [ ] 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 @@ -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. -| # | Area | Issue | Severity | -|---|------|-------|----------| -| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | -| 2 | Special dates | Hardcoded names/dates in `SpecialDatesSection` | High | -| 3 | Email invite | Placeholder screen with hardcoded `ABC123` code | Medium | -| 4 | Subscription | Placeholder screen, not real management | Medium | -| 5 | Partner home | Placeholder screen only | Medium | -| 6 | Settings | Account rows disabled ("Auth coming soon", "Export coming soon") | Medium | -| 7 | External links | Support URL points to `couplesconnect.app/support` | Low | -| 8 | Strings | 100+ hardcoded display strings; should move to `strings.xml` for localization | Low | -| 9 | Logging | `android.util.Log.e` used in `QuestionJsonParser` — acceptable for errors, but confirm no verbose/debug logs remain in release builds | Low | +| # | Area | Issue | Severity | Status | +| --- | --- | --- | --- | --- | +| 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 | **Not an issue** — `SpecialDatesSection` is dead code, never rendered; home shows honest placeholder copy | +| 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 | Open | +| 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 | **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 | **Fixed** — updated to `https://closer.app/support` in `ExternalLinks.kt` | +| 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` — 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 | ---