feat(date-memories): add DateMemoriesScreen and ViewModel

This commit is contained in:
null 2026-06-30 18:14:03 -05:00
parent f81987fa94
commit 151e019a88
1 changed files with 197 additions and 0 deletions

View File

@ -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)
)
}
}
}