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 0f6938da..a0a3f1ee 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -48,6 +48,7 @@ import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.BucketListScreen import app.closer.ui.paywall.PaywallScreen import app.closer.ui.play.PlayHubScreen +import app.closer.ui.challenges.ConnectionChallengesScreen import app.closer.ui.desiresync.DesireSyncScreen import app.closer.ui.howwell.HowWellScreen import app.closer.ui.thisorthat.ThisOrThatScreen @@ -311,6 +312,9 @@ fun AppNavigation( composable(route = AppRoute.DESIRE_SYNC) { DesireSyncScreen(onNavigate = navigateRoute) } + composable(route = AppRoute.CONNECTION_CHALLENGES) { + ConnectionChallengesScreen(onNavigate = navigateRoute) + } composable(route = AppRoute.WAITING_FOR_PARTNER) { WaitingForPartnerScreen( onNavigate = navigateRoute @@ -391,6 +395,7 @@ private val shellBackRoutes = setOf( AppRoute.THIS_OR_THAT, AppRoute.HOW_WELL, AppRoute.DESIRE_SYNC, + AppRoute.CONNECTION_CHALLENGES, AppRoute.WAITING_FOR_PARTNER, AppRoute.SUBSCRIPTION, ) 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 72780a85..d1e63fb9 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -41,6 +41,7 @@ object AppRoute { const val THIS_OR_THAT = "this_or_that" const val HOW_WELL = "how_well" const val DESIRE_SYNC = "desire_sync" + const val CONNECTION_CHALLENGES = "connection_challenges" const val WAITING_FOR_PARTNER = "waiting_for_partner" // Question thread: coupleId and questionId are required; prevId and nextId are optional. @@ -93,6 +94,7 @@ object AppRoute { Definition(THIS_OR_THAT, "This or That", "play"), Definition(HOW_WELL, "How Well Do You Know Me", "play"), Definition(DESIRE_SYNC, "Desire Sync", "play"), + Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"), Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play") ) @@ -141,6 +143,7 @@ object AppRoute { SUBSCRIPTION, RELATIONSHIP_SETTINGS, DELETE_ACCOUNT, + CONNECTION_CHALLENGES, WAITING_FOR_PARTNER ) diff --git a/app/src/main/java/app/closer/data/challenges/ChallengesCatalog.kt b/app/src/main/java/app/closer/data/challenges/ChallengesCatalog.kt new file mode 100644 index 00000000..a9ee1b95 --- /dev/null +++ b/app/src/main/java/app/closer/data/challenges/ChallengesCatalog.kt @@ -0,0 +1,80 @@ +package app.closer.data.challenges + +import app.closer.domain.model.ChallengeDayPrompt +import app.closer.domain.model.ConnectionChallenge + +object ChallengesCatalog { + + val all: List = listOf( + ConnectionChallenge( + id = "gratitude_week", + title = "Gratitude Week", + description = "Tell your partner one genuine thing you're grateful for, every day for a week.", + emoji = "🙏", + category = "Gratitude", + isPremium = false, + days = listOf( + ChallengeDayPrompt(1, "Tell your partner one thing they did this week that you genuinely appreciated.", "Say it out loud or send a message."), + ChallengeDayPrompt(2, "Share something your partner does every day that you often take for granted.", "The small, consistent things."), + ChallengeDayPrompt(3, "Thank your partner for something that made your life easier recently.", "It doesn't have to be big."), + ChallengeDayPrompt(4, "Tell your partner about a memory from your relationship that still makes you smile.", "A moment, not a milestone."), + ChallengeDayPrompt(5, "Share one quality in your partner that you genuinely admire.", "Something you'd tell a stranger about them."), + ChallengeDayPrompt(6, "Thank your partner for one way they've supported your growth.", "How have they made you better?"), + ChallengeDayPrompt(7, "Tell your partner what your life would look like without them — the honest version.", "Take your time with this one.") + ) + ), + ConnectionChallenge( + id = "appreciation_notes", + title = "Appreciation Notes", + description = "Leave one small, genuine note or message of appreciation every day.", + emoji = "💌", + category = "Appreciation", + isPremium = false, + days = listOf( + ChallengeDayPrompt(1, "Leave your partner a voice message — just one thing you love about them.", "No pressure. Two sentences is plenty."), + ChallengeDayPrompt(2, "Send a photo of something that reminded you of your partner today.", "Anything counts."), + ChallengeDayPrompt(3, "Tell your partner about a small shared moment you secretly treasure.", "Something they might not know you hold onto."), + ChallengeDayPrompt(4, "Acknowledge one thing your partner is going through right now and say you see it.", "You don't have to fix anything."), + ChallengeDayPrompt(5, "Write your partner a two-sentence note. No length requirement, no pressure.", "Two sentences. That's it."), + ChallengeDayPrompt(6, "Name one way your partner has changed you for the better.", "How are you different because of them?"), + ChallengeDayPrompt(7, "Tell your partner the thing you most want them to know about how you see them.", "The thing you mean but rarely say.") + ) + ), + ConnectionChallenge( + id = "quality_time", + title = "Quality Time", + description = "Fifteen minutes of real presence, every day for a week.", + emoji = "⏳", + category = "Connection", + isPremium = false, + days = listOf( + ChallengeDayPrompt(1, "Put your phones away for 15 minutes and just exist together — no agenda.", "Silence is fine."), + ChallengeDayPrompt(2, "Ask your partner one question you don't already know the answer to.", "Stay curious."), + ChallengeDayPrompt(3, "Share a meal without screens — even if it's just coffee.", "Just the two of you."), + ChallengeDayPrompt(4, "Do one small thing your partner enjoys, just because they enjoy it.", "Not your thing? Even better."), + ChallengeDayPrompt(5, "Show your partner something you've been thinking about lately.", "A song, an article, a thought."), + ChallengeDayPrompt(6, "Take a 10-minute walk together with no destination.", "No phones, no plan."), + ChallengeDayPrompt(7, "Tell your partner one thing you want more of in your relationship right now.", "Say it kindly. Mean it.") + ) + ), + ConnectionChallenge( + id = "deep_conversations", + title = "Deep Conversations", + description = "One real question a day that neither of you has thought to ask before.", + emoji = "💬", + category = "Communication", + isPremium = true, + days = listOf( + ChallengeDayPrompt(1, "What's something you've been meaning to tell your partner but haven't found the right moment for?", "This is the moment."), + ChallengeDayPrompt(2, "What does home mean to you, and does this relationship feel like home?", "Take it seriously."), + ChallengeDayPrompt(3, "What's one fear you have about your future together that you've never said out loud?", "You don't have to have a solution."), + ChallengeDayPrompt(4, "What's the kindest thing your partner has ever done for you?", "Tell them why it still matters."), + ChallengeDayPrompt(5, "What's something you wish your partner understood about you better?", "No blame, just honesty."), + ChallengeDayPrompt(6, "What's your most treasured shared memory from the last year?", "The one you'd keep if you could only keep one."), + ChallengeDayPrompt(7, "What do you want your life together to look like in five years?", "Dream a little.") + ) + ) + ) + + fun findById(id: String): ConnectionChallenge? = all.firstOrNull { it.id == id } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt new file mode 100644 index 00000000..bac02f9d --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt @@ -0,0 +1,83 @@ +package app.closer.data.remote + +import app.closer.domain.model.ChallengeProgressState +import com.google.firebase.firestore.FieldValue +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseFirestore) { + + private fun challengeRef(coupleId: String, challengeId: String) = + db.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.CHALLENGES) + .document(challengeId) + + private fun challengesCol(coupleId: String) = + db.collection(FirestoreCollections.COUPLES) + .document(coupleId) + .collection(FirestoreCollections.Couples.CHALLENGES) + + suspend fun getActiveChallengeId(coupleId: String): String? = + challengesCol(coupleId) + .whereEqualTo("status", "active") + .limit(1) + .get() + .await() + .documents + .firstOrNull() + ?.getString("challengeId") + + suspend fun startChallenge(coupleId: String, challengeId: String) { + challengeRef(coupleId, challengeId).set( + mapOf( + "challengeId" to challengeId, + "startedAt" to System.currentTimeMillis(), + "status" to "active", + "completions" to emptyMap>() + ) + ).await() + } + + suspend fun markDayComplete(coupleId: String, challengeId: String, userId: String, day: Int) { + challengeRef(coupleId, challengeId) + .update("completions.$userId", FieldValue.arrayUnion(day)) + .await() + } + + suspend fun finishChallenge(coupleId: String, challengeId: String) { + challengeRef(coupleId, challengeId).update("status", "completed").await() + } + + fun observeProgress( + coupleId: String, + challengeId: String, + myUserId: String, + partnerUserId: String + ): Flow = callbackFlow { + val reg = challengeRef(coupleId, challengeId) + .addSnapshotListener { snap, err -> + if (err != null || snap == null) return@addSnapshotListener + @Suppress("UNCHECKED_CAST") + val completions = snap.get("completions") as? Map> ?: emptyMap() + val mine = completions[myUserId]?.map { it.toInt() } ?: emptyList() + val partner = completions[partnerUserId]?.map { it.toInt() } ?: emptyList() + trySend( + ChallengeProgressState( + challengeId = challengeId, + startedAt = snap.getLong("startedAt") ?: 0L, + status = snap.getString("status") ?: "active", + myCompletedDays = mine, + partnerCompletedDays = partner + ) + ) + } + awaitClose { reg.remove() } + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index f37b16ca..550a7fd0 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -30,6 +30,7 @@ object FirestoreCollections { const val DATE_PLANS = "date_plans" const val BUCKET_LIST = "bucket_list" const val DAILY_QUESTION = "daily_question" + const val CHALLENGES = "challenges" } // ── Subcollections under couples/{coupleId}/daily_question/{date} ─────────── diff --git a/app/src/main/java/app/closer/domain/model/ConnectionChallenge.kt b/app/src/main/java/app/closer/domain/model/ConnectionChallenge.kt new file mode 100644 index 00000000..b2ac728e --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/ConnectionChallenge.kt @@ -0,0 +1,46 @@ +package app.closer.domain.model + +data class ConnectionChallenge( + val id: String, + val title: String, + val description: String, + val emoji: String, + val category: String, + val durationDays: Int = 7, + val days: List, + val isPremium: Boolean = false +) + +data class ChallengeDayPrompt( + val day: Int, + val prompt: String, + val hint: String = "" +) + +data class ChallengeProgressState( + val challengeId: String = "", + val startedAt: Long = 0L, + val status: String = "active", + val myCompletedDays: List = emptyList(), + val partnerCompletedDays: List = emptyList() +) { + // Days where both partners have checked in. + val jointCompletedDays: List + get() = myCompletedDays.filter { it in partnerCompletedDays } + + // Next day the current user should complete (regardless of partner). + val myNextDay: Int + get() = (myCompletedDays.maxOrNull() ?: 0) + 1 + + // Consecutive joint days from day 1. + val jointStreak: Int + get() { + var streak = 0 + for (d in 1..7) { + if (d in jointCompletedDays) streak++ else break + } + return streak + } + + val isComplete: Boolean get() = status == "completed" +} diff --git a/app/src/main/java/app/closer/domain/model/GameType.kt b/app/src/main/java/app/closer/domain/model/GameType.kt index 00a1ff03..8d7dbe29 100644 --- a/app/src/main/java/app/closer/domain/model/GameType.kt +++ b/app/src/main/java/app/closer/domain/model/GameType.kt @@ -5,4 +5,5 @@ object GameType { const val THIS_OR_THAT = "this_or_that" const val HOW_WELL = "how_well" const val DESIRE_SYNC = "desire_sync" + const val CONNECTION_CHALLENGES = "connection_challenges" } diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt new file mode 100644 index 00000000..919d9102 --- /dev/null +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -0,0 +1,654 @@ +package app.closer.ui.challenges + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.core.navigation.AppRoute +import app.closer.data.challenges.ChallengesCatalog +import app.closer.data.remote.FirestoreChallengeDataSource +import app.closer.domain.model.ChallengeProgressState +import app.closer.domain.model.ConnectionChallenge +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.ui.theme.CloserPalette +import app.closer.ui.theme.closerBackgroundBrush +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// ── ViewModel ───────────────────────────────────────────────────────────────── + +enum class ChallengesPhase { LOADING, PICK, ACTIVE } + +data class ChallengesUiState( + val phase: ChallengesPhase = ChallengesPhase.LOADING, + val activeChallenge: ConnectionChallenge? = null, + val progress: ChallengeProgressState? = null, + val coupleId: String? = null, + val userId: String? = null, + val partnerId: String? = null, + val error: String? = null, + val navigateTo: String? = null +) + +@HiltViewModel +class ConnectionChallengesViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val challengeDataSource: FirestoreChallengeDataSource +) : ViewModel() { + + private val _uiState = MutableStateFlow(ChallengesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var progressJob: Job? = null + + init { + load() + } + + private fun load() { + viewModelScope.launch { + val uid = authRepository.currentUserId ?: run { + _uiState.update { it.copy(phase = ChallengesPhase.PICK) } + return@launch + } + val couple = coupleRepository.getCoupleForUser(uid) ?: run { + _uiState.update { it.copy(phase = ChallengesPhase.PICK) } + return@launch + } + val partnerId = couple.userIds.firstOrNull { it != uid } ?: uid + + val activeChallengeId = runCatching { + challengeDataSource.getActiveChallengeId(couple.id) + }.getOrNull() + + if (activeChallengeId != null) { + val challenge = ChallengesCatalog.findById(activeChallengeId) + if (challenge != null) { + observeChallenge(couple.id, uid, partnerId, challenge) + return@launch + } + } + + _uiState.update { + it.copy( + phase = ChallengesPhase.PICK, + coupleId = couple.id, + userId = uid, + partnerId = partnerId + ) + } + } + } + + private fun observeChallenge( + coupleId: String, + userId: String, + partnerId: String, + challenge: ConnectionChallenge + ) { + progressJob?.cancel() + progressJob = viewModelScope.launch { + challengeDataSource.observeProgress(coupleId, challenge.id, userId, partnerId) + .collect { progress -> + _uiState.update { + it.copy( + phase = ChallengesPhase.ACTIVE, + coupleId = coupleId, + userId = userId, + partnerId = partnerId, + activeChallenge = challenge, + progress = progress + ) + } + // Auto-complete challenge when all days jointly done. + if (progress.jointCompletedDays.size == challenge.durationDays && + progress.status == "active" + ) { + runCatching { challengeDataSource.finishChallenge(coupleId, challenge.id) } + } + } + } + } + + fun startChallenge(challenge: ConnectionChallenge) { + val state = _uiState.value + val coupleId = state.coupleId ?: return + val userId = state.userId ?: return + val partnerId = state.partnerId ?: userId + viewModelScope.launch { + runCatching { challengeDataSource.startChallenge(coupleId, challenge.id) } + .onSuccess { observeChallenge(coupleId, userId, partnerId, challenge) } + .onFailure { + Log.w(TAG, "Could not start challenge", it) + _uiState.update { s -> s.copy(error = "Could not start challenge. Try again.") } + } + } + } + + fun markTodayComplete() { + val state = _uiState.value + val coupleId = state.coupleId ?: return + val userId = state.userId ?: return + val progress = state.progress ?: return + val challenge = state.activeChallenge ?: return + val day = progress.myNextDay + if (day > challenge.durationDays) return + viewModelScope.launch { + runCatching { challengeDataSource.markDayComplete(coupleId, challenge.id, userId, day) } + .onFailure { Log.w(TAG, "Could not mark day $day complete", it) } + } + } + + fun dismissError() = _uiState.update { it.copy(error = null) } + + fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } + + companion object { + private const val TAG = "ChallengesViewModel" + } +} + +// ── Screen ──────────────────────────────────────────────────────────────────── + +@Composable +fun ConnectionChallengesScreen( + onNavigate: (String) -> Unit = {}, + viewModel: ConnectionChallengesViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(closerBackgroundBrush()) + ) { + when (state.phase) { + ChallengesPhase.LOADING -> ChallengesLoadingScreen() + ChallengesPhase.PICK -> ChallengesPickScreen( + onBack = { onNavigate(AppRoute.PLAY) }, + onPick = { viewModel.startChallenge(it) } + ) + ChallengesPhase.ACTIVE -> ChallengesActiveScreen( + challenge = state.activeChallenge!!, + progress = state.progress ?: ChallengeProgressState(), + onBack = { onNavigate(AppRoute.PLAY) }, + onMarkComplete = { viewModel.markTodayComplete() } + ) + } + } +} + +// ── Loading ─────────────────────────────────────────────────────────────────── + +@Composable +private fun ChallengesLoadingScreen() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = CloserPalette.PurpleDeep) + } +} + +// ── Pick ────────────────────────────────────────────────────────────────────── + +@Composable +private fun ChallengesPickScreen( + onBack: () -> Unit, + onPick: (ConnectionChallenge) -> Unit +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Column { + Text( + text = "Connection Challenges", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "Pick a series to build a habit together.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(ChallengesCatalog.all) { challenge -> + ChallengePickCard(challenge = challenge, onPick = onPick) + } + + item { Spacer(Modifier.height(8.dp)) } + } +} + +@Composable +private fun ChallengePickCard( + challenge: ConnectionChallenge, + onPick: (ConnectionChallenge) -> Unit +) { + Card( + onClick = { if (!challenge.isPremium) onPick(challenge) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.10f), + modifier = Modifier.size(52.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = challenge.emoji, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = challenge.title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + if (challenge.isPremium) { + Surface( + shape = RoundedCornerShape(999.dp), + color = CloserPalette.Gold.copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon(Icons.Filled.Lock, contentDescription = null, tint = CloserPalette.Gold, modifier = Modifier.size(10.dp)) + Text("Premium", style = MaterialTheme.typography.labelSmall, color = CloserPalette.Gold, fontWeight = FontWeight.SemiBold) + } + } + } else { + Surface( + shape = RoundedCornerShape(999.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.10f) + ) { + Text( + text = "${challenge.durationDays} days", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold + ) + } + } + } + Text( + text = challenge.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = challenge.category, + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep.copy(alpha = 0.7f) + ) + } + } + } +} + +// ── Active ──────────────────────────────────────────────────────────────────── + +@Composable +private fun ChallengesActiveScreen( + challenge: ConnectionChallenge, + progress: ChallengeProgressState, + onBack: () -> Unit, + onMarkComplete: () -> Unit +) { + val alreadyDoneToday = progress.myNextDay > (progress.myCompletedDays.maxOrNull() ?: 0) + 1 || + progress.myCompletedDays.contains(progress.myNextDay - 1) + + // Has current user completed today? + val todayDone = progress.myCompletedDays.contains(progress.myNextDay.coerceAtMost(challenge.durationDays)) || + progress.myNextDay > challenge.durationDays + + val allComplete = progress.isComplete || progress.jointCompletedDays.size == challenge.durationDays + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = challenge.title, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (allComplete) "Completed 🎉" else "Day ${progress.myNextDay.coerceAtMost(challenge.durationDays)} of ${challenge.durationDays}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Streak badge + if (progress.jointStreak > 0) { + Surface( + shape = RoundedCornerShape(999.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.12f) + ) { + Text( + text = "🔥 ${progress.jointStreak}", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + + item { + // Day tracker strip + DayTrackerStrip( + totalDays = challenge.durationDays, + myCompletedDays = progress.myCompletedDays, + partnerCompletedDays = progress.partnerCompletedDays, + currentDay = progress.myNextDay + ) + } + + if (!allComplete) { + val displayDay = progress.myNextDay.coerceAtMost(challenge.durationDays) + val dayPrompt = challenge.days.getOrNull(displayDay - 1) + + if (dayPrompt != null) { + item { + // Today's prompt card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.10f) + ) { + Text( + text = "Day $displayDay", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold + ) + } + Text( + text = dayPrompt.prompt, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight + ) + if (dayPrompt.hint.isNotBlank()) { + Text( + text = dayPrompt.hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + item { + // Partner status row + val partnerDoneToday = progress.partnerCompletedDays.contains(displayDay) + Surface( + shape = RoundedCornerShape(16.dp), + color = if (partnerDoneToday) + CloserPalette.Evergreen.copy(alpha = 0.10f) + else + MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (partnerDoneToday) { + Icon(Icons.Filled.Check, contentDescription = null, tint = CloserPalette.Evergreen, modifier = Modifier.size(18.dp)) + Text("Partner completed today", style = MaterialTheme.typography.bodySmall, color = CloserPalette.Evergreen, fontWeight = FontWeight.Medium) + } else { + Text("⏳", style = MaterialTheme.typography.bodySmall) + Text("Waiting for your partner", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + item { + // CTA + val iDoneToday = progress.myCompletedDays.contains(displayDay) + Button( + onClick = onMarkComplete, + enabled = !iDoneToday, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 54.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CloserPalette.PurpleDeep, + disabledContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.35f) + ) + ) { + Text( + text = if (iDoneToday) "Done for today ✓" else "I did it today", + style = MaterialTheme.typography.labelLarge, + color = Color.White + ) + } + } + } + } else { + item { + // Completion state + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("🎉", style = MaterialTheme.typography.displaySmall) + Text( + text = "Challenge complete!", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Text( + text = "You and your partner finished \"${challenge.title}\" together. That's ${challenge.durationDays} days in a row.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } + } + + item { Spacer(Modifier.height(8.dp)) } + } +} + +@Composable +private fun DayTrackerStrip( + totalDays: Int, + myCompletedDays: List, + partnerCompletedDays: List, + currentDay: Int +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + for (day in 1..totalDays) { + val myDone = day in myCompletedDays + val partnerDone = day in partnerCompletedDays + val jointDone = myDone && partnerDone + val isCurrent = day == currentDay.coerceAtMost(totalDays) && !myDone + + val bg = when { + jointDone -> CloserPalette.PurpleDeep + myDone -> CloserPalette.PurpleDeep.copy(alpha = 0.45f) + isCurrent -> CloserPalette.PurpleDeep.copy(alpha = 0.15f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(bg), + contentAlignment = Alignment.Center + ) { + if (jointDone) { + Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp)) + } else { + Text( + text = "$day", + style = MaterialTheme.typography.labelSmall, + color = if (myDone || isCurrent) Color.White else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + fontWeight = FontWeight.SemiBold + ) + } + } + } + } +} diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index a7de528e..ef99c965 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -116,6 +116,12 @@ private fun PlayHubContent( ) } + item { + ConnectionChallengesCard( + onClick = { onNavigate(AppRoute.CONNECTION_CHALLENGES) } + ) + } + item { Row( modifier = Modifier.fillMaxWidth(), @@ -401,6 +407,85 @@ private fun HowWellCard( } } +@Composable +private fun ConnectionChallengesCard( + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 132.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.12f), + modifier = Modifier.size(52.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "🔗", + style = MaterialTheme.typography.headlineSmall + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Connection Challenges", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = CloserPalette.PurpleDeep.copy(alpha = 0.10f) + ) { + Text( + text = "7 days", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold + ) + } + } + Text( + text = "Pick a series and build a small habit together, one day at a time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = CloserPalette.PurpleDeep, + modifier = Modifier.size(18.dp) + ) + } + } +} + @Composable private fun FeaturedPlayCard( onClick: () -> Unit