From 90995cdaefb80ede3a9b572dd112eca8f1b63c59 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 18:14:08 -0500 Subject: [PATCH] feat(date-memories): add DateReflectionScreen and ViewModel --- .../closer/ui/dates/DateReflectionScreen.kt | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt diff --git a/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt new file mode 100644 index 00000000..aaf82533 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt @@ -0,0 +1,286 @@ +package app.closer.ui.dates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.core.crash.CrashReporter +import app.closer.data.remote.FirestoreDateReflectionDataSource +import app.closer.domain.model.DateReflection +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.UserRepository +import app.closer.ui.components.CloserCard +import app.closer.ui.components.CloserHeartLoader +import app.closer.ui.settings.SettingsSubpage +import app.closer.ui.theme.closerCardColor +import dagger.hilt.android.lifecycle.HiltViewModel +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 javax.inject.Inject + +/** The three fixed post-date reflection prompts. */ +private val REFLECTION_PROMPTS = listOf( + "Your favorite moment", + "What surprised you", + "What you appreciated most" +) + +enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, ERROR } + +data class DateReflectionUiState( + val phase: ReflectionPhase = ReflectionPhase.LOADING, + val partnerName: String? = null, + val favoriteMoment: String = "", + val surprise: String = "", + val appreciated: String = "", + val mine: DateReflection? = null, + val partner: DateReflection? = null, + val isSaving: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class DateReflectionViewModel @Inject constructor( + private val reflectionDataSource: FirestoreDateReflectionDataSource, + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val userRepository: UserRepository, + private val crashReporter: CrashReporter, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val dateId: String = savedStateHandle["dateId"] ?: "" + private val _uiState = MutableStateFlow(DateReflectionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var coupleId: String? = null + private var partnerId: String? = null + + init { load() } + + private fun load() { + viewModelScope.launch { + val uid = authRepository.currentUserId + val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } + if (uid == null || couple == null) { + _uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "Not paired.") } + return@launch + } + coupleId = couple.id + partnerId = couple.userIds.firstOrNull { it != uid } + val partnerName = partnerId?.let { + runCatching { userRepository.getUser(it)?.displayName }.getOrNull() + }?.takeIf { it.isNotBlank() } ?: "your partner" + _uiState.update { it.copy(partnerName = partnerName) } + refresh() + // Live-complete the reveal the moment the partner reflects. + partnerId?.let { pid -> + launch { + reflectionDataSource.observeReflected(couple.id, dateId, pid).collect { partnerReflected -> + if (partnerReflected && _uiState.value.phase == ReflectionPhase.AWAITING_PARTNER) refresh() + } + } + } + } + } + + private suspend fun refresh() { + val cid = coupleId ?: return + val uid = authRepository.currentUserId ?: return + val pid = partnerId + val iReflected = runCatching { reflectionDataSource.hasReflected(cid, dateId, uid) }.getOrDefault(false) + val partnerReflected = pid?.let { + runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false) + } ?: false + when { + !iReflected -> _uiState.update { it.copy(phase = ReflectionPhase.EDIT) } + iReflected && !partnerReflected -> { + val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull() + _uiState.update { it.copy(phase = ReflectionPhase.AWAITING_PARTNER, mine = mine) } + } + else -> { + val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull() + val partner = pid?.let { + runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, it) }.getOrNull() + } + runCatching { reflectionDataSource.markRevealed(cid, dateId, uid) } + _uiState.update { it.copy(phase = ReflectionPhase.REVEALED, mine = mine, partner = partner) } + } + } + } + + fun onFavoriteMoment(v: String) = _uiState.update { it.copy(favoriteMoment = v) } + fun onSurprise(v: String) = _uiState.update { it.copy(surprise = v) } + fun onAppreciated(v: String) = _uiState.update { it.copy(appreciated = v) } + + fun save() { + val state = _uiState.value + if (state.isSaving) return + val cid = coupleId + val uid = authRepository.currentUserId + if (cid == null || uid == null) return + if (state.favoriteMoment.isBlank() && state.surprise.isBlank() && state.appreciated.isBlank()) { + _uiState.update { it.copy(error = "Add at least one reflection first.") } + return + } + _uiState.update { it.copy(isSaving = true, error = null) } + viewModelScope.launch { + runCatching { + reflectionDataSource.saveReflection( + cid, dateId, uid, + DateReflection( + dateId = dateId, userId = uid, + favoriteMoment = state.favoriteMoment.trim(), + surprise = state.surprise.trim(), + appreciated = state.appreciated.trim() + ) + ) + }.onFailure { + crashReporter.recordException(it) + _uiState.update { s -> s.copy(isSaving = false, error = "Couldn't save. Try again.") } + return@launch + } + _uiState.update { it.copy(isSaving = false) } + refresh() + } + } + + fun dismissError() = _uiState.update { it.copy(error = null) } +} + +@Composable +fun DateReflectionScreen( + onNavigate: (String) -> Unit = {}, + viewModel: DateReflectionViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding -> + when (state.phase) { + ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() } + ReflectionPhase.ERROR -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { + Text(state.error ?: "Something went wrong.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + else -> Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + when (state.phase) { + ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel) + ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state) + ReflectionPhase.REVEALED -> RevealedReflections(state) + else -> {} + } + Spacer(Modifier.height(12.dp)) + } + } + } +} + +@Composable +private fun ReflectionEditor(state: DateReflectionUiState, viewModel: DateReflectionViewModel) { + Text( + "Reflect privately β€” your words stay sealed until you've both shared.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( + value = state.favoriteMoment, onValueChange = viewModel::onFavoriteMoment, + label = { Text(REFLECTION_PROMPTS[0]) }, modifier = Modifier.fillMaxWidth(), minLines = 2 + ) + OutlinedTextField( + value = state.surprise, onValueChange = viewModel::onSurprise, + label = { Text(REFLECTION_PROMPTS[1]) }, modifier = Modifier.fillMaxWidth(), minLines = 2 + ) + OutlinedTextField( + value = state.appreciated, onValueChange = viewModel::onAppreciated, + label = { Text(REFLECTION_PROMPTS[2]) }, modifier = Modifier.fillMaxWidth(), minLines = 2 + ) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + Button(onClick = viewModel::save, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) { + Text(if (state.isSaving) "Saving…" else "Save my reflection") + } +} + +@Composable +private fun AwaitingPartner(state: DateReflectionUiState) { + Text( + "Saved privately πŸ’œ β€” waiting for ${state.partnerName} to reflect. You'll reveal together.", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) } +} + +@Composable +private fun RevealedReflections(state: DateReflectionUiState) { + Text( + "You both reflected πŸ’œ", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + REFLECTION_PROMPTS.forEachIndexed { i, prompt -> + CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(prompt, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text("You", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(promptValue(state.mine, i).ifBlank { "β€”" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + Text(state.partnerName ?: "Partner", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(promptValue(state.partner, i).ifBlank { "β€”" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + } +} + +@Composable +private fun ReflectionCard(title: String, reflection: DateReflection) { + CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + REFLECTION_PROMPTS.forEachIndexed { i, prompt -> + val v = promptValue(reflection, i) + if (v.isNotBlank()) { + Text(prompt, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(v, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + } + } +} + +private fun promptValue(r: DateReflection?, index: Int): String = when (index) { + 0 -> r?.favoriteMoment.orEmpty() + 1 -> r?.surprise.orEmpty() + else -> r?.appreciated.orEmpty() +}