fix: DateReflectionScreen and FirestoreDateReflectionDataSource updates
This commit is contained in:
parent
896bf26b28
commit
09fea873e2
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue