feat: add Connection Challenge system with game routing, Firestore data source, ChallengeGameScreen in PlayHub

This commit is contained in:
null 2026-06-18 04:03:57 -05:00
parent 574fed27f7
commit 4e871f8e4f
9 changed files with 958 additions and 0 deletions

View File

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

View File

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

View File

@ -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<ConnectionChallenge> = 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 }
}

View File

@ -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<String, List<Int>>()
)
).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<ChallengeProgressState> = 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<String, List<Long>> ?: 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() }
}
}

View File

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

View File

@ -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<ChallengeDayPrompt>,
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<Int> = emptyList(),
val partnerCompletedDays: List<Int> = emptyList()
) {
// Days where both partners have checked in.
val jointCompletedDays: List<Int>
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"
}

View File

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

View File

@ -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<ChallengesUiState> = _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<Int>,
partnerCompletedDays: List<Int>,
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
)
}
}
}
}
}

View File

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