feat(wheel): ViewModel-backed wheel screens with local session store

This commit is contained in:
null 2026-06-16 00:56:08 -05:00
parent 577d39ea11
commit 011745e7d4
8 changed files with 1027 additions and 81 deletions

View File

@ -1,36 +1,218 @@
package com.couplesconnect.app.ui.wheel package com.couplesconnect.app.ui.wheel
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.ui.components.PlaceholderScreen import com.couplesconnect.app.ui.questions.displayCategoryName
@Composable @Composable
fun CategoryPickerScreen( fun CategoryPickerScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: CategoryPickerViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Choose the weather",
section = "Wheel", CategoryPickerContent(
description = "A category picker for matching the conversation to the couple's energy in the moment.", state = state,
route = AppRoute.CATEGORY_PICKER, onCategorySelected = { item ->
onNavigate = onNavigate, if (item.isLocked) onNavigate(AppRoute.PAYWALL)
accent = Color(0xFF6C8EA4), else onNavigate(AppRoute.spinWheel(item.category.id))
primaryAction = PlaceholderAction("Spin trust", AppRoute.spinWheel("trust")), },
secondaryAction = PlaceholderAction("Question packs", AppRoute.QUESTION_PACKS), onRetry = viewModel::load
chips = listOf("Categories", "Mood-aware", "Wheel entry"),
details = listOf(
"Seeded question categories can surface here",
"The selected category stays with the flow",
"The spin flow stays separate from daily questions"
)
) )
} }
@Composable
private fun CategoryPickerContent(
state: CategoryPickerUiState,
onCategorySelected: (CategoryPickerItem) -> Unit,
onRetry: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF0EEF8), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Choose the weather",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = "Pick a category that matches where you are tonight. The wheel picks the question.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
when {
state.isLoading -> item {
Row(
modifier = Modifier.padding(22.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(color = Color(0xFF7C6F9E))
Text("Loading categories…", style = MaterialTheme.typography.bodyMedium, color = Color(0xFF4E4642))
}
}
state.error != null -> item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Categories unavailable", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color(0xFF27211F))
Text(state.error, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF4E4642))
}
}
}
else -> items(state.categories, key = { it.category.id }) { item ->
CategoryCard(item = item, onClick = { onCategorySelected(item) })
}
}
item { Box(Modifier.padding(bottom = 8.dp)) }
}
}
}
@Composable
private fun CategoryCard(
item: CategoryPickerItem,
onClick: () -> Unit
) {
val containerColor = if (item.isLocked) Color(0xFFF5F0EC).copy(alpha = 0.84f) else Color.White.copy(alpha = 0.86f)
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Row(
modifier = Modifier.padding(18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (item.isLocked) Color(0xFF9E9693) else Color(0xFF27211F),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("${item.questionCount} prompts")
if (item.isLocked) CategoryPill("Premium")
}
}
if (item.isLocked) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "Locked",
tint = Color(0xFFB0A9A6),
modifier = Modifier.size(20.dp)
)
}
}
}
}
@Composable
private fun CategoryPill(label: String) {
Surface(shape = RoundedCornerShape(999.dp), color = Color(0xFFF0EDF9)) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF4A3F7A)
)
}
}
@Preview @Preview
@Composable @Composable
fun CategoryPickerScreenPreview() { fun CategoryPickerScreenPreview() {
CategoryPickerScreen() CategoryPickerContent(
state = CategoryPickerUiState(
isLoading = false,
categories = listOf(
CategoryPickerItem(
category = QuestionCategory("communication", "Communication", "Prompts for connection", "free", "chat"),
questionCount = 250,
isLocked = false
),
CategoryPickerItem(
category = QuestionCategory("intimacy", "Intimacy", "Prompts for closeness", "premium", "heart"),
questionCount = 180,
isLocked = true
)
)
),
onCategorySelected = {},
onRetry = {}
)
} }

View File

@ -0,0 +1,61 @@
package com.couplesconnect.app.ui.wheel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.billing.EntitlementChecker
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class CategoryPickerItem(
val category: QuestionCategory,
val questionCount: Int,
val isLocked: Boolean
)
data class CategoryPickerUiState(
val isLoading: Boolean = true,
val error: String? = null,
val categories: List<CategoryPickerItem> = emptyList()
)
@HiltViewModel
class CategoryPickerViewModel @Inject constructor(
private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(CategoryPickerUiState())
val uiState: StateFlow<CategoryPickerUiState> = _uiState.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
_uiState.value = CategoryPickerUiState(isLoading = true)
try {
val hasPremium = entitlementChecker.hasPremium
val items = repository.getCategories().map { category ->
CategoryPickerItem(
category = category,
questionCount = repository.getQuestionCountByCategory(category.id),
isLocked = category.access == "premium" && !hasPremium
)
}
_uiState.value = CategoryPickerUiState(isLoading = false, categories = items)
} catch (e: Exception) {
_uiState.value = CategoryPickerUiState(
isLoading = false,
error = e.message ?: "Could not load categories."
)
}
}
}
}

View File

@ -0,0 +1,18 @@
package com.couplesconnect.app.ui.wheel
import com.couplesconnect.app.domain.model.Question
import javax.inject.Inject
import javax.inject.Singleton
data class LocalWheelSession(
val categoryId: String,
val categoryName: String,
val questions: List<Question>
)
@Singleton
class LocalWheelSessionStore @Inject constructor() {
var activeSession: LocalWheelSession? = null
var lastAnswered: Int = 0
var lastTotal: Int = 0
}

View File

@ -1,37 +1,220 @@
package com.couplesconnect.app.ui.wheel package com.couplesconnect.app.ui.wheel
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute import androidx.compose.ui.unit.dp
import com.couplesconnect.app.ui.components.PlaceholderAction import androidx.compose.ui.unit.sp
import com.couplesconnect.app.ui.components.PlaceholderScreen import androidx.hilt.navigation.compose.hiltViewModel
@Composable @Composable
fun SpinWheelScreen( fun SpinWheelScreen(
categoryId: String, categoryId: String,
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: SpinWheelViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Let the prompt find you",
section = "Wheel", LaunchedEffect(state.navigateTo) {
description = "A playful selection surface for turning a chosen category into a short question session.", state.navigateTo?.let {
route = AppRoute.spinWheel(categoryId), onNavigate(it)
onNavigate = onNavigate, viewModel.onNavigated()
accent = Color(0xFFF2A65A), }
primaryAction = PlaceholderAction("Start session", AppRoute.wheelSession("session-preview")), }
secondaryAction = PlaceholderAction("Categories", AppRoute.CATEGORY_PICKER),
chips = listOf("Category $categoryId", "Motion", "Session"), SpinWheelContent(
details = listOf( state = state,
"Wheel animation has room to become tactile", onSpin = viewModel::spin,
"The chosen category stays visible", onStart = viewModel::startSession
"Session start feels like one continuous step"
)
) )
} }
@Composable
private fun SpinWheelContent(
state: SpinWheelUiState,
onSpin: () -> Unit,
onStart: () -> Unit
) {
val infiniteTransition = rememberInfiniteTransition(label = "wheel")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "wheel_rotation"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF0EEF8), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Let the prompt find you",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
textAlign = TextAlign.Center
)
if (state.categoryName.isNotBlank()) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF0EDF9)
) {
Text(
text = state.categoryName,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF4A3F7A)
)
}
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f)
) {
Surface(
modifier = Modifier
.size(220.dp)
.rotate(if (state.isSpinning) rotation else 0f),
shape = CircleShape,
color = Color(0xFFF0EDF9),
shadowElevation = 12.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = if (state.spunAndReady) "" else "",
fontSize = 64.sp,
color = if (state.spunAndReady) Color(0xFF81B29A) else Color(0xFF7C6F9E)
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
when {
state.error != null -> Text(
text = state.error,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB5473A),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
state.spunAndReady -> {
Text(
text = "${SpinWheelViewModel.SESSION_SIZE} questions selected",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C6F9E))
) {
Text("Start session", color = Color.White)
}
OutlinedButton(
onClick = onSpin,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp)
) {
Text("Spin again")
}
}
state.isLoading -> CircularProgressIndicator(color = Color(0xFF7C6F9E))
else -> {
Text(
text = "Tap to select ${SpinWheelViewModel.SESSION_SIZE} questions at random",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = onSpin,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C6F9E))
) {
Text("Spin", color = Color.White)
}
}
}
}
}
}
}
@Preview @Preview
@Composable @Composable
fun SpinWheelScreenPreview() { fun SpinWheelScreenPreview() {
SpinWheelScreen(categoryId = "trust") SpinWheelContent(
state = SpinWheelUiState(isLoading = false, categoryName = "Trust"),
onSpin = {},
onStart = {}
)
} }

View File

@ -0,0 +1,89 @@
package com.couplesconnect.app.ui.wheel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SpinWheelUiState(
val isLoading: Boolean = true,
val categoryName: String = "",
val isSpinning: Boolean = false,
val spunAndReady: Boolean = false,
val error: String? = null,
val navigateTo: String? = null
)
@HiltViewModel
class SpinWheelViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: QuestionRepository,
private val sessionStore: LocalWheelSessionStore
) : ViewModel() {
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
private val _uiState = MutableStateFlow(SpinWheelUiState())
val uiState: StateFlow<SpinWheelUiState> = _uiState.asStateFlow()
init {
loadCategory()
}
private fun loadCategory() {
viewModelScope.launch {
val category = runCatching { repository.getCategoryById(categoryId) }.getOrNull()
_uiState.update {
it.copy(
isLoading = false,
categoryName = category?.displayName ?: categoryId
)
}
}
}
fun spin() {
if (_uiState.value.isSpinning) return
viewModelScope.launch {
_uiState.update { it.copy(isSpinning = true, error = null) }
val questions = runCatching {
repository.getQuestionsByCategory(categoryId).shuffled().take(SESSION_SIZE)
}.getOrElse { emptyList() }
if (questions.isEmpty()) {
_uiState.update {
it.copy(isSpinning = false, error = "No questions found for this category.")
}
return@launch
}
val category = runCatching { repository.getCategoryById(categoryId) }.getOrNull()
sessionStore.activeSession = LocalWheelSession(
categoryId = categoryId,
categoryName = category?.displayName ?: categoryId,
questions = questions
)
_uiState.update { it.copy(isSpinning = false, spunAndReady = true) }
}
}
fun startSession() {
_uiState.update { it.copy(navigateTo = AppRoute.wheelSession("local")) }
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
companion object {
const val SESSION_SIZE = 10
}
}

View File

@ -1,37 +1,176 @@
package com.couplesconnect.app.ui.wheel package com.couplesconnect.app.ui.wheel
import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction import dagger.hilt.android.lifecycle.HiltViewModel
import com.couplesconnect.app.ui.components.PlaceholderScreen import javax.inject.Inject
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
@HiltViewModel
class WheelCompleteViewModel @Inject constructor(
private val sessionStore: LocalWheelSessionStore
) : ViewModel() {
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
val answered: Int = sessionStore.lastAnswered
val total: Int = sessionStore.lastTotal
}
@Composable @Composable
fun WheelCompleteScreen( fun WheelCompleteScreen(
sessionId: String, sessionId: String,
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: WheelCompleteViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( WheelCompleteContent(
title = "Close the loop", categoryName = viewModel.categoryName,
section = "Wheel", answered = viewModel.answered,
description = "A completion surface for celebrating the ritual and offering the next gentle step.", total = viewModel.total,
route = AppRoute.wheelComplete(sessionId), onHome = { onNavigate(AppRoute.HOME) },
onNavigate = onNavigate, onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) }
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Answer history", AppRoute.ANSWER_HISTORY),
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Session $sessionId", "Completion", "Reflect"),
details = listOf(
"The session id survives through the full wheel flow",
"Reflection and history paths are ready",
"Celebration can stay simple and sincere"
)
) )
} }
@Composable
private fun WheelCompleteContent(
categoryName: String,
answered: Int,
total: Int,
onHome: () -> Unit,
onSpinAgain: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF0F5F2), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "",
fontSize = 64.sp,
color = Color(0xFF81B29A)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Session complete",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
textAlign = TextAlign.Center
)
if (categoryName.isNotBlank()) {
Text(
text = categoryName,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642),
textAlign = TextAlign.Center
)
}
}
if (total > 0) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier.padding(22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "$answered",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF81B29A)
)
Text(
text = "of $total questions",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onHome,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF81B29A))
) {
Text("Back home", color = Color.White)
}
OutlinedButton(
onClick = onSpinAgain,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp)
) {
Text("Spin again")
}
}
}
}
}
@Preview @Preview
@Composable @Composable
fun WheelCompleteScreenPreview() { fun WheelCompleteScreenPreview() {
WheelCompleteScreen(sessionId = "session-preview") WheelCompleteContent(
categoryName = "Trust",
answered = 8,
total = 10,
onHome = {},
onSpinAgain = {}
)
} }

View File

@ -1,37 +1,233 @@
package com.couplesconnect.app.ui.wheel package com.couplesconnect.app.ui.wheel
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute import androidx.compose.ui.unit.dp
import com.couplesconnect.app.ui.components.PlaceholderAction import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.ui.components.PlaceholderScreen import com.couplesconnect.app.domain.model.Question
@Composable @Composable
fun WheelSessionScreen( fun WheelSessionScreen(
sessionId: String, sessionId: String,
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: WheelSessionViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Stay with the question",
section = "Wheel", LaunchedEffect(state.navigateTo) {
description = "A lightweight session space for a chosen prompt, timer, partner state, and completion moment.", state.navigateTo?.let {
route = AppRoute.wheelSession(sessionId), onNavigate(it)
onNavigate = onNavigate, viewModel.onNavigated()
accent = Color(0xFFE07A5F), }
primaryAction = PlaceholderAction("Complete", AppRoute.wheelComplete(sessionId)), }
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Session $sessionId", "Prompt flow", "Finish path"), WheelSessionContent(
details = listOf( state = state,
"Session state can stay calm and readable", onNext = viewModel::next,
"Completion keeps continuity with the same moment", onSkip = viewModel::skip,
"The flow can return home at any point" onEnd = viewModel::endEarly
)
) )
} }
@Composable
private fun WheelSessionContent(
state: WheelSessionUiState,
onNext: () -> Unit,
onSkip: () -> Unit,
onEnd: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F0F8), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 24.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
if (state.isEmpty) {
EmptySessionCard()
return@Column
}
val total = state.questions.size
val current = state.currentIndex
val progress = if (total > 0) (current.toFloat() / total) else 0f
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (state.categoryName.isNotBlank()) {
Surface(shape = RoundedCornerShape(999.dp), color = Color(0xFFF0EDF9)) {
Text(
text = state.categoryName,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4A3F7A)
)
}
}
Text(
text = "${current + 1} / $total",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF9E9693)
)
}
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
color = Color(0xFF7C6F9E),
trackColor = Color(0xFFE8E4F0)
)
val question = state.questions.getOrNull(current)
Card(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(28.dp),
contentAlignment = Alignment.Center
) {
Text(
text = question?.text ?: "",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.titleLarge.lineHeight
)
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C6F9E))
) {
Text(
text = if (current + 1 >= total) "Finish" else "Next question",
color = Color.White
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onSkip,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(14.dp)
) {
Text("Skip")
}
TextButton(
onClick = onEnd,
modifier = Modifier.weight(1f)
) {
Text("End session", color = Color(0xFF9E9693))
}
}
}
}
}
}
@Composable
private fun EmptySessionCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"No active session",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Text(
"Go back to the category picker and spin the wheel to start.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Preview @Preview
@Composable @Composable
fun WheelSessionScreenPreview() { fun WheelSessionScreenPreview() {
WheelSessionScreen(sessionId = "session-preview") WheelSessionContent(
state = WheelSessionUiState(
questions = listOf(
Question(id = "1", text = "When did you last feel truly seen by me?", category = "trust", depthLevel = 3),
Question(id = "2", text = "What's one thing you wish we talked about more?", category = "communication", depthLevel = 2)
),
currentIndex = 0,
categoryName = "Trust"
),
onNext = {},
onSkip = {},
onEnd = {}
)
} }

View File

@ -0,0 +1,78 @@
package com.couplesconnect.app.ui.wheel
import androidx.lifecycle.ViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.Question
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class WheelSessionUiState(
val questions: List<Question> = emptyList(),
val currentIndex: Int = 0,
val skippedCount: Int = 0,
val categoryName: String = "",
val navigateTo: String? = null,
val isEmpty: Boolean = false
)
@HiltViewModel
class WheelSessionViewModel @Inject constructor(
private val sessionStore: LocalWheelSessionStore
) : ViewModel() {
private val _uiState = MutableStateFlow(WheelSessionUiState())
val uiState: StateFlow<WheelSessionUiState> = _uiState.asStateFlow()
init {
val session = sessionStore.activeSession
if (session == null) {
_uiState.update { it.copy(isEmpty = true) }
} else {
_uiState.update {
it.copy(
questions = session.questions,
categoryName = session.categoryName
)
}
}
}
fun next() {
val state = _uiState.value
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) {
finishSession()
} else {
_uiState.update { it.copy(currentIndex = nextIndex) }
}
}
fun skip() {
val state = _uiState.value
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.questions.size) {
finishSession()
} else {
_uiState.update { it.copy(currentIndex = nextIndex, skippedCount = state.skippedCount + 1) }
}
}
fun endEarly() {
finishSession()
}
private fun finishSession() {
val state = _uiState.value
sessionStore.lastAnswered = (state.currentIndex + 1).coerceAtMost(state.questions.size)
sessionStore.lastTotal = state.questions.size
_uiState.update { it.copy(navigateTo = AppRoute.wheelComplete("local")) }
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
}