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.DateMatchScreen
|
||||||
import app.closer.ui.dates.DateMatchesScreen
|
import app.closer.ui.dates.DateMatchesScreen
|
||||||
import app.closer.ui.dates.DateBuilderScreen
|
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.dates.BucketListScreen
|
||||||
import app.closer.ui.paywall.PaywallScreen
|
import app.closer.ui.paywall.PaywallScreen
|
||||||
import app.closer.ui.play.PlayHubScreen
|
import app.closer.ui.play.PlayHubScreen
|
||||||
|
|
@ -509,6 +511,15 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.DATE_BUILDER) {
|
composable(route = AppRoute.DATE_BUILDER) {
|
||||||
DateBuilderScreen(onNavigate = navigateRoute)
|
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) {
|
composable(route = AppRoute.BUCKET_LIST) {
|
||||||
BucketListScreen(onNavigate = navigateRoute)
|
BucketListScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ object AppRoute {
|
||||||
const val DATE_MATCH = "date_match"
|
const val DATE_MATCH = "date_match"
|
||||||
const val DATE_MATCHES = "date_matches"
|
const val DATE_MATCHES = "date_matches"
|
||||||
const val DATE_BUILDER = "date_builder"
|
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 BUCKET_LIST = "bucket_list"
|
||||||
const val THIS_OR_THAT = "this_or_that"
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
const val HOW_WELL = "how_well"
|
const val HOW_WELL = "how_well"
|
||||||
|
|
@ -195,6 +197,8 @@ object AppRoute {
|
||||||
|
|
||||||
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
|
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 =
|
fun conversation(coupleId: String, conversationId: String): String =
|
||||||
"conversation/${coupleId.asRouteArg()}/${conversationId.asRouteArg()}"
|
"conversation/${coupleId.asRouteArg()}/${conversationId.asRouteArg()}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -42,8 +45,13 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import app.closer.domain.model.DateCostLevel
|
import app.closer.domain.model.DateCostLevel
|
||||||
import app.closer.domain.model.DateIdea
|
import app.closer.domain.model.DateIdea
|
||||||
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.DateMatch
|
import app.closer.domain.model.DateMatch
|
||||||
import app.closer.domain.model.DateMatchSuggestion
|
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.domain.model.SwipeAction
|
||||||
import app.closer.ui.components.EmptyState
|
import app.closer.ui.components.EmptyState
|
||||||
import app.closer.ui.components.ErrorState
|
import app.closer.ui.components.ErrorState
|
||||||
|
|
@ -57,10 +65,17 @@ fun DateMatchesScreen(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
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(
|
DateMatchesContent(
|
||||||
state = state,
|
state = state,
|
||||||
onRetry = viewModel::retry,
|
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(
|
private fun DateMatchesContent(
|
||||||
state: DateMatchesUiState,
|
state: DateMatchesUiState,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit,
|
||||||
|
onMarkCompleted: (DateMatch) -> Unit = {},
|
||||||
|
onMemories: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -104,6 +121,16 @@ private fun DateMatchesContent(
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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 ->
|
items(state.mutualMatches, key = { it.id }) { match ->
|
||||||
MatchCard(match = match)
|
MatchCard(match = match, onMarkCompleted = { onMarkCompleted(match) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,10 +246,22 @@ private fun SectionHeader(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MatchCard(match: DateMatch) {
|
private fun MatchCard(match: DateMatch, onMarkCompleted: () -> Unit = {}) {
|
||||||
val idea = match.dateIdea ?: return
|
val idea = match.dateIdea ?: return
|
||||||
IdeaCard(
|
IdeaCard(
|
||||||
idea = idea,
|
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 = {
|
badge = {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
|
@ -286,7 +325,8 @@ private fun SuggestionCard(suggestion: DateMatchSuggestion) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun IdeaCard(
|
private fun IdeaCard(
|
||||||
idea: DateIdea,
|
idea: DateIdea,
|
||||||
badge: @Composable () -> Unit
|
badge: @Composable () -> Unit,
|
||||||
|
action: (@Composable () -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -348,6 +388,8 @@ private fun IdeaCard(
|
||||||
InfoChip(label = "Premium", emphasis = true)
|
InfoChip(label = "Premium", emphasis = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package app.closer.ui.dates
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.data.remote.FirestoreDateMemoryDataSource
|
||||||
import app.closer.data.repository.DateIdeaSeed
|
import app.closer.data.repository.DateIdeaSeed
|
||||||
import app.closer.domain.model.DateIdea
|
import app.closer.domain.model.DateIdea
|
||||||
import app.closer.domain.model.DateMatch
|
import app.closer.domain.model.DateMatch
|
||||||
|
import app.closer.domain.model.DateMemory
|
||||||
import app.closer.domain.model.DateMatchSuggestion
|
import app.closer.domain.model.DateMatchSuggestion
|
||||||
import app.closer.domain.model.DateSwipe
|
import app.closer.domain.model.DateSwipe
|
||||||
import app.closer.domain.model.SwipeAction
|
import app.closer.domain.model.SwipeAction
|
||||||
|
|
@ -16,8 +18,11 @@ import app.closer.domain.repository.UserRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -42,16 +47,44 @@ class DateMatchesViewModel @Inject constructor(
|
||||||
private val repository: DateMatchRepository,
|
private val repository: DateMatchRepository,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val userRepository: UserRepository
|
private val userRepository: UserRepository,
|
||||||
|
private val memoryDataSource: FirestoreDateMemoryDataSource
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DateMatchesUiState())
|
private val _uiState = MutableStateFlow(DateMatchesUiState())
|
||||||
val uiState: StateFlow<DateMatchesUiState> = _uiState.asStateFlow()
|
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 {
|
init {
|
||||||
loadMatches()
|
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() {
|
private fun loadMatches() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = DateMatchesUiState(isLoading = true)
|
_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