feat(date-memories): add DateReflectionScreen and ViewModel
This commit is contained in:
parent
151e019a88
commit
90995cdaef
|
|
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue