feat(ui): wheel history screen, session repo, external links, shared loading/error/empty components

This commit is contained in:
null 2026-06-16 01:24:04 -05:00
parent bee617c493
commit 7a9d4c3b49
16 changed files with 740 additions and 117 deletions

View File

@ -50,6 +50,7 @@ import com.couplesconnect.app.ui.settings.SubscriptionScreen
import com.couplesconnect.app.ui.wheel.CategoryPickerScreen
import com.couplesconnect.app.ui.wheel.SpinWheelScreen
import com.couplesconnect.app.ui.wheel.WheelCompleteScreen
import com.couplesconnect.app.ui.wheel.WheelHistoryScreen
import com.couplesconnect.app.ui.wheel.WheelSessionScreen
@Composable
@ -222,6 +223,9 @@ fun AppNavigation(
onNavigate = navController::navigate
)
}
composable(route = AppRoute.WHEEL_HISTORY) {
WheelHistoryScreen(onNavigate = navController::navigate)
}
// Paywall
composable(route = AppRoute.PAYWALL) {

View File

@ -32,6 +32,7 @@ object AppRoute {
const val SUBSCRIPTION = "subscription"
const val RELATIONSHIP_SETTINGS = "relationship_settings"
const val DELETE_ACCOUNT = "delete_account"
const val WHEEL_HISTORY = "wheel_history"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
@ -73,7 +74,8 @@ object AppRoute {
Definition(PRIVACY, "Privacy", "settings"),
Definition(SUBSCRIPTION, "Subscription", "settings"),
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
Definition(DELETE_ACCOUNT, "Delete Account", "settings")
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel")
)
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"

View File

@ -0,0 +1,8 @@
package com.couplesconnect.app.core.navigation
object ExternalLinks {
const val PRIVACY_POLICY = "https://couplesconnect.app/privacy"
const val TERMS_OF_SERVICE = "https://couplesconnect.app/terms"
const val SUBSCRIPTION_TERMS = "https://couplesconnect.app/subscription-terms"
const val SUPPORT = "https://couplesconnect.app/support"
}

View File

@ -0,0 +1,69 @@
package com.couplesconnect.app.data.repository
import com.couplesconnect.app.domain.model.QuestionSession
import com.couplesconnect.app.domain.repository.QuestionSessionRepository
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class QuestionSessionRepositoryImpl @Inject constructor(
private val firestore: FirebaseFirestore
) : QuestionSessionRepository {
override suspend fun saveSession(session: QuestionSession): Result<Unit> = runCatching {
val doc = if (session.id.isBlank()) {
firestore.collection("couples")
.document(session.coupleId)
.collection("sessions")
.document()
} else {
firestore.collection("couples")
.document(session.coupleId)
.collection("sessions")
.document(session.id)
}
val data = mapOf(
"id" to doc.id,
"coupleId" to session.coupleId,
"categoryId" to session.categoryId,
"questionIds" to session.questionIds,
"startedByUserId" to session.startedByUserId,
"startedAt" to session.startedAt,
"completedAt" to session.completedAt,
"isPremium" to session.isPremium,
"status" to session.status
)
doc.set(data).await()
}
override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> =
runCatching {
firestore.collection("couples")
.document(coupleId)
.collection("sessions")
.orderBy("completedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.limit(50)
.get()
.await()
.documents
.mapNotNull { doc ->
runCatching {
QuestionSession(
id = doc.getString("id") ?: doc.id,
coupleId = doc.getString("coupleId") ?: coupleId,
categoryId = doc.getString("categoryId") ?: "",
questionIds = (doc.get("questionIds") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
startedByUserId = doc.getString("startedByUserId") ?: "",
startedAt = doc.getLong("startedAt") ?: 0L,
completedAt = doc.getLong("completedAt"),
isPremium = doc.getBoolean("isPremium") ?: false,
status = doc.getString("status") ?: "completed"
)
}.getOrNull()
}
}
}

View File

@ -4,6 +4,7 @@ import com.couplesconnect.app.core.billing.EntitlementChecker
import com.couplesconnect.app.core.billing.FakeEntitlementChecker
import com.couplesconnect.app.data.local.SettingsDataStore
import com.couplesconnect.app.data.repository.CoupleRepositoryImpl
import com.couplesconnect.app.data.repository.QuestionSessionRepositoryImpl
import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl
import com.couplesconnect.app.data.repository.InviteRepositoryImpl
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
@ -12,6 +13,7 @@ import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl
import com.couplesconnect.app.data.repository.UserRepositoryImpl
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.QuestionSessionRepository
import com.couplesconnect.app.domain.repository.InviteRepository
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
@ -54,4 +56,7 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
@Binds @Singleton
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
}

View File

@ -0,0 +1,8 @@
package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.QuestionSession
interface QuestionSessionRepository {
suspend fun saveSession(session: QuestionSession): Result<Unit>
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
}

View File

@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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
@ -35,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.ui.components.EmptyState
import com.couplesconnect.app.ui.questions.displayCategoryName
@Composable
@ -98,7 +97,12 @@ private fun AnswerHistoryContent(
if (state.answers.isEmpty()) {
item {
EmptyHistoryCard(onDailyQuestion = onDailyQuestion)
EmptyState(
title = "No answers saved yet",
body = "Answer a daily question or choose a prompt from a pack, and it will appear here.",
actionLabel = "Daily question",
onAction = onDailyQuestion
)
}
} else {
items(state.answers, key = { it.questionId }) { answer ->
@ -113,39 +117,6 @@ private fun AnswerHistoryContent(
}
}
@Composable
private fun EmptyHistoryCard(onDailyQuestion: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f))
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "No answers saved yet",
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
Text(
text = "Answer a daily question or choose a prompt from a pack, and it will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
Button(
onClick = onDailyQuestion,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Daily question")
}
}
}
}
@Composable
private fun AnswerHistoryCard(

View File

@ -0,0 +1,60 @@
package com.couplesconnect.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
title: String,
body: String,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f))
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
if (actionLabel != null && onAction != null) {
Button(
onClick = onAction,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text(actionLabel)
}
}
}
}
}

View File

@ -0,0 +1,58 @@
package com.couplesconnect.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun ErrorState(
title: String = "Something went wrong",
message: String,
retryLabel: String = "Try again",
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF4F2))
) {
Column(
modifier = Modifier.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF8B2E1A)
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5C1E0E)
)
if (onRetry != null) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp)
) {
Text(retryLabel)
}
}
}
}
}

View File

@ -0,0 +1,50 @@
package com.couplesconnect.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun LoadingState(
message: String = "Loading…",
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.8f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(34.dp),
color = Color(0xFFE07A5F)
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
textAlign = TextAlign.Center
)
}
}
}

View File

@ -26,6 +26,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalUriHandler
import com.couplesconnect.app.core.navigation.ExternalLinks
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
@ -46,6 +48,8 @@ private val BENEFITS = listOf(
fun PaywallScreen(
onNavigate: (String) -> Unit = {}
) {
val uriHandler = LocalUriHandler.current
Box(
modifier = Modifier
.fillMaxSize()
@ -161,6 +165,21 @@ fun PaywallScreen(
color = Color(0xFF9E9693)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) }) {
Text("Privacy", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9E9693))
}
TextButton(onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) }) {
Text("Terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9E9693))
}
TextButton(onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) }) {
Text("Subscription terms", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9E9693))
}
}
}
}
}

View File

@ -21,7 +21,6 @@ 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.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@ -44,6 +43,9 @@ import androidx.compose.ui.unit.dp
import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfigImpl
import com.couplesconnect.app.ui.components.EmptyState
import com.couplesconnect.app.ui.components.ErrorState
import com.couplesconnect.app.ui.components.LoadingState
import com.couplesconnect.app.ui.questions.components.QuestionAnswerInput
import com.couplesconnect.app.ui.questions.components.QuestionHeader
@ -87,14 +89,15 @@ fun LocalQuestionContent(
LocalQuestionHeader(title = title, subtitle = subtitle)
when {
state.isLoading -> LoadingCard()
state.error != null -> MessageCard(
state.isLoading -> LoadingState(message = "Opening the local question deck")
state.error != null -> ErrorState(
title = "Question paused",
message = state.error
message = state.error,
onRetry = onRefresh
)
state.question == null -> MessageCard(
state.question == null -> EmptyState(
title = "No local question found",
message = "The local question database is ready, but this path did not return a prompt."
body = "The local question database is ready, but this path did not return a prompt."
)
else -> {
val question = state.question
@ -192,61 +195,6 @@ private fun LocalQuestionHeader(
}
}
@Composable
private fun LoadingCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.8f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(34.dp),
color = Color(0xFFE07A5F)
)
Text(
text = "Opening the local question deck",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun MessageCard(
title: String,
message: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
) {
Column(
modifier = Modifier.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun QuestionMetaRow(question: Question) {

View File

@ -1,32 +1,152 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
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 com.couplesconnect.app.core.navigation.ExternalLinks
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Keep trust visible",
section = "Settings",
description = "A privacy surface for answer visibility, data boundaries, and couple safety preferences.",
route = AppRoute.PRIVACY,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Boundaries", "Visibility", "Safety"),
details = listOf(
"Privacy choices can be designed before persistence",
"Answer visibility rules can stay explicit",
"Subscription is nearby without mixing concerns"
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("Privacy & Terms") },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Your data stays between the two of you. These documents explain exactly what we collect, how we use it, and what rights you have.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column {
LegalLinkRow(
label = "Privacy Policy",
description = "How we handle your data",
onClick = { uriHandler.openUri(ExternalLinks.PRIVACY_POLICY) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow(
label = "Terms of Service",
description = "Your rights and our responsibilities",
onClick = { uriHandler.openUri(ExternalLinks.TERMS_OF_SERVICE) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow(
label = "Subscription Terms",
description = "Billing, renewals, and cancellations",
onClick = { uriHandler.openUri(ExternalLinks.SUBSCRIPTION_TERMS) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
LegalLinkRow(
label = "Support",
description = "Get help or contact us",
onClick = { uriHandler.openUri(ExternalLinks.SUPPORT) }
)
}
}
Text(
text = "Answer text and messages are private by design and are never shared with third parties.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
}
@Composable
private fun LegalLinkRow(
label: String,
description: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF81B29A)
)
)
}
}
@Preview

View File

@ -1,8 +1,14 @@
package com.couplesconnect.app.ui.wheel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionSession
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.QuestionSessionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -36,11 +42,36 @@ import androidx.hilt.navigation.compose.hiltViewModel
@HiltViewModel
class WheelCompleteViewModel @Inject constructor(
private val sessionStore: LocalWheelSessionStore
private val sessionStore: LocalWheelSessionStore,
private val sessionRepository: QuestionSessionRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository
) : ViewModel() {
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
val answered: Int = sessionStore.lastAnswered
val total: Int = sessionStore.lastTotal
init {
saveSession()
}
private fun saveSession() {
val session = sessionStore.activeSession ?: return
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch
sessionRepository.saveSession(
QuestionSession(
coupleId = couple.id,
categoryId = session.categoryId,
questionIds = session.questions.map { it.id },
startedByUserId = uid,
completedAt = System.currentTimeMillis(),
status = "completed"
)
)
}
}
}
@Composable

View File

@ -0,0 +1,212 @@
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.automirrored.filled.ArrowBack
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionSession
import com.couplesconnect.app.ui.components.EmptyState
import com.couplesconnect.app.ui.components.ErrorState
import com.couplesconnect.app.ui.components.LoadingState
import com.couplesconnect.app.ui.questions.displayCategoryName
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WheelHistoryScreen(
onNavigate: (String) -> Unit = {},
viewModel: WheelHistoryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Session History") },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF0F5F2), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(padding)
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Text(
text = "Spin sessions",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F),
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp)
)
}
when {
!state.hasPremium -> item {
WheelHistoryLockedCard(onUnlock = { onNavigate(AppRoute.PAYWALL) })
}
state.isLoading -> item { LoadingState(message = "Loading your sessions…") }
state.error != null -> item {
ErrorState(
message = state.error!!,
onRetry = viewModel::load
)
}
state.sessions.isEmpty() -> item {
EmptyState(
title = "No sessions yet",
body = "Completed spin wheel sessions will appear here.",
actionLabel = "Spin now",
onAction = { onNavigate(AppRoute.CATEGORY_PICKER) }
)
}
else -> items(state.sessions, key = { it.id }) { session ->
WheelSessionCard(session = session)
}
}
}
}
}
}
@Composable
private fun WheelSessionCard(session: QuestionSession) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = session.categoryId.displayCategoryName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Text(
text = "${session.questionIds.size} questions",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF4E4642)
)
}
session.completedAt?.let { ts ->
Text(
text = SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(ts)),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF81B29A)
)
}
}
}
}
@Composable
private fun WheelHistoryLockedCard(onUnlock: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f)),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
shape = RoundedCornerShape(50.dp),
color = Color(0xFFF0F5F2)
) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.padding(16.dp).size(28.dp),
tint = Color(0xFF81B29A)
)
}
Text(
text = "History is a premium feature",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Text(
text = "Unlock to browse all your past spin wheel sessions together.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
Button(
onClick = onUnlock,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Unlock premium", color = Color.White)
}
}
}
}

View File

@ -0,0 +1,58 @@
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.QuestionSession
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.QuestionSessionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class WheelHistoryUiState(
val isLoading: Boolean = false,
val sessions: List<QuestionSession> = emptyList(),
val hasPremium: Boolean = false,
val error: String? = null
)
@HiltViewModel
class WheelHistoryViewModel @Inject constructor(
private val sessionRepository: QuestionSessionRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val entitlementChecker: EntitlementChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(WheelHistoryUiState(hasPremium = entitlementChecker.hasPremium))
val uiState: StateFlow<WheelHistoryUiState> = _uiState.asStateFlow()
init {
if (entitlementChecker.hasPremium) load()
}
fun load() {
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val couple = coupleRepository.getCoupleForUser(uid)
if (couple == null) {
_uiState.update { it.copy(isLoading = false) }
return@launch
}
sessionRepository.getSessionsForCouple(couple.id)
.onSuccess { sessions ->
_uiState.update { it.copy(isLoading = false, sessions = sessions) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Failed to load history") }
}
}
}
}