From 151e019a88cac629a05f4a8b0e238ba4172a6ad4 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 18:14:03 -0500 Subject: [PATCH] feat(date-memories): add DateMemoriesScreen and ViewModel --- .../app/closer/ui/dates/DateMemoriesScreen.kt | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 app/src/main/java/app/closer/ui/dates/DateMemoriesScreen.kt 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) + ) + } + } +}