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.dates.BucketListScreen
|
||||||
import app.closer.ui.paywall.PaywallScreen
|
import app.closer.ui.paywall.PaywallScreen
|
||||||
import app.closer.ui.play.PlayHubScreen
|
import app.closer.ui.play.PlayHubScreen
|
||||||
|
import app.closer.ui.challenges.ConnectionChallengesScreen
|
||||||
import app.closer.ui.desiresync.DesireSyncScreen
|
import app.closer.ui.desiresync.DesireSyncScreen
|
||||||
import app.closer.ui.howwell.HowWellScreen
|
import app.closer.ui.howwell.HowWellScreen
|
||||||
import app.closer.ui.thisorthat.ThisOrThatScreen
|
import app.closer.ui.thisorthat.ThisOrThatScreen
|
||||||
|
|
@ -311,6 +312,9 @@ fun AppNavigation(
|
||||||
composable(route = AppRoute.DESIRE_SYNC) {
|
composable(route = AppRoute.DESIRE_SYNC) {
|
||||||
DesireSyncScreen(onNavigate = navigateRoute)
|
DesireSyncScreen(onNavigate = navigateRoute)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.CONNECTION_CHALLENGES) {
|
||||||
|
ConnectionChallengesScreen(onNavigate = navigateRoute)
|
||||||
|
}
|
||||||
composable(route = AppRoute.WAITING_FOR_PARTNER) {
|
composable(route = AppRoute.WAITING_FOR_PARTNER) {
|
||||||
WaitingForPartnerScreen(
|
WaitingForPartnerScreen(
|
||||||
onNavigate = navigateRoute
|
onNavigate = navigateRoute
|
||||||
|
|
@ -391,6 +395,7 @@ private val shellBackRoutes = setOf(
|
||||||
AppRoute.THIS_OR_THAT,
|
AppRoute.THIS_OR_THAT,
|
||||||
AppRoute.HOW_WELL,
|
AppRoute.HOW_WELL,
|
||||||
AppRoute.DESIRE_SYNC,
|
AppRoute.DESIRE_SYNC,
|
||||||
|
AppRoute.CONNECTION_CHALLENGES,
|
||||||
AppRoute.WAITING_FOR_PARTNER,
|
AppRoute.WAITING_FOR_PARTNER,
|
||||||
AppRoute.SUBSCRIPTION,
|
AppRoute.SUBSCRIPTION,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ object AppRoute {
|
||||||
const val THIS_OR_THAT = "this_or_that"
|
const val THIS_OR_THAT = "this_or_that"
|
||||||
const val HOW_WELL = "how_well"
|
const val HOW_WELL = "how_well"
|
||||||
const val DESIRE_SYNC = "desire_sync"
|
const val DESIRE_SYNC = "desire_sync"
|
||||||
|
const val CONNECTION_CHALLENGES = "connection_challenges"
|
||||||
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
const val WAITING_FOR_PARTNER = "waiting_for_partner"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// 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(THIS_OR_THAT, "This or That", "play"),
|
||||||
Definition(HOW_WELL, "How Well Do You Know Me", "play"),
|
Definition(HOW_WELL, "How Well Do You Know Me", "play"),
|
||||||
Definition(DESIRE_SYNC, "Desire Sync", "play"),
|
Definition(DESIRE_SYNC, "Desire Sync", "play"),
|
||||||
|
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
|
||||||
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play")
|
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -141,6 +143,7 @@ object AppRoute {
|
||||||
SUBSCRIPTION,
|
SUBSCRIPTION,
|
||||||
RELATIONSHIP_SETTINGS,
|
RELATIONSHIP_SETTINGS,
|
||||||
DELETE_ACCOUNT,
|
DELETE_ACCOUNT,
|
||||||
|
CONNECTION_CHALLENGES,
|
||||||
WAITING_FOR_PARTNER
|
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 DATE_PLANS = "date_plans"
|
||||||
const val BUCKET_LIST = "bucket_list"
|
const val BUCKET_LIST = "bucket_list"
|
||||||
const val DAILY_QUESTION = "daily_question"
|
const val DAILY_QUESTION = "daily_question"
|
||||||
|
const val CHALLENGES = "challenges"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────
|
// ── 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 THIS_OR_THAT = "this_or_that"
|
||||||
const val HOW_WELL = "how_well"
|
const val HOW_WELL = "how_well"
|
||||||
const val DESIRE_SYNC = "desire_sync"
|
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 {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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
|
@Composable
|
||||||
private fun FeaturedPlayCard(
|
private fun FeaturedPlayCard(
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue