feat(ui): wheel history screen, session repo, external links, shared loading/error/empty components
This commit is contained in:
parent
bee617c493
commit
7a9d4c3b49
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue