Revert "feat(date-memories): add DateMemories timeline + DateReflection screen, wire into nav (batch 2/8)"
This reverts commit 15087df13b.
This commit is contained in:
parent
5905c2b2d0
commit
6a0849deb7
|
|
@ -46,8 +46,6 @@ 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
|
||||||
|
|
@ -511,15 +509,6 @@ 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,8 +46,6 @@ 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"
|
||||||
|
|
@ -197,8 +195,6 @@ 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,9 +29,6 @@ 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
|
||||||
|
|
@ -45,13 +42,8 @@ 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
|
||||||
|
|
@ -65,17 +57,10 @@ 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) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,9 +68,7 @@ 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
|
||||||
|
|
@ -121,16 +104,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +149,7 @@ private fun DateMatchesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(state.mutualMatches, key = { it.id }) { match ->
|
items(state.mutualMatches, key = { it.id }) { match ->
|
||||||
MatchCard(match = match, onMarkCompleted = { onMarkCompleted(match) })
|
MatchCard(match = match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,22 +219,10 @@ private fun SectionHeader(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MatchCard(match: DateMatch, onMarkCompleted: () -> Unit = {}) {
|
private fun MatchCard(match: DateMatch) {
|
||||||
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),
|
||||||
|
|
@ -325,8 +286,7 @@ 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(),
|
||||||
|
|
@ -388,8 +348,6 @@ private fun IdeaCard(
|
||||||
InfoChip(label = "Premium", emphasis = true)
|
InfoChip(label = "Premium", emphasis = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
action?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ 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
|
||||||
|
|
@ -18,11 +16,8 @@ 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
|
||||||
|
|
@ -47,44 +42,16 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
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