From 15087df13b2d52d6b35db6f81d7a8f024da2a1b8 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 16:51:52 -0500 Subject: [PATCH] feat(date-memories): add DateMemories timeline + DateReflection screen, wire into nav (batch 2/8) --- .../closer/core/navigation/AppNavigation.kt | 11 + .../app/closer/core/navigation/AppRoute.kt | 4 + .../app/closer/ui/dates/DateMatchesScreen.kt | 52 +++- .../closer/ui/dates/DateMatchesViewModel.kt | 35 ++- .../app/closer/ui/dates/DateMemoriesScreen.kt | 197 ++++++++++++ .../closer/ui/dates/DateReflectionScreen.kt | 286 ++++++++++++++++++ 6 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/dates/DateMemoriesScreen.kt create mode 100644 app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 095c37e0..d48653fa 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) } diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index f46c0ad9..49e061bd 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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()}" diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt index a99c9a99..f52437e0 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchesScreen.kt @@ -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() } } } diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt index 90996771..4c47b896 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchesViewModel.kt @@ -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 = _uiState.asStateFlow() + /** Emits the dateId to open its reflection screen right after a date is marked done. */ + private val _markedDateId = MutableSharedFlow(extraBufferCapacity = 1) + val markedDateId: SharedFlow = _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) diff --git a/app/src/main/java/app/closer/ui/dates/DateMemoriesScreen.kt b/app/src/main/java/app/closer/ui/dates/DateMemoriesScreen.kt new file mode 100644 index 00000000..6d8503ba --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateMemoriesScreen.kt @@ -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 = 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 = _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) + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt new file mode 100644 index 00000000..aaf82533 --- /dev/null +++ b/app/src/main/java/app/closer/ui/dates/DateReflectionScreen.kt @@ -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 = _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() +}