Closer/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt

320 lines
12 KiB
Kotlin

package app.closer.ui.answers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.core.crash.CrashReporter
import app.closer.crypto.PendingAnswerKeyStore
import app.closer.crypto.SealedRevealManager
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
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
enum class FollowUpOption(val label: String, val route: String? = null) {
DEEPER_FOLLOW_UP("Want to ask one deeper follow-up?", AppRoute.QUESTION_COMPOSER),
DATE_IDEA("Want to turn this into a date idea?", AppRoute.DATE_BUILDER),
SAVE_MEMORY("Want to save this as a memory?", AppRoute.MEMORY_LANE),
ANOTHER_QUESTION("Want to try another question from this category?")
}
/**
* Tracks where we are in the sealed-answer reveal exchange.
*
* State machine:
* NONE — answer is enc:v1: (schemaVersion 2); use existing non-sealed reveal path
* ANSWER_SEALED — own answer submitted; partner has not answered yet
* BOTH_ANSWERED — both answers submitted; "Reveal is ready" but keys not released yet
* RELEASING_KEY — writing our one-time key to Firestore (in-flight)
* WAITING_FOR_PARTNER — our key released; partner has not released theirs yet
* REVEALED — both keys released, partner answer decrypted and visible
* LOST_LOCAL_KEY — the pending answer key is missing from this device (unrecoverable)
*/
enum class SealedRevealPhase {
NONE,
ANSWER_SEALED,
BOTH_ANSWERED,
RELEASING_KEY,
WAITING_FOR_PARTNER,
REVEALED,
LOST_LOCAL_KEY
}
data class AnswerRevealUiState(
val isLoading: Boolean = true,
val error: String? = null,
val question: Question? = null,
val answer: LocalAnswer? = null,
val partnerAnswer: LocalAnswer? = null,
val coupleId: String? = null,
val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null,
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
)
@HiltViewModel
class AnswerRevealViewModel @Inject constructor(
private val questionRepository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
private val firestoreAnswerDataSource: FirestoreAnswerDataSource,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter,
private val sealedRevealManager: SealedRevealManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val questionId: String = savedStateHandle["questionId"] ?: ""
private val _uiState = MutableStateFlow(AnswerRevealUiState())
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
init {
load()
observeAnswer()
}
private fun load() {
viewModelScope.launch {
_uiState.value = AnswerRevealUiState(isLoading = true)
try {
val (coupleId, partnerId) = resolveCoupleAndPartner()
val question = questionRepository.getQuestionById(questionId)
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = if (coupleId != null && partnerId != null) {
runCatching {
firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId,
userId = partnerId,
date = effectiveDate(answer)
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null
val sealedPhase = computeSealedPhase(answer, partnerAnswer)
val category = answer?.category ?: question?.category ?: ""
_uiState.value = AnswerRevealUiState(
isLoading = false,
question = question,
answer = answer,
partnerAnswer = partnerAnswer,
coupleId = coupleId,
partnerId = partnerId,
sealedRevealPhase = sealedPhase,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
} catch (e: Exception) {
crashReporter.recordException(e)
_uiState.value = AnswerRevealUiState(
isLoading = false,
error = e.message ?: "Could not load this reveal."
)
}
}
}
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
if (answer.isRevealed) return SealedRevealPhase.REVEALED
if (!pendingAnswerKeyStore.hasPendingKey(questionId)) return SealedRevealPhase.LOST_LOCAL_KEY
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
}
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
val userId = authRepository.currentUserId ?: return null to null
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
.onFailure { crashReporter.recordException(it) }
.getOrNull()
?: return null to null
val partnerId = couple.userIds.firstOrNull { it != userId }
return couple.id to partnerId
}
private fun observeAnswer() {
viewModelScope.launch {
localAnswerRepository.observeAnswer(questionId).collect { answer ->
_uiState.update { it.copy(answer = answer) }
}
}
}
fun refreshPartnerAnswer() {
val state = _uiState.value
val coupleId = state.coupleId ?: return
val partnerId = state.partnerId ?: return
viewModelScope.launch {
if (state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY) return@launch
if (state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER) {
tryDecryptPartnerAnswer(coupleId, partnerId, state)
return@launch
}
val partnerAnswer = runCatching {
firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId,
userId = partnerId,
date = effectiveDate(state.answer)
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
partnerAnswer?.let { pa ->
val newPhase = computeSealedPhase(state.answer, pa)
_uiState.update { it.copy(partnerAnswer = pa, sealedRevealPhase = newPhase) }
}
}
}
fun revealAnswer() {
val state = _uiState.value
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
performSealedReveal(state)
} else {
performLegacyReveal()
}
}
private fun performSealedReveal(state: AnswerRevealUiState) {
val coupleId = state.coupleId ?: return
val partnerId = state.partnerId ?: return
val userId = authRepository.currentUserId ?: return
val date = effectiveDate(state.answer)
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.RELEASING_KEY) }
viewModelScope.launch {
val released = runCatching {
sealedRevealManager.releaseOwnKey(
coupleId = coupleId,
date = date,
questionId = questionId,
userId = userId,
partnerId = partnerId
)
}.onFailure { crashReporter.recordException(it) }.getOrDefault(false)
if (!released) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.LOST_LOCAL_KEY) }
return@launch
}
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
}
}
private suspend fun tryDecryptPartnerAnswer(
coupleId: String,
partnerId: String,
state: AnswerRevealUiState
) {
val userId = authRepository.currentUserId ?: return
val encryptedPayload = state.partnerAnswer?.encryptedPayload
if (encryptedPayload == null) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
return
}
val payload = runCatching {
sealedRevealManager.decryptPartnerAnswer(
coupleId = coupleId,
date = effectiveDate(state.partnerAnswer),
questionId = questionId,
partnerId = partnerId,
userId = userId,
encryptedPayload = encryptedPayload
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
if (payload == null) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
return
}
val decryptedPartnerAnswer = state.partnerAnswer?.copy(
writtenText = payload.writtenText,
selectedOptionIds = payload.selectedOptionIds,
scaleValue = payload.scaleValue,
isSealed = false
)
localAnswerRepository.markRevealed(questionId)
val ownAnswer = localAnswerRepository.getAnswer(questionId)
val category = ownAnswer?.category ?: state.question?.category ?: ""
_uiState.update {
it.copy(
answer = ownAnswer,
partnerAnswer = decryptedPartnerAnswer,
sealedRevealPhase = SealedRevealPhase.REVEALED,
followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartnerAnswer, category)
)
}
}
private fun performLegacyReveal() {
viewModelScope.launch {
localAnswerRepository.markRevealed(questionId)
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: ""
_uiState.update {
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
}
}
}
private fun generateFollowUpOptions(
answer: LocalAnswer?,
partnerAnswer: LocalAnswer?,
category: String
): List<FollowUpOption> {
if (answer == null || !answer.isRevealed) return emptyList()
val bothRevealed = partnerAnswer?.isRevealed == true
val options = mutableListOf<FollowUpOption>()
options += FollowUpOption.DEEPER_FOLLOW_UP
options += if (category.isNotBlank()) {
FollowUpOption.ANOTHER_QUESTION
} else if (bothRevealed) {
FollowUpOption.DATE_IDEA
} else {
FollowUpOption.SAVE_MEMORY
}
return options.take(2)
}
fun onFollowUpSelected(option: FollowUpOption, onNavigate: (String) -> Unit) {
val route = option.route
if (route == null && option == FollowUpOption.ANOTHER_QUESTION) {
val category = _uiState.value.answer?.category
?: _uiState.value.question?.category
?: return
onNavigate(AppRoute.questionCategory(category))
return
}
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
}
fun showSnackbar(message: String) {
_uiState.update { it.copy(snackbarMessage = message) }
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
private fun effectiveDate(answer: LocalAnswer?): String =
answer?.answerDate?.takeIf { it.isNotBlank() }
?: FirestoreAnswerDataSource.todayLocalDateString()
}