fix: DateReflectionScreen and FirestoreDateReflectionDataSource updates

This commit is contained in:
null 2026-07-01 20:01:01 -05:00
parent 896bf26b28
commit 09fea873e2
2 changed files with 63 additions and 14 deletions

View File

@ -77,12 +77,18 @@ class FirestoreDateReflectionDataSource @Inject constructor(
} }
} }
/** True once a user has reflected (metadata doc readable by both → drives the "your turn" state). */ /**
* True once a user has reflected (metadata doc readable by both drives the "your turn" state).
* Propagates read failures (does NOT swallow them as `false`): a caller must be able to tell
* "definitely hasn't reflected" from "couldn't read" otherwise a transient read error would drop the
* author back into an empty editor and let them overwrite/re-create a reflection they already saved.
* Best-effort callers can still `runCatching { }.getOrDefault(false)`.
*/
suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean = suspend fun hasReflected(coupleId: String, dateId: String, userId: String): Boolean =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).get() answerRef(coupleId, dateId, userId).get()
.addOnSuccessListener { cont.resume(it.exists()) } .addOnSuccessListener { cont.resume(it.exists()) }
.addOnFailureListener { cont.resume(false) } .addOnFailureListener { cont.resumeWithException(it) }
} }
/** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */ /** Live "has this user reflected?" — lets the reflection screen complete the reveal in real time. */

View File

@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
/** The four post-date reflection prompts (the last is an open-ended, optional note). */ /** The four post-date reflection prompts (the last is an open-ended, optional note). */
@ -60,6 +61,9 @@ private val REFLECTION_PROMPTS = listOf(
private const val MAX_PROMPT_LEN = 500 private const val MAX_PROMPT_LEN = 500
private const val MAX_NOTES_LEN = 1000 private const val MAX_NOTES_LEN = 1000
/** Bound the initial couple read so an offline/no-cache hang can't strand the screen on the loader. */
private const val COUPLE_LOAD_TIMEOUT_MS = 8_000L
enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, LOCKED, ERROR } enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, LOCKED, ERROR }
data class DateReflectionUiState( data class DateReflectionUiState(
@ -74,7 +78,9 @@ data class DateReflectionUiState(
val partner: DateReflection? = null, val partner: DateReflection? = null,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,
val error: String? = null val error: String? = null,
/** Whether the ERROR state offers a "Try again" (only for transient read failures, not a bad nav arg). */
val errorRetryable: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -106,9 +112,13 @@ class DateReflectionViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
val uid = authRepository.currentUserId val uid = authRepository.currentUserId
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } // Bound the read: offline with no cache, getCoupleForUser can hang, stranding the loader forever.
val couple = uid?.let {
runCatching { withTimeoutOrNull(COUPLE_LOAD_TIMEOUT_MS) { coupleRepository.getCoupleForUser(it) } }.getOrNull()
}
if (uid == null || couple == null) { if (uid == null || couple == null) {
_uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "Not paired.") } // couple==null can be a genuine unpaired state or a transient read failure — offer retry.
_uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "Couldn't load your relationship. Try again.", errorRetryable = true) }
return@launch return@launch
} }
coupleId = couple.id coupleId = couple.id
@ -136,7 +146,18 @@ class DateReflectionViewModel @Inject constructor(
val cid = coupleId ?: return val cid = coupleId ?: return
val uid = authRepository.currentUserId ?: return val uid = authRepository.currentUserId ?: return
val pid = partnerId val pid = partnerId
val iReflected = runCatching { reflectionDataSource.hasReflected(cid, dateId, uid) }.getOrDefault(false) // My own status gates whether we show the (create) editor. A *failed* read must NOT be treated as
// "haven't reflected" — that would drop me into an empty editor and let me overwrite the reflection I
// already saved (or hit the seal rule once my partner has reflected). Surface a retryable error.
val iReflected = runCatching { reflectionDataSource.hasReflected(cid, dateId, uid) }.getOrElse {
crashReporter.recordException(it)
_uiState.update { s ->
s.copy(phase = ReflectionPhase.ERROR, error = "Couldn't load this reflection. Check your connection and try again.", errorRetryable = true)
}
return
}
// Partner status can safely default to "not yet" on a failed read — worst case we show AWAITING and
// the live observeReflected listener (or a reopen) completes the reveal; nothing is lost by waiting.
val partnerReflected = pid?.let { val partnerReflected = pid?.let {
runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false) runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false)
} ?: false } ?: false
@ -223,6 +244,14 @@ class DateReflectionViewModel @Inject constructor(
} }
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
/** Retry after a transient load/read failure (the ERROR state's "Try again"). */
fun retry() {
_uiState.update { it.copy(phase = ReflectionPhase.LOADING, error = null) }
// Re-run the full load if we never resolved the couple; otherwise just re-read (avoids
// re-subscribing the partner-reflected listener that load() attaches).
if (coupleId == null) load() else viewModelScope.launch { refresh() }
}
} }
@Composable @Composable
@ -234,7 +263,11 @@ fun DateReflectionScreen(
SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding -> SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding ->
when (state.phase) { when (state.phase) {
ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() } ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
ReflectionPhase.ERROR -> CenteredMessage(padding, state.error ?: "Something went wrong.") ReflectionPhase.ERROR -> CenteredMessage(
padding,
state.error ?: "Something went wrong.",
onRetry = if (state.errorRetryable) viewModel::retry else null
)
ReflectionPhase.LOCKED -> CenteredMessage( ReflectionPhase.LOCKED -> CenteredMessage(
padding, padding,
"Locked — unlock your vault on this device to view these reflections." "Locked — unlock your vault on this device to view these reflections."
@ -270,14 +303,24 @@ fun DateReflectionScreen(
} }
@Composable @Composable
private fun CenteredMessage(padding: androidx.compose.foundation.layout.PaddingValues, message: String) { private fun CenteredMessage(
padding: androidx.compose.foundation.layout.PaddingValues,
message: String,
onRetry: (() -> Unit)? = null
) {
Box(Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp), Alignment.Center) { Box(Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp), Alignment.Center) {
Text( Column(
message, horizontalAlignment = Alignment.CenterHorizontally,
style = MaterialTheme.typography.bodyLarge, verticalArrangement = Arrangement.spacedBy(16.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant, ) {
textAlign = TextAlign.Center Text(
) message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
onRetry?.let { OutlinedButton(onClick = it) { Text("Try again") } }
}
} }
} }