112 lines
4.8 KiB
Kotlin
112 lines
4.8 KiB
Kotlin
package app.closer.ui.questions
|
|
|
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
|
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
|
import app.closer.domain.model.LocalAnswer
|
|
import app.closer.domain.model.Question
|
|
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
|
|
import app.closer.domain.repository.LocalAnswerRepository
|
|
|
|
/**
|
|
* Reconcile a missing local answer with Firestore. When Room has no answer for [question] but the
|
|
* couple's Firestore record shows this [userId] already answered [date]'s question, rebuild the
|
|
* answer from the read-gated couple-key payload (the owner can always read their own) and persist
|
|
* it back to Room so subsequent loads are correct.
|
|
*
|
|
* This closes the R23 data-loss class: on a fresh device / after the local DB is cleared, Room is
|
|
* empty while Firestore still holds the answer, so the screen would otherwise offer a re-answer —
|
|
* but the `secure/payload` doc is immutable (`allow update: if false`), so the re-write is silently
|
|
* rejected and a *changed* answer is lost. Returning the prior answer keeps the screen in its
|
|
* submitted/reveal state instead of an editable form. Returns null only when there is genuinely no
|
|
* prior answer (the normal first-answer path).
|
|
*
|
|
* The decrypted content is persisted to Room only when it actually decrypts, so a transient
|
|
* key-unavailable read never poisons Room with an empty answer (a later load can still recover it).
|
|
*
|
|
* Room-first: when Room already holds the answer it is returned immediately (a single local read,
|
|
* no network), so this is safe to call on every load from any surface.
|
|
*/
|
|
suspend fun reconcileLocalAnswerFromFirestore(
|
|
question: Question,
|
|
coupleId: String,
|
|
date: String,
|
|
userId: String,
|
|
firestore: FirestoreAnswerDataSource,
|
|
localAnswers: LocalAnswerRepository
|
|
): LocalAnswer? {
|
|
localAnswers.getAnswer(question.id)?.let { return it }
|
|
val meta = runCatching { firestore.getAnswerForUser(coupleId, userId, date) }.getOrNull() ?: return null
|
|
// The daily assignment can rotate; only heal when the stored answer is for THIS question.
|
|
if (meta.questionId.isNotBlank() && meta.questionId != question.id) return null
|
|
val decoded = runCatching { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) }.getOrNull()
|
|
val ids = decoded?.selectedOptionIds ?: emptyList()
|
|
val healed = LocalAnswer(
|
|
questionId = question.id,
|
|
questionText = question.text,
|
|
category = question.category,
|
|
answerType = meta.answerType.ifBlank { question.type },
|
|
writtenText = decoded?.writtenText,
|
|
selectedOptionIds = ids,
|
|
selectedOptionTexts = selectedOptionTexts(question, ids),
|
|
scaleValue = decoded?.scaleValue,
|
|
createdAt = meta.createdAt,
|
|
updatedAt = meta.updatedAt,
|
|
isRevealed = meta.isRevealed,
|
|
schemaVersion = 2,
|
|
isSealed = false,
|
|
answerDate = date
|
|
)
|
|
if (decoded != null) runCatching { localAnswers.saveAnswer(healed) }
|
|
return healed
|
|
}
|
|
|
|
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
|
|
answer ?: return copy(submitted = false, isRevealed = false)
|
|
return copy(
|
|
submitted = true,
|
|
isRevealed = answer.isRevealed,
|
|
pendingWrittenText = answer.writtenText.orEmpty(),
|
|
pendingSelectedOptionIds = answer.selectedOptionIds,
|
|
pendingScaleValue = answer.scaleValue ?: pendingScaleValue
|
|
)
|
|
}
|
|
|
|
fun LocalQuestionUiState.toLocalAnswer(question: Question): LocalAnswer {
|
|
return LocalAnswer(
|
|
questionId = question.id,
|
|
questionText = question.text,
|
|
category = question.category,
|
|
answerType = question.type,
|
|
writtenText = pendingWrittenText.takeIf { question.type == "written" && it.isNotBlank() },
|
|
selectedOptionIds = when (question.type) {
|
|
"single_choice", "multi_choice", "this_or_that" -> pendingSelectedOptionIds
|
|
else -> emptyList()
|
|
},
|
|
selectedOptionTexts = selectedOptionTexts(question, pendingSelectedOptionIds),
|
|
scaleValue = pendingScaleValue.takeIf { question.type == "scale" }
|
|
)
|
|
}
|
|
|
|
internal fun selectedOptionTexts(
|
|
question: Question,
|
|
selectedOptionIds: List<String>
|
|
): List<String> {
|
|
if (selectedOptionIds.isEmpty()) return emptyList()
|
|
return when (question.type) {
|
|
"this_or_that" -> {
|
|
val cfg = question.answerConfig as? ThisOrThatAnswerConfigImpl
|
|
listOfNotNull(cfg?.config?.optionA, cfg?.config?.optionB)
|
|
.filter { it.id in selectedOptionIds }
|
|
.map { it.text }
|
|
}
|
|
"single_choice", "multi_choice" -> {
|
|
val cfg = question.answerConfig as? ChoiceAnswerConfigImpl
|
|
cfg?.config?.options
|
|
?.filter { it.id in selectedOptionIds }
|
|
?.map { it.text }
|
|
?: emptyList()
|
|
}
|
|
else -> emptyList()
|
|
}
|
|
}
|