feat: add Connection Challenge system with game routing, Firestore data source, ChallengeGameScreen in PlayHub
This commit is contained in:
parent
574fed27f7
commit
4e871f8e4f
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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} ───────────
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue