feat(date-memories): add DateReflectionScreen and ViewModel

This commit is contained in:
null 2026-06-30 18:14:08 -05:00
parent 151e019a88
commit 90995cdaef
1 changed files with 286 additions and 0 deletions

View File

@ -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<DateReflectionUiState> = _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()
}