feat: capsule/challenge data sources, game screens, wheel history + viewmodel
This commit is contained in:
parent
17403b1a75
commit
c56dd53edd
|
|
@ -26,13 +26,23 @@ class FirestoreCapsuleDataSource @Inject constructor(
|
|||
.document(coupleId)
|
||||
.collection(FirestoreCollections.Couples.CAPSULES)
|
||||
|
||||
/**
|
||||
* Decrypts a field value.
|
||||
* - Plaintext (unencrypted legacy value): returned as-is for backward compat.
|
||||
* - Encrypted but key unavailable: returns null so the UI can show a placeholder.
|
||||
* - Decryption failure: returns null rather than leaking a garbled ciphertext.
|
||||
*/
|
||||
private fun decryptField(value: String?, coupleId: String): String? {
|
||||
if (value == null) return null
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
if (!fieldEncryptor.isEncrypted(value)) return value // legacy plaintext
|
||||
val aead = encryptionManager.aeadFor(coupleId) ?: run {
|
||||
Log.w(TAG, "key unavailable for $coupleId, masking encrypted field")
|
||||
return null
|
||||
}
|
||||
return runCatching { fieldEncryptor.decrypt(value, aead, coupleId) }
|
||||
.getOrElse { e ->
|
||||
Log.w(TAG, "decrypt failed, returning raw: ${e.message}")
|
||||
value
|
||||
Log.w(TAG, "decrypt failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,8 +54,8 @@ class FirestoreCapsuleDataSource @Inject constructor(
|
|||
id = doc.id,
|
||||
coupleId = coupleId,
|
||||
authorId = doc.getString("authorId") ?: "",
|
||||
title = decryptField(rawTitle, coupleId) ?: rawTitle,
|
||||
content = decryptField(rawContent, coupleId) ?: rawContent,
|
||||
title = decryptField(rawTitle, coupleId) ?: "— Encrypted —",
|
||||
content = decryptField(rawContent, coupleId) ?: "— Encrypted —",
|
||||
promptUsed = decryptField(rawPrompt, coupleId),
|
||||
unlockAt = doc.getLong("unlockAt") ?: 0L,
|
||||
createdAt = doc.getLong("createdAt") ?: 0L,
|
||||
|
|
@ -65,11 +75,17 @@ class FirestoreCapsuleDataSource @Inject constructor(
|
|||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a capsule. Requires the couple encryption key to be available — throws
|
||||
* [IllegalStateException] if the key is missing so the caller can surface an error
|
||||
* rather than silently storing plaintext.
|
||||
*/
|
||||
suspend fun createCapsule(capsule: TimeCapsule): String {
|
||||
val aead = encryptionManager.aeadFor(capsule.coupleId)
|
||||
val encTitle = aead?.let { fieldEncryptor.encrypt(capsule.title, it, capsule.coupleId) } ?: capsule.title
|
||||
val encContent = aead?.let { fieldEncryptor.encrypt(capsule.content, it, capsule.coupleId) } ?: capsule.content
|
||||
val encPrompt = aead?.let { fieldEncryptor.encryptNullable(capsule.promptUsed, it, capsule.coupleId) } ?: capsule.promptUsed
|
||||
?: throw IllegalStateException("Encryption key not ready. Please try again in a moment.")
|
||||
val encTitle = fieldEncryptor.encrypt(capsule.title, aead, capsule.coupleId)
|
||||
val encContent = fieldEncryptor.encrypt(capsule.content, aead, capsule.coupleId)
|
||||
val encPrompt = fieldEncryptor.encryptNullable(capsule.promptUsed, aead, capsule.coupleId)
|
||||
|
||||
val ref = col(capsule.coupleId).document()
|
||||
ref.set(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF
|
|||
.document(coupleId)
|
||||
.collection(FirestoreCollections.Couples.CHALLENGES)
|
||||
|
||||
suspend fun getCompletedChallengeIds(coupleId: String): List<Pair<String, Long>> =
|
||||
challengesCol(coupleId)
|
||||
.whereEqualTo("status", "completed")
|
||||
.get()
|
||||
.await()
|
||||
.documents
|
||||
.mapNotNull { doc ->
|
||||
val id = doc.getString("challengeId") ?: return@mapNotNull null
|
||||
val startedAt = doc.getLong("startedAt") ?: 0L
|
||||
Pair(id, startedAt)
|
||||
}
|
||||
|
||||
suspend fun getActiveChallengeId(coupleId: String): String? =
|
||||
challengesCol(coupleId)
|
||||
.whereEqualTo("status", "active")
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
|||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
val uid = authRepository.currentUserId ?: run {
|
||||
_uiState.update { it.copy(phase = ChallengesPhase.PICK) }
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
|
||||
return@launch
|
||||
}
|
||||
val couple = coupleRepository.getCoupleForUser(uid) ?: run {
|
||||
|
|
|
|||
|
|
@ -265,7 +265,11 @@ class DesireSyncViewModel @Inject constructor(
|
|||
val sId = sessionId ?: return
|
||||
val uid = userId ?: return
|
||||
runCatching { dataSource.submitAnswers(cId, sId, uid, answers) }
|
||||
.onFailure { Log.w(TAG, "Could not submit answers", it) }
|
||||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
}
|
||||
// The observer flips WAITING → REVEAL once the partner's answers land.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,11 @@ class HowWellViewModel @Inject constructor(
|
|||
val sId = sessionId ?: return
|
||||
val uid = userId ?: return
|
||||
runCatching { dataSource.submitAnswers(cId, sId, uid, myAnswers.toList()) }
|
||||
.onFailure { Log.w(TAG, "Could not submit answers", it) }
|
||||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
}
|
||||
// The observer flips WAITING → COMPLETE once the partner's answers land.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,9 +227,10 @@ class MemoryLaneViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
runCatching { capsuleDataSource.createCapsule(capsule) }
|
||||
.onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST) } }
|
||||
.onFailure {
|
||||
Log.w(TAG, "Could not save capsule", it)
|
||||
_uiState.update { s -> s.copy(isSaving = false, error = "Could not save. Try again.") }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "Could not save capsule", e)
|
||||
val msg = if (e is IllegalStateException) e.message ?: "Encryption not ready. Try again." else "Could not save. Check your connection."
|
||||
_uiState.update { s -> s.copy(isSaving = false, error = msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,7 +308,11 @@ class ThisOrThatViewModel @Inject constructor(
|
|||
val sId = sessionId ?: return
|
||||
val uid = userId ?: return
|
||||
runCatching { dataSource.submitAnswers(cId, sId, uid, answers) }
|
||||
.onFailure { Log.w(TAG, "Could not submit answers", it) }
|
||||
.onFailure { e ->
|
||||
submitted = false
|
||||
Log.w(TAG, "Could not submit answers", e)
|
||||
_uiState.update { it.copy(phase = TotPhase.ERROR, error = "Couldn't save your answers. Check your connection and go back to try again.") }
|
||||
}
|
||||
// The observer flips WAITING → REVEAL once the partner's answers land
|
||||
// (or right away, if they finished first).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.GameType
|
||||
import app.closer.domain.model.QuestionSession
|
||||
import app.closer.domain.model.TimeCapsule
|
||||
import app.closer.ui.components.EmptyState
|
||||
import app.closer.ui.components.ErrorState
|
||||
import app.closer.ui.components.LoadingState
|
||||
|
|
@ -100,30 +101,49 @@ fun GameHistoryScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val hasAny = state.sessions.isNotEmpty() || state.completedChallenges.isNotEmpty() || state.unlockedCapsules.isNotEmpty()
|
||||
|
||||
when {
|
||||
!state.hasPremium -> item {
|
||||
GameHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) })
|
||||
}
|
||||
state.isLoading -> item { LoadingState(message = "Loading your sessions…") }
|
||||
state.isLoading -> item { LoadingState(message = "Loading your games…") }
|
||||
state.error != null -> item {
|
||||
ErrorState(
|
||||
message = state.error!!,
|
||||
onRetry = viewModel::load
|
||||
)
|
||||
}
|
||||
state.sessions.isEmpty() -> item {
|
||||
!hasAny -> item {
|
||||
EmptyState(
|
||||
title = "No games played yet",
|
||||
body = "Finish a round of This or That, How Well, Desire Sync, or Spin the Wheel — your results will show up here so you can revisit them together.",
|
||||
body = "Finish a round of This or That, How Well, Desire Sync, Spin the Wheel, a Connection Challenge, or Memory Lane — your results will show up here.",
|
||||
actionLabel = "Play a game",
|
||||
onAction = { onNavigate(AppRoute.PLAY) }
|
||||
)
|
||||
}
|
||||
else -> items(state.sessions, key = { it.id }) { session ->
|
||||
WheelSessionCard(
|
||||
session = session,
|
||||
onClick = { onNavigate(sessionReplayRoute(session)) }
|
||||
)
|
||||
else -> {
|
||||
if (state.sessions.isNotEmpty()) {
|
||||
item { HistorySectionHeader("Game Sessions") }
|
||||
items(state.sessions, key = { it.id }) { session ->
|
||||
WheelSessionCard(
|
||||
session = session,
|
||||
onClick = { onNavigate(sessionReplayRoute(session)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.completedChallenges.isNotEmpty()) {
|
||||
item { HistorySectionHeader("Completed Challenges") }
|
||||
items(state.completedChallenges, key = { it.challengeId }) { entry ->
|
||||
ChallengeHistoryCard(entry)
|
||||
}
|
||||
}
|
||||
if (state.unlockedCapsules.isNotEmpty()) {
|
||||
item { HistorySectionHeader("Memory Lane") }
|
||||
items(state.unlockedCapsules, key = { it.id }) { capsule ->
|
||||
CapsuleHistoryCard(capsule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +189,95 @@ private fun WheelSessionCard(session: QuestionSession, onClick: () -> Unit = {})
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistorySectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChallengeHistoryCard(entry: CompletedChallengeEntry) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(entry.emoji, style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Challenge complete",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (entry.startedAt > 0L) {
|
||||
Text(
|
||||
text = SimpleDateFormat("d MMM", java.util.Locale.getDefault()).format(Date(entry.startedAt)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFB98AF4)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapsuleHistoryCard(capsule: TimeCapsule) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.88f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("💌", style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = capsule.title.ifBlank { "Untitled capsule" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Memory Lane",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (capsule.unlockAt > 0L) {
|
||||
Text(
|
||||
text = SimpleDateFormat("d MMM", java.util.Locale.getDefault()).format(Date(capsule.unlockAt)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFB98AF4)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTitle(session: QuestionSession): String = when (session.gameType) {
|
||||
GameType.THIS_OR_THAT -> "This or That"
|
||||
GameType.HOW_WELL -> "How Well Do You Know Me"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ package app.closer.ui.wheel
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.data.challenges.ChallengesCatalog
|
||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||
import app.closer.domain.model.QuestionSession
|
||||
import app.closer.domain.model.TimeCapsule
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.QuestionSessionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -17,9 +22,18 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CompletedChallengeEntry(
|
||||
val challengeId: String,
|
||||
val title: String,
|
||||
val emoji: String,
|
||||
val startedAt: Long
|
||||
)
|
||||
|
||||
data class GameHistoryUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val sessions: List<QuestionSession> = emptyList(),
|
||||
val completedChallenges: List<CompletedChallengeEntry> = emptyList(),
|
||||
val unlockedCapsules: List<TimeCapsule> = emptyList(),
|
||||
val hasPremium: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
|
@ -29,6 +43,8 @@ class GameHistoryViewModel @Inject constructor(
|
|||
private val sessionRepository: QuestionSessionRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||
entitlementChecker: EntitlementChecker
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -53,12 +69,48 @@ class GameHistoryViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(isLoading = false) }
|
||||
return@launch
|
||||
}
|
||||
sessionRepository.getSessionsForCouple(couple.id)
|
||||
val coupleId = couple.id
|
||||
|
||||
val sessionsDeferred = async { sessionRepository.getSessionsForCouple(coupleId) }
|
||||
val challengesDeferred = async { runCatching { challengeDataSource.getCompletedChallengeIds(coupleId) }.getOrElse { emptyList() } }
|
||||
val capsulesDeferred = async { runCatching { capsuleDataSource.getCapsules(coupleId) }.getOrElse { emptyList() } }
|
||||
|
||||
val sessionsResult = sessionsDeferred.await()
|
||||
val completedChallengeIds = challengesDeferred.await()
|
||||
val allCapsules = capsulesDeferred.await()
|
||||
|
||||
val completedChallenges = completedChallengeIds.mapNotNull { (challengeId, startedAt) ->
|
||||
val def = ChallengesCatalog.findById(challengeId) ?: return@mapNotNull null
|
||||
CompletedChallengeEntry(
|
||||
challengeId = challengeId,
|
||||
title = def.title,
|
||||
emoji = def.emoji,
|
||||
startedAt = startedAt
|
||||
)
|
||||
}.sortedByDescending { it.startedAt }
|
||||
|
||||
val unlockedCapsules = allCapsules.filter { it.status == "unlocked" }
|
||||
|
||||
sessionsResult
|
||||
.onSuccess { sessions ->
|
||||
_uiState.update { it.copy(isLoading = false, sessions = sessions) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
sessions = sessions,
|
||||
completedChallenges = completedChallenges,
|
||||
unlockedCapsules = unlockedCapsules
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't load your past games.") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
completedChallenges = completedChallenges,
|
||||
unlockedCapsules = unlockedCapsules,
|
||||
error = e.message ?: "Couldn't load your past games."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue