diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt index 5ed18332..bec32e4b 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateReflectionDataSource.kt @@ -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. */ diff --git a/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt index 8a6069a3..7474233e 100644 --- a/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt @@ -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") } } + } } }