feat(date-memories): add DateMemories timeline + DateReflection screen, wire into nav (batch 2/8)
This commit is contained in:
parent
18ffdcdbaf
commit
15087df13b
|
|
@ -46,6 +46,8 @@ import app.closer.ui.pairing.RecoveryScreen
|
|||
import app.closer.ui.dates.DateMatchScreen
|
||||
import app.closer.ui.dates.DateMatchesScreen
|
||||
import app.closer.ui.dates.DateBuilderScreen
|
||||
import app.closer.ui.dates.DateMemoriesScreen
|
||||
import app.closer.ui.dates.DateReflectionScreen
|
||||
import app.closer.ui.dates.BucketListScreen
|
||||
import app.closer.ui.paywall.PaywallScreen
|
||||
import app.closer.ui.play.PlayHubScreen
|
||||
|
|
@ -509,6 +511,15 @@ fun AppNavigation(
|
|||
composable(route = AppRoute.DATE_BUILDER) {
|
||||
DateBuilderScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.DATE_MEMORIES) {
|
||||
DateMemoriesScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.DATE_REFLECTION,
|
||||
arguments = listOf(navArgument("dateId") { type = NavType.StringType })
|
||||
) {
|
||||
DateReflectionScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
composable(route = AppRoute.BUCKET_LIST) {
|
||||
BucketListScreen(onNavigate = navigateRoute)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ object AppRoute {
|
|||
const val DATE_MATCH = "date_match"
|
||||
const val DATE_MATCHES = "date_matches"
|
||||
const val DATE_BUILDER = "date_builder"
|
||||
const val DATE_MEMORIES = "date_memories"
|
||||
const val DATE_REFLECTION = "date_reflection/{dateId}"
|
||||
const val BUCKET_LIST = "bucket_list"
|
||||
const val THIS_OR_THAT = "this_or_that"
|
||||
const val HOW_WELL = "how_well"
|
||||
|
|
@ -195,6 +197,8 @@ object AppRoute {
|
|||
|
||||
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
|
||||
|
||||
fun dateReflection(dateId: String): String = "date_reflection/${dateId.asRouteArg()}"
|
||||
|
||||
fun conversation(coupleId: String, conversationId: String): String =
|
||||
"conversation/${coupleId.asRouteArg()}/${conversationId.asRouteArg()}"
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -42,8 +45,13 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.domain.model.DateCostLevel
|
||||
import app.closer.domain.model.DateIdea
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.DateMatch
|
||||
import app.closer.domain.model.DateMatchSuggestion
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import app.closer.domain.model.SwipeAction
|
||||
import app.closer.ui.components.EmptyState
|
||||
import app.closer.ui.components.ErrorState
|
||||
|
|
@ -57,10 +65,17 @@ fun DateMatchesScreen(
|
|||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
// After marking a date done, open its reflection.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.markedDateId.collect { dateId -> onNavigate(AppRoute.dateReflection(dateId)) }
|
||||
}
|
||||
|
||||
DateMatchesContent(
|
||||
state = state,
|
||||
onRetry = viewModel::retry,
|
||||
onBack = { onNavigate("back") }
|
||||
onBack = { onNavigate("back") },
|
||||
onMarkCompleted = viewModel::markCompleted,
|
||||
onMemories = { onNavigate(AppRoute.DATE_MEMORIES) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +83,9 @@ fun DateMatchesScreen(
|
|||
private fun DateMatchesContent(
|
||||
state: DateMatchesUiState,
|
||||
onRetry: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
onMarkCompleted: (DateMatch) -> Unit = {},
|
||||
onMemories: () -> Unit = {}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -104,6 +121,16 @@ private fun DateMatchesContent(
|
|||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(onClick = onMemories, contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 4.dp)) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.glyph_date_replay),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Your date memories", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +176,7 @@ private fun DateMatchesContent(
|
|||
)
|
||||
}
|
||||
items(state.mutualMatches, key = { it.id }) { match ->
|
||||
MatchCard(match = match)
|
||||
MatchCard(match = match, onMarkCompleted = { onMarkCompleted(match) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,10 +246,22 @@ private fun SectionHeader(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(match: DateMatch) {
|
||||
private fun MatchCard(match: DateMatch, onMarkCompleted: () -> Unit = {}) {
|
||||
val idea = match.dateIdea ?: return
|
||||
IdeaCard(
|
||||
idea = idea,
|
||||
action = {
|
||||
TextButton(onClick = onMarkCompleted, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(
|
||||
imageVector = CloserGlyphs.Heart,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9B1B5A)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("We did this", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
badge = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
|
|
@ -286,7 +325,8 @@ private fun SuggestionCard(suggestion: DateMatchSuggestion) {
|
|||
@Composable
|
||||
private fun IdeaCard(
|
||||
idea: DateIdea,
|
||||
badge: @Composable () -> Unit
|
||||
badge: @Composable () -> Unit,
|
||||
action: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -348,6 +388,8 @@ private fun IdeaCard(
|
|||
InfoChip(label = "Premium", emphasis = true)
|
||||
}
|
||||
}
|
||||
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ package app.closer.ui.dates
|
|||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.data.remote.FirestoreDateMemoryDataSource
|
||||
import app.closer.data.repository.DateIdeaSeed
|
||||
import app.closer.domain.model.DateIdea
|
||||
import app.closer.domain.model.DateMatch
|
||||
import app.closer.domain.model.DateMemory
|
||||
import app.closer.domain.model.DateMatchSuggestion
|
||||
import app.closer.domain.model.DateSwipe
|
||||
import app.closer.domain.model.SwipeAction
|
||||
|
|
@ -16,8 +18,11 @@ import app.closer.domain.repository.UserRepository
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -42,16 +47,44 @@ class DateMatchesViewModel @Inject constructor(
|
|||
private val repository: DateMatchRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val memoryDataSource: FirestoreDateMemoryDataSource
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DateMatchesUiState())
|
||||
val uiState: StateFlow<DateMatchesUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** Emits the dateId to open its reflection screen right after a date is marked done. */
|
||||
private val _markedDateId = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val markedDateId: SharedFlow<String> = _markedDateId.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadMatches()
|
||||
}
|
||||
|
||||
/** Log a matched date as completed (idempotent) → then open its reflection. */
|
||||
fun markCompleted(match: DateMatch) {
|
||||
val cid = _uiState.value.coupleId ?: return
|
||||
val uid = authRepository.currentUserId ?: return
|
||||
val idea = match.dateIdea ?: DateIdeaSeed.byId(match.dateIdeaId)
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
memoryDataSource.markCompleted(
|
||||
cid,
|
||||
DateMemory(
|
||||
id = match.id,
|
||||
dateIdeaId = match.dateIdeaId,
|
||||
title = idea?.title ?: "",
|
||||
category = idea?.category ?: "",
|
||||
completedAt = System.currentTimeMillis(),
|
||||
addedBy = uid
|
||||
)
|
||||
)
|
||||
}.onSuccess { _markedDateId.tryEmit(match.id) }
|
||||
.onFailure { Log.w(TAG, "markCompleted failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMatches() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DateMatchesUiState(isLoading = true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
package app.closer.ui.dates
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.R
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.remote.FirestoreDateMemoryDataSource
|
||||
import app.closer.data.remote.FirestoreDateReflectionDataSource
|
||||
import app.closer.domain.model.DateMemory
|
||||
import app.closer.domain.model.DateReflectionState
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.ui.components.BrandIllustration
|
||||
import app.closer.ui.components.CloserCard
|
||||
import app.closer.ui.components.CloserHeartLoader
|
||||
import app.closer.ui.settings.SettingsSubpage
|
||||
import app.closer.ui.theme.CloserPalette
|
||||
import app.closer.ui.theme.closerCardColor
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
data class DateMemoryRow(val memory: DateMemory, val state: DateReflectionState)
|
||||
|
||||
data class DateMemoriesUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val rows: List<DateMemoryRow> = emptyList()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DateMemoriesViewModel @Inject constructor(
|
||||
private val memoryDataSource: FirestoreDateMemoryDataSource,
|
||||
private val reflectionDataSource: FirestoreDateReflectionDataSource,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DateMemoriesUiState())
|
||||
val uiState: StateFlow<DateMemoriesUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val uid = authRepository.currentUserId
|
||||
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||||
if (uid == null || couple == null) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
return@launch
|
||||
}
|
||||
val partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
memoryDataSource.observeHistory(couple.id).collect { memories ->
|
||||
val rows = memories.map { m ->
|
||||
val mine = runCatching { reflectionDataSource.hasReflected(couple.id, m.id, uid) }.getOrDefault(false)
|
||||
val partner = partnerId?.let {
|
||||
runCatching { reflectionDataSource.hasReflected(couple.id, m.id, it) }.getOrDefault(false)
|
||||
} ?: false
|
||||
val state = when {
|
||||
mine && partner -> DateReflectionState.BOTH_DONE
|
||||
mine -> DateReflectionState.AWAITING_PARTNER
|
||||
else -> DateReflectionState.NONE
|
||||
}
|
||||
DateMemoryRow(m, state)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, rows = rows) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateMemoriesScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: DateMemoriesViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
SettingsSubpage(title = "Date memories", onBack = { onNavigate("back") }) { padding ->
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
|
||||
state.rows.isEmpty() -> DateMemoriesEmpty(Modifier.padding(padding))
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(state.rows, key = { it.memory.id }) { row ->
|
||||
DateMemoryCard(row) { onNavigate(AppRoute.dateReflection(row.memory.id)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateMemoryCard(row: DateMemoryRow, onClick: () -> Unit) {
|
||||
CloserCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), containerColor = closerCardColor()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = row.memory.title.ifBlank { "Your date" },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
row.memory.category.takeIf { it.isNotBlank() }?.replace('_', ' '),
|
||||
row.memory.completedAt.takeIf { it > 0L }
|
||||
?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
ReflectionChip(row.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReflectionChip(state: DateReflectionState) {
|
||||
val (label, color) = when (state) {
|
||||
DateReflectionState.NONE -> "Reflect" to MaterialTheme.colorScheme.primary
|
||||
DateReflectionState.AWAITING_PARTNER -> "Waiting" to CloserPalette.Gold
|
||||
DateReflectionState.BOTH_DONE -> "View" to CloserPalette.Evergreen
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(50), color = color.copy(alpha = 0.14f)) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateMemoriesEmpty(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
BrandIllustration(
|
||||
res = R.drawable.illustration_date_memories_empty,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(200.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Your dates, remembered",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "When you go on a date, mark it done and reflect together — your moments will gather here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
package app.closer.ui.dates
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.crash.CrashReporter
|
||||
import app.closer.data.remote.FirestoreDateReflectionDataSource
|
||||
import app.closer.domain.model.DateReflection
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.UserRepository
|
||||
import app.closer.ui.components.CloserCard
|
||||
import app.closer.ui.components.CloserHeartLoader
|
||||
import app.closer.ui.settings.SettingsSubpage
|
||||
import app.closer.ui.theme.closerCardColor
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The three fixed post-date reflection prompts. */
|
||||
private val REFLECTION_PROMPTS = listOf(
|
||||
"Your favorite moment",
|
||||
"What surprised you",
|
||||
"What you appreciated most"
|
||||
)
|
||||
|
||||
enum class ReflectionPhase { LOADING, EDIT, AWAITING_PARTNER, REVEALED, ERROR }
|
||||
|
||||
data class DateReflectionUiState(
|
||||
val phase: ReflectionPhase = ReflectionPhase.LOADING,
|
||||
val partnerName: String? = null,
|
||||
val favoriteMoment: String = "",
|
||||
val surprise: String = "",
|
||||
val appreciated: String = "",
|
||||
val mine: DateReflection? = null,
|
||||
val partner: DateReflection? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DateReflectionViewModel @Inject constructor(
|
||||
private val reflectionDataSource: FirestoreDateReflectionDataSource,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val crashReporter: CrashReporter,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val dateId: String = savedStateHandle["dateId"] ?: ""
|
||||
private val _uiState = MutableStateFlow(DateReflectionUiState())
|
||||
val uiState: StateFlow<DateReflectionUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var coupleId: String? = null
|
||||
private var partnerId: String? = null
|
||||
|
||||
init { load() }
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
val uid = authRepository.currentUserId
|
||||
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||||
if (uid == null || couple == null) {
|
||||
_uiState.update { it.copy(phase = ReflectionPhase.ERROR, error = "Not paired.") }
|
||||
return@launch
|
||||
}
|
||||
coupleId = couple.id
|
||||
partnerId = couple.userIds.firstOrNull { it != uid }
|
||||
val partnerName = partnerId?.let {
|
||||
runCatching { userRepository.getUser(it)?.displayName }.getOrNull()
|
||||
}?.takeIf { it.isNotBlank() } ?: "your partner"
|
||||
_uiState.update { it.copy(partnerName = partnerName) }
|
||||
refresh()
|
||||
// Live-complete the reveal the moment the partner reflects.
|
||||
partnerId?.let { pid ->
|
||||
launch {
|
||||
reflectionDataSource.observeReflected(couple.id, dateId, pid).collect { partnerReflected ->
|
||||
if (partnerReflected && _uiState.value.phase == ReflectionPhase.AWAITING_PARTNER) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val cid = coupleId ?: return
|
||||
val uid = authRepository.currentUserId ?: return
|
||||
val pid = partnerId
|
||||
val iReflected = runCatching { reflectionDataSource.hasReflected(cid, dateId, uid) }.getOrDefault(false)
|
||||
val partnerReflected = pid?.let {
|
||||
runCatching { reflectionDataSource.hasReflected(cid, dateId, it) }.getOrDefault(false)
|
||||
} ?: false
|
||||
when {
|
||||
!iReflected -> _uiState.update { it.copy(phase = ReflectionPhase.EDIT) }
|
||||
iReflected && !partnerReflected -> {
|
||||
val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull()
|
||||
_uiState.update { it.copy(phase = ReflectionPhase.AWAITING_PARTNER, mine = mine) }
|
||||
}
|
||||
else -> {
|
||||
val mine = runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, uid) }.getOrNull()
|
||||
val partner = pid?.let {
|
||||
runCatching { reflectionDataSource.decryptReflectionFor(cid, dateId, it) }.getOrNull()
|
||||
}
|
||||
runCatching { reflectionDataSource.markRevealed(cid, dateId, uid) }
|
||||
_uiState.update { it.copy(phase = ReflectionPhase.REVEALED, mine = mine, partner = partner) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onFavoriteMoment(v: String) = _uiState.update { it.copy(favoriteMoment = v) }
|
||||
fun onSurprise(v: String) = _uiState.update { it.copy(surprise = v) }
|
||||
fun onAppreciated(v: String) = _uiState.update { it.copy(appreciated = v) }
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
if (state.isSaving) return
|
||||
val cid = coupleId
|
||||
val uid = authRepository.currentUserId
|
||||
if (cid == null || uid == null) return
|
||||
if (state.favoriteMoment.isBlank() && state.surprise.isBlank() && state.appreciated.isBlank()) {
|
||||
_uiState.update { it.copy(error = "Add at least one reflection first.") }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(isSaving = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
reflectionDataSource.saveReflection(
|
||||
cid, dateId, uid,
|
||||
DateReflection(
|
||||
dateId = dateId, userId = uid,
|
||||
favoriteMoment = state.favoriteMoment.trim(),
|
||||
surprise = state.surprise.trim(),
|
||||
appreciated = state.appreciated.trim()
|
||||
)
|
||||
)
|
||||
}.onFailure {
|
||||
crashReporter.recordException(it)
|
||||
_uiState.update { s -> s.copy(isSaving = false, error = "Couldn't save. Try again.") }
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateReflectionScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: DateReflectionViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
SettingsSubpage(title = "Date reflection", onBack = { onNavigate("back") }) { padding ->
|
||||
when (state.phase) {
|
||||
ReflectionPhase.LOADING -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) { CloserHeartLoader() }
|
||||
ReflectionPhase.ERROR -> Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
|
||||
Text(state.error ?: "Something went wrong.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
when (state.phase) {
|
||||
ReflectionPhase.EDIT -> ReflectionEditor(state, viewModel)
|
||||
ReflectionPhase.AWAITING_PARTNER -> AwaitingPartner(state)
|
||||
ReflectionPhase.REVEALED -> RevealedReflections(state)
|
||||
else -> {}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReflectionEditor(state: DateReflectionUiState, viewModel: DateReflectionViewModel) {
|
||||
Text(
|
||||
"Reflect privately — your words stay sealed until you've both shared.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.favoriteMoment, onValueChange = viewModel::onFavoriteMoment,
|
||||
label = { Text(REFLECTION_PROMPTS[0]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.surprise, onValueChange = viewModel::onSurprise,
|
||||
label = { Text(REFLECTION_PROMPTS[1]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.appreciated, onValueChange = viewModel::onAppreciated,
|
||||
label = { Text(REFLECTION_PROMPTS[2]) }, modifier = Modifier.fillMaxWidth(), minLines = 2
|
||||
)
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
||||
Button(onClick = viewModel::save, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.isSaving) "Saving…" else "Save my reflection")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AwaitingPartner(state: DateReflectionUiState) {
|
||||
Text(
|
||||
"Saved privately 💜 — waiting for ${state.partnerName} to reflect. You'll reveal together.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
state.mine?.let { ReflectionCard(title = "Your reflection", reflection = it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealedReflections(state: DateReflectionUiState) {
|
||||
Text(
|
||||
"You both reflected 💜",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
REFLECTION_PROMPTS.forEachIndexed { i, prompt ->
|
||||
CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(prompt, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text("You", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(promptValue(state.mine, i).ifBlank { "—" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(state.partnerName ?: "Partner", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(promptValue(state.partner, i).ifBlank { "—" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReflectionCard(title: String, reflection: DateReflection) {
|
||||
CloserCard(modifier = Modifier.fillMaxWidth(), containerColor = closerCardColor()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
REFLECTION_PROMPTS.forEachIndexed { i, prompt ->
|
||||
val v = promptValue(reflection, i)
|
||||
if (v.isNotBlank()) {
|
||||
Text(prompt, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(v, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptValue(r: DateReflection?, index: Int): String = when (index) {
|
||||
0 -> r?.favoriteMoment.orEmpty()
|
||||
1 -> r?.surprise.orEmpty()
|
||||
else -> r?.appreciated.orEmpty()
|
||||
}
|
||||
Loading…
Reference in New Issue