From 7a9d4c3b495b05dece94826f5a68899fa10822cf Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 01:24:04 -0500 Subject: [PATCH] feat(ui): wheel history screen, session repo, external links, shared loading/error/empty components --- .../app/core/navigation/AppNavigation.kt | 4 + .../app/core/navigation/AppRoute.kt | 4 +- .../app/core/navigation/ExternalLinks.kt | 8 + .../QuestionSessionRepositoryImpl.kt | 69 ++++++ .../couplesconnect/app/di/RepositoryModule.kt | 5 + .../repository/QuestionSessionRepository.kt | 8 + .../app/ui/answers/AnswerHistoryScreen.kt | 43 +--- .../app/ui/components/EmptyState.kt | 60 +++++ .../app/ui/components/ErrorState.kt | 58 +++++ .../app/ui/components/LoadingState.kt | 50 +++++ .../app/ui/paywall/PaywallScreen.kt | 19 ++ .../app/ui/questions/LocalQuestionContent.kt | 70 +----- .../app/ui/settings/PrivacyScreen.kt | 156 +++++++++++-- .../app/ui/wheel/WheelCompleteScreen.kt | 33 ++- .../app/ui/wheel/WheelHistoryScreen.kt | 212 ++++++++++++++++++ .../app/ui/wheel/WheelHistoryViewModel.kt | 58 +++++ 16 files changed, 740 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/couplesconnect/app/core/navigation/ExternalLinks.kt create mode 100644 app/src/main/java/com/couplesconnect/app/data/repository/QuestionSessionRepositoryImpl.kt create mode 100644 app/src/main/java/com/couplesconnect/app/domain/repository/QuestionSessionRepository.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/components/EmptyState.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/components/ErrorState.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/components/LoadingState.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryScreen.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryViewModel.kt diff --git a/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt b/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt index 71988709..9dfb0229 100644 --- a/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt +++ b/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt @@ -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) { diff --git a/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt b/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt index d6f638e3..5af15d44 100644 --- a/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt +++ b/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt @@ -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()}" diff --git a/app/src/main/java/com/couplesconnect/app/core/navigation/ExternalLinks.kt b/app/src/main/java/com/couplesconnect/app/core/navigation/ExternalLinks.kt new file mode 100644 index 00000000..d91b1f5e --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/navigation/ExternalLinks.kt @@ -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" +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/QuestionSessionRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/QuestionSessionRepositoryImpl.kt new file mode 100644 index 00000000..270c3bc0 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/repository/QuestionSessionRepositoryImpl.kt @@ -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 = 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> = + 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() ?: 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() + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt index 989dc2e8..83240531 100644 --- a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/QuestionSessionRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/QuestionSessionRepository.kt new file mode 100644 index 00000000..4df77747 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/repository/QuestionSessionRepository.kt @@ -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 + suspend fun getSessionsForCouple(coupleId: String): Result> +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/answers/AnswerHistoryScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/answers/AnswerHistoryScreen.kt index 901b844e..716c518b 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/answers/AnswerHistoryScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/answers/AnswerHistoryScreen.kt @@ -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( diff --git a/app/src/main/java/com/couplesconnect/app/ui/components/EmptyState.kt b/app/src/main/java/com/couplesconnect/app/ui/components/EmptyState.kt new file mode 100644 index 00000000..7e37e6dd --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/components/EmptyState.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/components/ErrorState.kt b/app/src/main/java/com/couplesconnect/app/ui/components/ErrorState.kt new file mode 100644 index 00000000..dbfdc5d1 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/components/ErrorState.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/components/LoadingState.kt b/app/src/main/java/com/couplesconnect/app/ui/components/LoadingState.kt new file mode 100644 index 00000000..3815a314 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/components/LoadingState.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt index 8cf384b3..7bf1644b 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/paywall/PaywallScreen.kt @@ -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)) + } + } } } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/questions/LocalQuestionContent.kt b/app/src/main/java/com/couplesconnect/app/ui/questions/LocalQuestionContent.kt index 0011242a..b1130da4 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/questions/LocalQuestionContent.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/questions/LocalQuestionContent.kt @@ -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) { diff --git a/app/src/main/java/com/couplesconnect/app/ui/settings/PrivacyScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/settings/PrivacyScreen.kt index 7d21cbcd..3dd87c43 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/settings/PrivacyScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/settings/PrivacyScreen.kt @@ -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 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 bbb0b431..c2c16a84 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,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 diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryScreen.kt new file mode 100644 index 00000000..6ed9bc15 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryViewModel.kt new file mode 100644 index 00000000..532ef9b8 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/wheel/WheelHistoryViewModel.kt @@ -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 = 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 = _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") } + } + } + } +}