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.CategoryPickerScreen
|
||||||
import com.couplesconnect.app.ui.wheel.SpinWheelScreen
|
import com.couplesconnect.app.ui.wheel.SpinWheelScreen
|
||||||
import com.couplesconnect.app.ui.wheel.WheelCompleteScreen
|
import com.couplesconnect.app.ui.wheel.WheelCompleteScreen
|
||||||
|
import com.couplesconnect.app.ui.wheel.WheelHistoryScreen
|
||||||
import com.couplesconnect.app.ui.wheel.WheelSessionScreen
|
import com.couplesconnect.app.ui.wheel.WheelSessionScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -222,6 +223,9 @@ fun AppNavigation(
|
||||||
onNavigate = navController::navigate
|
onNavigate = navController::navigate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(route = AppRoute.WHEEL_HISTORY) {
|
||||||
|
WheelHistoryScreen(onNavigate = navController::navigate)
|
||||||
|
}
|
||||||
|
|
||||||
// Paywall
|
// Paywall
|
||||||
composable(route = AppRoute.PAYWALL) {
|
composable(route = AppRoute.PAYWALL) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ object AppRoute {
|
||||||
const val SUBSCRIPTION = "subscription"
|
const val SUBSCRIPTION = "subscription"
|
||||||
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
const val RELATIONSHIP_SETTINGS = "relationship_settings"
|
||||||
const val DELETE_ACCOUNT = "delete_account"
|
const val DELETE_ACCOUNT = "delete_account"
|
||||||
|
const val WHEEL_HISTORY = "wheel_history"
|
||||||
|
|
||||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
@ -73,7 +74,8 @@ object AppRoute {
|
||||||
Definition(PRIVACY, "Privacy", "settings"),
|
Definition(PRIVACY, "Privacy", "settings"),
|
||||||
Definition(SUBSCRIPTION, "Subscription", "settings"),
|
Definition(SUBSCRIPTION, "Subscription", "settings"),
|
||||||
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "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()}"
|
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.core.billing.FakeEntitlementChecker
|
||||||
import com.couplesconnect.app.data.local.SettingsDataStore
|
import com.couplesconnect.app.data.local.SettingsDataStore
|
||||||
import com.couplesconnect.app.data.repository.CoupleRepositoryImpl
|
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.FirebaseAuthRepositoryImpl
|
||||||
import com.couplesconnect.app.data.repository.InviteRepositoryImpl
|
import com.couplesconnect.app.data.repository.InviteRepositoryImpl
|
||||||
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
|
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.data.repository.UserRepositoryImpl
|
||||||
import com.couplesconnect.app.domain.repository.AuthRepository
|
import com.couplesconnect.app.domain.repository.AuthRepository
|
||||||
import com.couplesconnect.app.domain.repository.CoupleRepository
|
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.InviteRepository
|
||||||
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
|
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
|
||||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||||
|
|
@ -54,4 +56,7 @@ abstract class RepositoryModule {
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -35,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.couplesconnect.app.core.navigation.AppRoute
|
import com.couplesconnect.app.core.navigation.AppRoute
|
||||||
import com.couplesconnect.app.domain.model.LocalAnswer
|
import com.couplesconnect.app.domain.model.LocalAnswer
|
||||||
|
import com.couplesconnect.app.ui.components.EmptyState
|
||||||
import com.couplesconnect.app.ui.questions.displayCategoryName
|
import com.couplesconnect.app.ui.questions.displayCategoryName
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -98,7 +97,12 @@ private fun AnswerHistoryContent(
|
||||||
|
|
||||||
if (state.answers.isEmpty()) {
|
if (state.answers.isEmpty()) {
|
||||||
item {
|
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 {
|
} else {
|
||||||
items(state.answers, key = { it.questionId }) { answer ->
|
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
|
@Composable
|
||||||
private fun AnswerHistoryCard(
|
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.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
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.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
@ -46,6 +48,8 @@ private val BENEFITS = listOf(
|
||||||
fun PaywallScreen(
|
fun PaywallScreen(
|
||||||
onNavigate: (String) -> Unit = {}
|
onNavigate: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -161,6 +165,21 @@ fun PaywallScreen(
|
||||||
color = Color(0xFF9E9693)
|
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.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
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.ChoiceAnswerConfigImpl
|
||||||
import com.couplesconnect.app.domain.model.Question
|
import com.couplesconnect.app.domain.model.Question
|
||||||
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfigImpl
|
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.QuestionAnswerInput
|
||||||
import com.couplesconnect.app.ui.questions.components.QuestionHeader
|
import com.couplesconnect.app.ui.questions.components.QuestionHeader
|
||||||
|
|
||||||
|
|
@ -87,14 +89,15 @@ fun LocalQuestionContent(
|
||||||
LocalQuestionHeader(title = title, subtitle = subtitle)
|
LocalQuestionHeader(title = title, subtitle = subtitle)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.isLoading -> LoadingCard()
|
state.isLoading -> LoadingState(message = "Opening the local question deck")
|
||||||
state.error != null -> MessageCard(
|
state.error != null -> ErrorState(
|
||||||
title = "Question paused",
|
title = "Question paused",
|
||||||
message = state.error
|
message = state.error,
|
||||||
|
onRetry = onRefresh
|
||||||
)
|
)
|
||||||
state.question == null -> MessageCard(
|
state.question == null -> EmptyState(
|
||||||
title = "No local question found",
|
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 -> {
|
else -> {
|
||||||
val question = state.question
|
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
|
@Composable
|
||||||
private fun QuestionMetaRow(question: Question) {
|
private fun QuestionMetaRow(question: Question) {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,152 @@
|
||||||
package com.couplesconnect.app.ui.settings
|
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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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 androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.couplesconnect.app.core.navigation.AppRoute
|
import androidx.compose.ui.unit.dp
|
||||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
import com.couplesconnect.app.core.navigation.ExternalLinks
|
||||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacyScreen(
|
fun PrivacyScreen(
|
||||||
onNavigate: (String) -> Unit = {}
|
onNavigate: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
PlaceholderScreen(
|
val uriHandler = LocalUriHandler.current
|
||||||
title = "Keep trust visible",
|
|
||||||
section = "Settings",
|
Scaffold(
|
||||||
description = "A privacy surface for answer visibility, data boundaries, and couple safety preferences.",
|
topBar = {
|
||||||
route = AppRoute.PRIVACY,
|
TopAppBar(
|
||||||
onNavigate = onNavigate,
|
title = { Text("Privacy & Terms") },
|
||||||
accent = Color(0xFF81B29A),
|
navigationIcon = {
|
||||||
primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION),
|
IconButton(onClick = { onNavigate("back") }) {
|
||||||
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
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"
|
) { 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
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
package com.couplesconnect.app.ui.wheel
|
package com.couplesconnect.app.ui.wheel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.couplesconnect.app.core.navigation.AppRoute
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -36,11 +42,36 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class WheelCompleteViewModel @Inject constructor(
|
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() {
|
) : ViewModel() {
|
||||||
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
|
val categoryName: String = sessionStore.activeSession?.categoryName ?: ""
|
||||||
val answered: Int = sessionStore.lastAnswered
|
val answered: Int = sessionStore.lastAnswered
|
||||||
val total: Int = sessionStore.lastTotal
|
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
|
@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