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