feat: settings polish, privacy strings, home partner state, proguard rules
This commit is contained in:
parent
0a377ecdda
commit
dff86eb089
|
|
@ -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.**
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue