diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerScreen.kt index d6e20968..f2dfea69 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerScreen.kt @@ -1,36 +1,218 @@ 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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow 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.ui.components.PlaceholderAction -import com.couplesconnect.app.ui.components.PlaceholderScreen +import com.couplesconnect.app.domain.model.QuestionCategory +import com.couplesconnect.app.ui.questions.displayCategoryName @Composable fun CategoryPickerScreen( - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: CategoryPickerViewModel = hiltViewModel() ) { - PlaceholderScreen( - title = "Choose the weather", - section = "Wheel", - description = "A category picker for matching the conversation to the couple's energy in the moment.", - route = AppRoute.CATEGORY_PICKER, - onNavigate = onNavigate, - accent = Color(0xFF6C8EA4), - primaryAction = PlaceholderAction("Spin trust", AppRoute.spinWheel("trust")), - secondaryAction = PlaceholderAction("Question packs", AppRoute.QUESTION_PACKS), - 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" - ) + val state by viewModel.uiState.collectAsState() + + CategoryPickerContent( + state = state, + onCategorySelected = { item -> + if (item.isLocked) onNavigate(AppRoute.PAYWALL) + else onNavigate(AppRoute.spinWheel(item.category.id)) + }, + onRetry = viewModel::load ) } +@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 @Composable 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 = {} + ) } diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerViewModel.kt new file mode 100644 index 00000000..48ff5b8e --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/CategoryPickerViewModel.kt @@ -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 = emptyList() +) + +@HiltViewModel +class CategoryPickerViewModel @Inject constructor( + private val repository: QuestionRepository, + private val entitlementChecker: EntitlementChecker +) : ViewModel() { + + private val _uiState = MutableStateFlow(CategoryPickerUiState()) + val uiState: StateFlow = _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." + ) + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/LocalWheelSessionStore.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/LocalWheelSessionStore.kt new file mode 100644 index 00000000..f580ff4f --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/LocalWheelSessionStore.kt @@ -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 +) + +@Singleton +class LocalWheelSessionStore @Inject constructor() { + var activeSession: LocalWheelSession? = null + var lastAnswered: Int = 0 + var lastTotal: Int = 0 +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelScreen.kt index 5ccb095a..f6c63f8f 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelScreen.kt @@ -1,37 +1,220 @@ 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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.couplesconnect.app.core.navigation.AppRoute -import com.couplesconnect.app.ui.components.PlaceholderAction -import com.couplesconnect.app.ui.components.PlaceholderScreen +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel @Composable fun SpinWheelScreen( categoryId: String, - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: SpinWheelViewModel = hiltViewModel() ) { - PlaceholderScreen( - title = "Let the prompt find you", - section = "Wheel", - description = "A playful selection surface for turning a chosen category into a short question session.", - route = AppRoute.spinWheel(categoryId), - onNavigate = onNavigate, - accent = Color(0xFFF2A65A), - primaryAction = PlaceholderAction("Start session", AppRoute.wheelSession("session-preview")), - secondaryAction = PlaceholderAction("Categories", AppRoute.CATEGORY_PICKER), - chips = listOf("Category $categoryId", "Motion", "Session"), - details = listOf( - "Wheel animation has room to become tactile", - "The chosen category stays visible", - "Session start feels like one continuous step" - ) + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { + onNavigate(it) + viewModel.onNavigated() + } + } + + SpinWheelContent( + state = state, + onSpin = viewModel::spin, + onStart = viewModel::startSession ) } +@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 @Composable fun SpinWheelScreenPreview() { - SpinWheelScreen(categoryId = "trust") + SpinWheelContent( + state = SpinWheelUiState(isLoading = false, categoryName = "Trust"), + onSpin = {}, + onStart = {} + ) } diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelViewModel.kt new file mode 100644 index 00000000..5ec3a08f --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/SpinWheelViewModel.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelCompleteScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelCompleteScreen.kt index c1f08baf..bbb0b431 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelCompleteScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelCompleteScreen.kt @@ -1,37 +1,176 @@ package com.couplesconnect.app.ui.wheel -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.ViewModel import com.couplesconnect.app.core.navigation.AppRoute -import com.couplesconnect.app.ui.components.PlaceholderAction -import com.couplesconnect.app.ui.components.PlaceholderScreen +import dagger.hilt.android.lifecycle.HiltViewModel +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 fun WheelCompleteScreen( sessionId: String, - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: WheelCompleteViewModel = hiltViewModel() ) { - PlaceholderScreen( - title = "Close the loop", - section = "Wheel", - description = "A completion surface for celebrating the ritual and offering the next gentle step.", - route = AppRoute.wheelComplete(sessionId), - onNavigate = onNavigate, - 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" - ) + WheelCompleteContent( + categoryName = viewModel.categoryName, + answered = viewModel.answered, + total = viewModel.total, + onHome = { onNavigate(AppRoute.HOME) }, + onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) } ) } +@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 @Composable fun WheelCompleteScreenPreview() { - WheelCompleteScreen(sessionId = "session-preview") + WheelCompleteContent( + categoryName = "Trust", + answered = 8, + total = 10, + onHome = {}, + onSpinAgain = {} + ) } diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionScreen.kt index ea91fd2a..9061b0a7 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionScreen.kt @@ -1,37 +1,233 @@ 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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.couplesconnect.app.core.navigation.AppRoute -import com.couplesconnect.app.ui.components.PlaceholderAction -import com.couplesconnect.app.ui.components.PlaceholderScreen +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.couplesconnect.app.domain.model.Question @Composable fun WheelSessionScreen( sessionId: String, - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: WheelSessionViewModel = hiltViewModel() ) { - PlaceholderScreen( - title = "Stay with the question", - section = "Wheel", - description = "A lightweight session space for a chosen prompt, timer, partner state, and completion moment.", - route = AppRoute.wheelSession(sessionId), - onNavigate = onNavigate, - accent = Color(0xFFE07A5F), - primaryAction = PlaceholderAction("Complete", AppRoute.wheelComplete(sessionId)), - secondaryAction = PlaceholderAction("Home", AppRoute.HOME), - chips = listOf("Session $sessionId", "Prompt flow", "Finish path"), - details = listOf( - "Session state can stay calm and readable", - "Completion keeps continuity with the same moment", - "The flow can return home at any point" - ) + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { + onNavigate(it) + viewModel.onNavigated() + } + } + + WheelSessionContent( + state = state, + onNext = viewModel::next, + onSkip = viewModel::skip, + 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 @Composable 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 = {} + ) } diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionViewModel.kt new file mode 100644 index 00000000..a0c40ab5 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelSessionViewModel.kt @@ -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 = 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 = _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) } + } +}