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 =
|
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. */
|
||||||
|
|
|
||||||
|
|
@ -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") } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue