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 =
suspendCancellableCoroutine { cont ->
answerRef(coupleId, dateId, userId).get()
.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. */

View File

@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
/** 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_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 }
data class DateReflectionUiState(
@ -74,7 +78,9 @@ data class DateReflectionUiState(
val partner: DateReflection? = null,
val isSaving: 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
@ -106,9 +112,13 @@ class DateReflectionViewModel @Inject constructor(
}
viewModelScope.launch {
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) {
_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
}
coupleId = couple.id
@ -136,7 +146,18 @@ class DateReflectionViewModel @Inject constructor(
val cid = coupleId ?: return
val uid = authRepository.currentUserId ?: return
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 {
runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false)
} ?: false
@ -223,6 +244,14 @@ class DateReflectionViewModel @Inject constructor(
}
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
@ -234,7 +263,11 @@ fun DateReflectionScreen(
SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding ->
when (state.phase) {
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(
padding,
"Locked — unlock your vault on this device to view these reflections."
@ -270,14 +303,24 @@ fun DateReflectionScreen(
}
@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) {
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
onRetry?.let { OutlinedButton(onClick = it) { Text("Try again") } }
}
}
}