feat(ui): navigation refactor, screen wiring, local answer persistence

- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
This commit is contained in:
null 2026-06-15 23:48:55 -05:00
parent 5991acb283
commit af7603d61c
57 changed files with 4086 additions and 1342 deletions

View File

@ -8,7 +8,9 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.CouplesConnect"
android:supportsRtl="true">

View File

@ -17,7 +17,12 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
CouplesConnectTheme {
AppNavigation()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation()
}
}
}
}

View File

@ -1,16 +1,33 @@
package com.couplesconnect.app.core.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.couplesconnect.app.ui.auth.ForgotPasswordScreen
import com.couplesconnect.app.ui.answers.AnswerHistoryScreen
import com.couplesconnect.app.ui.answers.AnswerRevealScreen
import com.couplesconnect.app.ui.auth.LoginScreen
import com.couplesconnect.app.ui.auth.SignUpScreen
import com.couplesconnect.app.ui.home.HomeScreen
import com.couplesconnect.app.ui.home.PartnerHomeScreen
import com.couplesconnect.app.ui.onboarding.CreateProfileScreen
import com.couplesconnect.app.ui.onboarding.OnboardingScreen
import com.couplesconnect.app.ui.pairing.AcceptInviteScreen
@ -19,8 +36,15 @@ import com.couplesconnect.app.ui.pairing.EmailInviteScreen
import com.couplesconnect.app.ui.pairing.InviteConfirmScreen
import com.couplesconnect.app.ui.paywall.PaywallScreen
import com.couplesconnect.app.ui.questions.DailyQuestionScreen
import com.couplesconnect.app.ui.questions.QuestionCategoryScreen
import com.couplesconnect.app.ui.questions.QuestionComposerScreen
import com.couplesconnect.app.ui.questions.QuestionPackLibraryScreen
import com.couplesconnect.app.ui.questions.QuestionThreadScreen
import com.couplesconnect.app.ui.settings.AccountScreen
import com.couplesconnect.app.ui.settings.NotificationSettingsScreen
import com.couplesconnect.app.ui.settings.PrivacyScreen
import com.couplesconnect.app.ui.settings.SettingsScreen
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
@ -32,132 +56,228 @@ fun AppNavigation(
startDestination: String = AppRoute.ONBOARDING
) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier
) {
// Onboarding
composable(route = AppRoute.ONBOARDING) {
OnboardingScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.CREATE_PROFILE) {
CreateProfileScreen(onNavigate = navController::navigate)
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomRoutes = topLevelRoutes.map { it.route }.toSet()
// Auth
composable(route = AppRoute.LOGIN) {
LoginScreen(onNavigate = navController::navigate)
Scaffold(
modifier = modifier,
bottomBar = {
if (currentRoute in bottomRoutes) {
AppBottomNavigation(
currentRoute = currentRoute,
onRouteSelected = { route ->
navController.navigate(route) {
launchSingleTop = true
}
}
)
}
}
// Home
composable(route = AppRoute.HOME) {
HomeScreen(onNavigate = navController::navigate)
}
// Daily Question
composable(route = AppRoute.DAILY_QUESTION) {
DailyQuestionScreen(onNavigate = navController::navigate)
}
// Question Thread — full answer/reveal/discussion experience
composable(
route = AppRoute.QUESTION_THREAD,
arguments = listOf(
navArgument("coupleId") { type = NavType.StringType },
navArgument("questionId") { type = NavType.StringType },
navArgument("prevId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("nextId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { padding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(padding)
) {
QuestionThreadScreen(
onNavigate = navController::navigate,
onBack = { navController.popBackStack() }
)
}
// Onboarding
composable(route = AppRoute.ONBOARDING) {
OnboardingScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.CREATE_PROFILE) {
CreateProfileScreen(onNavigate = navController::navigate)
}
// Answers
composable(
route = AppRoute.ANSWER_REVEAL,
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
) {
AnswerRevealScreen(
questionId = it.arguments?.getString("questionId") ?: "",
onNavigate = navController::navigate
)
}
composable(route = AppRoute.ANSWER_HISTORY) {
AnswerHistoryScreen(onNavigate = navController::navigate)
}
// Auth
composable(route = AppRoute.LOGIN) {
LoginScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.SIGN_UP) {
SignUpScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.FORGOT_PASSWORD) {
ForgotPasswordScreen(onNavigate = navController::navigate)
}
// Pairing
composable(route = AppRoute.CREATE_INVITE) {
CreateInviteScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.EMAIL_INVITE) {
EmailInviteScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.ACCEPT_INVITE) {
AcceptInviteScreen(onNavigate = navController::navigate)
}
composable(
route = AppRoute.INVITE_CONFIRM,
arguments = listOf(navArgument("inviteCode") { type = NavType.StringType })
) {
InviteConfirmScreen(
inviteCode = it.arguments?.getString("inviteCode") ?: "",
onNavigate = navController::navigate
)
}
// Home
composable(route = AppRoute.HOME) {
HomeScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.PARTNER_HOME) {
PartnerHomeScreen(onNavigate = navController::navigate)
}
// Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) {
CategoryPickerScreen(onNavigate = navController::navigate)
}
composable(
route = AppRoute.SPIN_WHEEL,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) {
SpinWheelScreen(
categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navController::navigate
)
}
composable(
route = AppRoute.WHEEL_SESSION,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelSessionScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navController::navigate
)
}
composable(
route = AppRoute.WHEEL_COMPLETE,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelCompleteScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navController::navigate
)
}
// Daily Question
composable(route = AppRoute.DAILY_QUESTION) {
DailyQuestionScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.QUESTION_PACKS) {
QuestionPackLibraryScreen(onNavigate = navController::navigate)
}
composable(
route = AppRoute.QUESTION_CATEGORY,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) {
QuestionCategoryScreen(
categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navController::navigate
)
}
composable(route = AppRoute.QUESTION_COMPOSER) {
QuestionComposerScreen(onNavigate = navController::navigate)
}
// Paywall
composable(route = AppRoute.PAYWALL) {
PaywallScreen(onNavigate = navController::navigate)
}
// Question Thread
composable(
route = AppRoute.QUESTION_THREAD,
arguments = listOf(
navArgument("coupleId") { type = NavType.StringType },
navArgument("questionId") { type = NavType.StringType },
navArgument("prevId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("nextId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) {
QuestionThreadScreen(
coupleId = it.arguments?.getString("coupleId") ?: "",
questionId = it.arguments?.getString("questionId") ?: "",
previousQuestionId = it.arguments?.getString("prevId"),
nextQuestionId = it.arguments?.getString("nextId"),
onNavigate = navController::navigate,
onBack = { navController.popBackStack() }
)
}
// Settings
composable(route = AppRoute.SETTINGS) {
SettingsScreen(onNavigate = navController::navigate)
// Answers
composable(
route = AppRoute.ANSWER_REVEAL,
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
) {
AnswerRevealScreen(
questionId = it.arguments?.getString("questionId") ?: "",
onNavigate = navController::navigate
)
}
composable(route = AppRoute.ANSWER_HISTORY) {
AnswerHistoryScreen(onNavigate = navController::navigate)
}
// Pairing
composable(route = AppRoute.CREATE_INVITE) {
CreateInviteScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.EMAIL_INVITE) {
EmailInviteScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.ACCEPT_INVITE) {
AcceptInviteScreen(onNavigate = navController::navigate)
}
composable(
route = AppRoute.INVITE_CONFIRM,
arguments = listOf(navArgument("inviteCode") { type = NavType.StringType })
) {
InviteConfirmScreen(
inviteCode = it.arguments?.getString("inviteCode") ?: "",
onNavigate = navController::navigate
)
}
// Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) {
CategoryPickerScreen(onNavigate = navController::navigate)
}
composable(
route = AppRoute.SPIN_WHEEL,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) {
SpinWheelScreen(
categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navController::navigate
)
}
composable(
route = AppRoute.WHEEL_SESSION,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelSessionScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navController::navigate
)
}
composable(
route = AppRoute.WHEEL_COMPLETE,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelCompleteScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navController::navigate
)
}
// Paywall
composable(route = AppRoute.PAYWALL) {
PaywallScreen(onNavigate = navController::navigate)
}
// Settings
composable(route = AppRoute.SETTINGS) {
SettingsScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.ACCOUNT) {
AccountScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.NOTIFICATIONS) {
NotificationSettingsScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.PRIVACY) {
PrivacyScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.SUBSCRIPTION) {
SubscriptionScreen(onNavigate = navController::navigate)
}
}
}
}
private data class TopLevelRoute(
val route: String,
val label: String,
val icon: ImageVector
)
private val topLevelRoutes = listOf(
TopLevelRoute(AppRoute.HOME, "Home", Icons.Filled.Home),
TopLevelRoute(AppRoute.DAILY_QUESTION, "Today", Icons.Filled.Favorite),
TopLevelRoute(AppRoute.QUESTION_PACKS, "Packs", Icons.Filled.Star),
TopLevelRoute(AppRoute.ANSWER_HISTORY, "Answers", Icons.Filled.Favorite),
TopLevelRoute(AppRoute.SETTINGS, "Settings", Icons.Filled.Settings)
)
@Composable
private fun AppBottomNavigation(
currentRoute: String?,
onRouteSelected: (String) -> Unit
) {
NavigationBar {
topLevelRoutes.forEach { item ->
NavigationBarItem(
selected = currentRoute == item.route,
onClick = { onRouteSelected(item.route) },
icon = {
Icon(
imageVector = item.icon,
contentDescription = item.label
)
},
label = { Text(item.label) }
)
}
}
}

View File

@ -1,11 +1,19 @@
package com.couplesconnect.app.core.navigation
import android.net.Uri
object AppRoute {
const val ONBOARDING = "onboarding"
const val LOGIN = "login"
const val SIGN_UP = "sign_up"
const val FORGOT_PASSWORD = "forgot_password"
const val CREATE_PROFILE = "create_profile"
const val HOME = "home"
const val PARTNER_HOME = "partner_home"
const val DAILY_QUESTION = "daily_question"
const val QUESTION_PACKS = "question_packs"
const val QUESTION_CATEGORY = "question_category/{categoryId}"
const val QUESTION_COMPOSER = "question_composer"
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
const val ANSWER_HISTORY = "answer_history"
const val CREATE_INVITE = "create_invite"
@ -18,23 +26,78 @@ object AppRoute {
const val WHEEL_COMPLETE = "wheel_complete/{sessionId}"
const val PAYWALL = "paywall"
const val SETTINGS = "settings"
const val ACCOUNT = "account"
const val NOTIFICATIONS = "notifications"
const val PRIVACY = "privacy"
const val SUBSCRIPTION = "subscription"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
"question_thread/{coupleId}/{questionId}?prevId={prevId}&nextId={nextId}"
data class Definition(
val route: String,
val title: String,
val group: String
)
val definitions = listOf(
Definition(ONBOARDING, "Onboarding", "onboarding"),
Definition(CREATE_PROFILE, "Create Profile", "onboarding"),
Definition(LOGIN, "Login", "auth"),
Definition(SIGN_UP, "Sign Up", "auth"),
Definition(FORGOT_PASSWORD, "Forgot Password", "auth"),
Definition(HOME, "Home", "home"),
Definition(PARTNER_HOME, "Partner Home", "home"),
Definition(DAILY_QUESTION, "Daily Question", "questions"),
Definition(QUESTION_PACKS, "Question Packs", "questions"),
Definition(QUESTION_CATEGORY, "Question Category", "questions"),
Definition(QUESTION_COMPOSER, "Question Composer", "questions"),
Definition(QUESTION_THREAD, "Question Thread", "questions"),
Definition(ANSWER_REVEAL, "Answer Reveal", "answers"),
Definition(ANSWER_HISTORY, "Answer History", "answers"),
Definition(CREATE_INVITE, "Create Invite", "pairing"),
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
Definition(CATEGORY_PICKER, "Category Picker", "wheel"),
Definition(SPIN_WHEEL, "Spin Wheel", "wheel"),
Definition(WHEEL_SESSION, "Wheel Session", "wheel"),
Definition(WHEEL_COMPLETE, "Wheel Complete", "wheel"),
Definition(PAYWALL, "Paywall", "paywall"),
Definition(SETTINGS, "Settings", "settings"),
Definition(ACCOUNT, "Account", "settings"),
Definition(NOTIFICATIONS, "Notifications", "settings"),
Definition(PRIVACY, "Privacy", "settings"),
Definition(SUBSCRIPTION, "Subscription", "settings")
)
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
fun inviteConfirm(inviteCode: String): String = "invite_confirm/${inviteCode.asRouteArg()}"
fun questionCategory(categoryId: String): String = "question_category/${categoryId.asRouteArg()}"
fun spinWheel(categoryId: String): String = "spin_wheel/${categoryId.asRouteArg()}"
fun wheelSession(sessionId: String): String = "wheel_session/${sessionId.asRouteArg()}"
fun wheelComplete(sessionId: String): String = "wheel_complete/${sessionId.asRouteArg()}"
fun questionThread(
coupleId: String,
questionId: String,
prevId: String? = null,
nextId: String? = null
): String {
var route = "question_thread/$coupleId/$questionId"
var route = "question_thread/${coupleId.asRouteArg()}/${questionId.asRouteArg()}"
val params = buildList {
prevId?.let { add("prevId=$it") }
nextId?.let { add("nextId=$it") }
prevId?.let { add("prevId=${it.asRouteArg()}") }
nextId?.let { add("nextId=${it.asRouteArg()}") }
}
if (params.isNotEmpty()) route += "?" + params.joinToString("&")
return route
}
private fun String.asRouteArg(): String = Uri.encode(this)
}

View File

@ -9,7 +9,7 @@ import com.couplesconnect.app.data.local.entity.CategoryEntity
@Dao
interface CategoryDao {
@Query("SELECT * FROM question_category")
@Query("SELECT * FROM question_category ORDER BY display_name ASC")
suspend fun getAllCategories(): List<CategoryEntity>
@Query("SELECT * FROM question_category WHERE id = :id LIMIT 1")

View File

@ -6,23 +6,25 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.couplesconnect.app.data.local.entity.QuestionEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface QuestionDao {
@Query("SELECT * FROM question WHERE id = :id LIMIT 1")
suspend fun getQuestionById(id: String): QuestionEntity?
@Query("SELECT * FROM question ORDER BY RANDOM() LIMIT 1")
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestion(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = :categoryId")
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' ORDER BY depth_level ASC, id ASC")
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
@Query("SELECT * FROM question WHERE is_premium = 0")
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
suspend fun getQuestionCountByCategory(categoryId: String): Int
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
suspend fun getFreeQuestions(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE is_premium = 1")
@Query("SELECT * FROM question WHERE is_premium = 1 AND status = 'active'")
suspend fun getPremiumQuestions(): List<QuestionEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -1,10 +1,12 @@
package com.couplesconnect.app.data.local.mapper
import com.couplesconnect.app.data.local.entity.QuestionEntity
import com.couplesconnect.app.data.local.entity.CategoryEntity
import com.couplesconnect.app.domain.model.ChoiceAnswerConfig
import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl
import com.couplesconnect.app.domain.model.ChoiceOption
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.model.ScaleAnswerConfig
import com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfig
@ -30,6 +32,16 @@ fun QuestionEntity.toQuestion(): Question {
)
}
fun CategoryEntity.toQuestionCategory(): QuestionCategory {
return QuestionCategory(
id = id,
displayName = displayName,
description = description,
access = access,
iconName = iconName
)
}
private fun parseTags(raw: String): List<String> = try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }

View File

@ -1,16 +1,19 @@
package com.couplesconnect.app.data.repository
import com.couplesconnect.app.data.local.AppDatabase
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository
class FakeQuestionRepository : QuestionRepository {
override fun getDailyQuestion(): Question {
// Return a simple question as fallback - should be replaced with Room query
throw NotImplementedError("Use RoomQuestionRepository instead")
}
override suspend fun getDailyQuestion(): Question? = null
override fun getQuestionById(id: String): Question? {
throw NotImplementedError("Use RoomQuestionRepository instead")
}
override suspend fun getQuestionById(id: String): Question? = null
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> = emptyList()
override suspend fun getCategories(): List<QuestionCategory> = emptyList()
override suspend fun getCategoryById(id: String): QuestionCategory? = null
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
}

View File

@ -1,15 +1,41 @@
package com.couplesconnect.app.data.repository
import com.couplesconnect.app.data.local.AppDatabase
import com.couplesconnect.app.data.local.CategoryDao
import com.couplesconnect.app.data.local.QuestionDao
import com.couplesconnect.app.data.local.mapper.toQuestion
import com.couplesconnect.app.data.local.mapper.toQuestionCategory
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository
import javax.inject.Inject
import javax.inject.Singleton
class RoomQuestionRepository : QuestionRepository {
override fun getDailyQuestion(): Question {
throw NotImplementedError("Room queries need to be wrapped in coroutine scope")
@Singleton
class RoomQuestionRepository @Inject constructor(
private val questionDao: QuestionDao,
private val categoryDao: CategoryDao
) : QuestionRepository {
override suspend fun getDailyQuestion(): Question? {
return questionDao.getDailyQuestion()?.toQuestion()
}
override fun getQuestionById(id: String): Question? {
throw NotImplementedError("Room queries need to be wrapped in coroutine scope")
override suspend fun getQuestionById(id: String): Question? {
return questionDao.getQuestionById(id)?.toQuestion()
}
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> {
return questionDao.getQuestionsByCategory(categoryId).map { it.toQuestion() }
}
override suspend fun getCategories(): List<QuestionCategory> {
return categoryDao.getAllCategories().map { it.toQuestionCategory() }
}
override suspend fun getCategoryById(id: String): QuestionCategory? {
return categoryDao.getCategoryById(id)?.toQuestionCategory()
}
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
return questionDao.getQuestionCountByCategory(categoryId)
}
}

View File

@ -0,0 +1,120 @@
package com.couplesconnect.app.data.repository
import android.content.Context
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
@Singleton
class SharedPreferencesLocalAnswerRepository @Inject constructor(
@ApplicationContext context: Context
) : LocalAnswerRepository {
private val prefs = context.getSharedPreferences("local_answers", Context.MODE_PRIVATE)
private val answers = MutableStateFlow(readAnswers())
override fun observeAnswers(): Flow<List<LocalAnswer>> = answers
override fun observeAnswer(questionId: String): Flow<LocalAnswer?> {
return answers.map { list -> list.firstOrNull { it.questionId == questionId } }
}
override suspend fun getAnswer(questionId: String): LocalAnswer? {
return answers.value.firstOrNull { it.questionId == questionId }
}
override suspend fun saveAnswer(answer: LocalAnswer) {
val existing = answers.value.firstOrNull { it.questionId == answer.questionId }
val saved = answer.copy(
createdAt = existing?.createdAt ?: answer.createdAt,
updatedAt = System.currentTimeMillis(),
isRevealed = existing?.isRevealed ?: answer.isRevealed
)
val updated = answers.value
.filterNot { it.questionId == saved.questionId }
.plus(saved)
.sortedByDescending { it.updatedAt }
persist(updated)
}
override suspend fun markRevealed(questionId: String) {
val updated = answers.value.map { answer ->
if (answer.questionId == questionId) {
answer.copy(isRevealed = true, updatedAt = System.currentTimeMillis())
} else {
answer
}
}
persist(updated)
}
override suspend fun deleteAnswer(questionId: String) {
persist(answers.value.filterNot { it.questionId == questionId })
}
private fun readAnswers(): List<LocalAnswer> {
val raw = prefs.getString(KEY_ANSWERS, null) ?: return emptyList()
return runCatching {
val array = JSONArray(raw)
(0 until array.length()).mapNotNull { index ->
array.optJSONObject(index)?.toLocalAnswer()
}
}.getOrDefault(emptyList())
}
private fun persist(updated: List<LocalAnswer>) {
prefs.edit()
.putString(KEY_ANSWERS, JSONArray(updated.map { it.toJson() }).toString())
.apply()
answers.value = updated
}
private fun JSONObject.toLocalAnswer(): LocalAnswer {
return LocalAnswer(
questionId = optString("questionId"),
questionText = optString("questionText"),
category = optString("category"),
answerType = optString("answerType"),
writtenText = optString("writtenText").takeIf { it.isNotBlank() },
selectedOptionIds = optStringList("selectedOptionIds"),
selectedOptionTexts = optStringList("selectedOptionTexts"),
scaleValue = if (has("scaleValue") && !isNull("scaleValue")) optInt("scaleValue") else null,
createdAt = optLong("createdAt", System.currentTimeMillis()),
updatedAt = optLong("updatedAt", System.currentTimeMillis()),
isRevealed = optBoolean("isRevealed", false)
)
}
private fun LocalAnswer.toJson(): JSONObject {
return JSONObject()
.put("questionId", questionId)
.put("questionText", questionText)
.put("category", category)
.put("answerType", answerType)
.put("writtenText", writtenText)
.put("selectedOptionIds", JSONArray(selectedOptionIds))
.put("selectedOptionTexts", JSONArray(selectedOptionTexts))
.put("scaleValue", scaleValue)
.put("createdAt", createdAt)
.put("updatedAt", updatedAt)
.put("isRevealed", isRevealed)
}
private fun JSONObject.optStringList(key: String): List<String> {
val array = optJSONArray(key) ?: return emptyList()
return (0 until array.length()).mapNotNull { index ->
array.optString(index).takeIf { it.isNotBlank() }
}
}
private companion object {
const val KEY_ANSWERS = "answers"
}
}

View File

@ -1,8 +1,6 @@
package com.couplesconnect.app.di
import com.couplesconnect.app.data.local.AppDatabase
import com.couplesconnect.app.data.local.entity.QuestionEntity
import com.couplesconnect.app.data.local.entity.CategoryEntity
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,4 +29,8 @@ object DatabaseModule {
@Provides
@Singleton
fun provideQuestionDao(db: AppDatabase) = db.questionDao()
@Provides
@Singleton
fun provideCategoryDao(db: AppDatabase) = db.categoryDao()
}

View File

@ -1,12 +1,13 @@
package com.couplesconnect.app.di
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
import com.couplesconnect.app.data.repository.RoomQuestionRepository
import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import com.couplesconnect.app.domain.repository.QuestionThreadRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -21,19 +22,15 @@ abstract class RepositoryModule {
impl: QuestionThreadRepositoryImpl
): QuestionThreadRepository
companion object {
@Provides
@Singleton
fun provideQuestionRepository(): QuestionRepository {
return object : QuestionRepository {
override fun getDailyQuestion(): Question {
throw NotImplementedError("Use RoomQuestionRepository instead")
}
@Binds
@Singleton
abstract fun bindQuestionRepository(
impl: RoomQuestionRepository
): QuestionRepository
override fun getQuestionById(id: String): Question? {
throw NotImplementedError("Use RoomQuestionRepository instead")
}
}
}
}
@Binds
@Singleton
abstract fun bindLocalAnswerRepository(
impl: SharedPreferencesLocalAnswerRepository
): LocalAnswerRepository
}

View File

@ -0,0 +1,15 @@
package com.couplesconnect.app.domain.model
data class LocalAnswer(
val questionId: String,
val questionText: String,
val category: String,
val answerType: String,
val writtenText: String? = null,
val selectedOptionIds: List<String> = emptyList(),
val selectedOptionTexts: List<String> = emptyList(),
val scaleValue: Int? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val isRevealed: Boolean = false
)

View File

@ -0,0 +1,13 @@
package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.LocalAnswer
import kotlinx.coroutines.flow.Flow
interface LocalAnswerRepository {
fun observeAnswers(): Flow<List<LocalAnswer>>
fun observeAnswer(questionId: String): Flow<LocalAnswer?>
suspend fun getAnswer(questionId: String): LocalAnswer?
suspend fun saveAnswer(answer: LocalAnswer)
suspend fun markRevealed(questionId: String)
suspend fun deleteAnswer(questionId: String)
}

View File

@ -1,8 +1,13 @@
package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
interface QuestionRepository {
fun getDailyQuestion(): Question
fun getQuestionById(id: String): Question?
}
suspend fun getDailyQuestion(): Question?
suspend fun getQuestionById(id: String): Question?
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
suspend fun getCategories(): List<QuestionCategory>
suspend fun getCategoryById(id: String): QuestionCategory?
suspend fun getQuestionCountByCategory(categoryId: String): Int
}

View File

@ -1,42 +1,232 @@
package com.couplesconnect.app.ui.answers
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.material3.ExperimentalMaterial3Api
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
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.ui.questions.displayCategoryName
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnswerHistoryScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: AnswerHistoryViewModel = hiltViewModel()
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Answer History") }) }
) { padding ->
Box(
val state by viewModel.uiState.collectAsState()
AnswerHistoryContent(
state = state,
onAnswerSelected = { onNavigate(AppRoute.answerReveal(it.questionId)) },
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onDelete = viewModel::deleteAnswer
)
}
@Composable
private fun AnswerHistoryContent(
state: AnswerHistoryUiState,
onAnswerSelected: (LocalAnswer) -> Unit,
onDailyQuestion: () -> Unit,
onDelete: (String) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "What you have opened",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = "Saved local answers, including private drafts and revealed reflections.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
if (state.answers.isEmpty()) {
item {
EmptyHistoryCard(onDailyQuestion = onDailyQuestion)
}
} else {
items(state.answers, key = { it.questionId }) { answer ->
AnswerHistoryCard(
answer = answer,
onClick = { onAnswerSelected(answer) },
onDelete = { onDelete(answer.questionId) }
)
}
}
}
}
}
@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 = "Answer History — Coming Soon",
style = MaterialTheme.typography.headlineSmall
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(
answer: LocalAnswer,
onClick: () -> Unit,
onDelete: () -> Unit
) {
Card(
onClick = onClick,
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(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HistoryPill(if (answer.isRevealed) "Revealed" else "Private")
HistoryPill(answer.category.displayCategoryName())
}
Text(
text = answer.questionText,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = if (answer.isRevealed) answer.revealSummary() else "Saved privately. Tap to reveal.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
OutlinedButton(
onClick = onDelete,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp)
) {
Text("Remove local answer")
}
}
}
}
@Composable
private fun HistoryPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734)
)
}
}
@Preview
@Composable
fun AnswerHistoryScreenPreview() {
AnswerHistoryScreen()
AnswerHistoryContent(
state = AnswerHistoryUiState(
answers = listOf(
LocalAnswer(
questionId = "preview",
questionText = "What helped you feel close this week?",
category = "gratitude",
answerType = "written",
writtenText = "The quiet walk after dinner.",
isRevealed = true
)
)
),
onAnswerSelected = {},
onDailyQuestion = {},
onDelete = {}
)
}

View File

@ -0,0 +1,41 @@
package com.couplesconnect.app.ui.answers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AnswerHistoryUiState(
val answers: List<LocalAnswer> = emptyList()
)
@HiltViewModel
class AnswerHistoryViewModel @Inject constructor(
private val localAnswerRepository: LocalAnswerRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AnswerHistoryUiState())
val uiState: StateFlow<AnswerHistoryUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
_uiState.value = AnswerHistoryUiState(
answers = answers.sortedByDescending { it.updatedAt }
)
}
}
}
fun deleteAnswer(questionId: String) {
viewModelScope.launch {
localAnswerRepository.deleteAnswer(questionId)
}
}
}

View File

@ -1,43 +1,341 @@
package com.couplesconnect.app.ui.answers
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.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedButton
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.ui.questions.displayCategoryName
import com.couplesconnect.app.ui.questions.displayQuestionType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnswerRevealScreen(
questionId: String,
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: AnswerRevealViewModel = hiltViewModel()
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Answer Reveal") }) }
) { padding ->
Box(
val state by viewModel.uiState.collectAsState()
AnswerRevealContent(
state = state,
questionId = questionId,
onReveal = viewModel::revealAnswer,
onAnswerQuestion = {
onNavigate(AppRoute.questionThread("local-preview", questionId))
},
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) }
)
}
@Composable
private fun AnswerRevealContent(
state: AnswerRevealUiState,
questionId: String,
onReveal: () -> Unit,
onAnswerQuestion: () -> Unit,
onHistory: () -> Unit,
onHome: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Text(
text = "Answer Reveal — Coming Soon",
style = MaterialTheme.typography.headlineSmall
text = "Reveal together",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = "This is the local reveal state for a saved answer. Partner sync can land here later without changing the flow.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
when {
state.isLoading -> RevealMessageCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFE07A5F))
Text("Loading reveal")
}
}
state.error != null -> RevealMessageCard {
Text(
text = state.error,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
state.answer == null -> NoAnswerState(
question = state.question,
questionId = questionId,
onAnswerQuestion = onAnswerQuestion,
onHome = onHome
)
state.answer.isRevealed -> RevealedState(
answer = state.answer,
question = state.question,
onHistory = onHistory,
onHome = onHome
)
else -> ReadyToRevealState(
answer = state.answer,
question = state.question,
onReveal = onReveal,
onHistory = onHistory
)
}
}
}
}
@Composable
private fun NoAnswerState(
question: Question?,
questionId: String,
onAnswerQuestion: () -> Unit,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("No local answer yet")
Text(
text = question?.text ?: "Question $questionId is ready when you are.",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
Text(
text = "Answer this prompt first, then come back here for the reveal state.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onAnswerQuestion,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Answer")
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Home")
}
}
}
}
}
@Composable
private fun ReadyToRevealState(
answer: LocalAnswer,
question: Question?,
onReveal: () -> Unit,
onHistory: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Private answer saved")
Text(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
Text(
text = "Your answer is saved locally. Tap reveal when you want to open it.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onReveal,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Reveal")
}
OutlinedButton(
onClick = onHistory,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("History")
}
}
}
}
}
@Composable
private fun RevealedState(
answer: LocalAnswer,
question: Question?,
onHistory: () -> Unit,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RevealPill("Revealed locally")
RevealPill(answer.category.displayCategoryName())
RevealPill(answer.answerType.displayQuestionType())
}
Text(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color(0xFFFFF5F1)
) {
Text(
text = answer.revealSummary(),
modifier = Modifier.padding(18.dp),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF3E3734)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onHistory,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF81B29A))
) {
Text("History")
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Home")
}
}
}
}
}
@Composable
private fun RevealMessageCard(content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f)),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
Box(modifier = Modifier.padding(20.dp)) {
content()
}
}
}
@Composable
private fun RevealPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734)
)
}
}
fun LocalAnswer.revealSummary(): String {
return when (answerType) {
"written" -> writtenText.orEmpty()
"scale" -> "You chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}
}
@Preview
@Composable
fun AnswerRevealScreenPreview() {
AnswerRevealScreen(questionId = "test_question_id")
AnswerRevealContent(
state = AnswerRevealUiState(
isLoading = false,
answer = LocalAnswer(
questionId = "preview",
questionText = "What helped you feel close this week?",
category = "gratitude",
answerType = "written",
writtenText = "The quiet walk after dinner.",
isRevealed = true
)
),
questionId = "preview",
onReveal = {},
onAnswerQuestion = {},
onHistory = {},
onHome = {}
)
}

View File

@ -0,0 +1,73 @@
package com.couplesconnect.app.ui.answers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AnswerRevealUiState(
val isLoading: Boolean = true,
val error: String? = null,
val question: Question? = null,
val answer: LocalAnswer? = null
)
@HiltViewModel
class AnswerRevealViewModel @Inject constructor(
private val questionRepository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val questionId: String = savedStateHandle["questionId"] ?: ""
private val _uiState = MutableStateFlow(AnswerRevealUiState())
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
init {
load()
observeAnswer()
}
private fun load() {
viewModelScope.launch {
_uiState.value = AnswerRevealUiState(isLoading = true)
try {
_uiState.value = AnswerRevealUiState(
isLoading = false,
question = questionRepository.getQuestionById(questionId),
answer = localAnswerRepository.getAnswer(questionId)
)
} catch (e: Exception) {
_uiState.value = AnswerRevealUiState(
isLoading = false,
error = e.message ?: "Could not load this reveal."
)
}
}
}
private fun observeAnswer() {
viewModelScope.launch {
localAnswerRepository.observeAnswer(questionId).collect { answer ->
_uiState.update { it.copy(answer = answer) }
}
}
}
fun revealAnswer() {
viewModelScope.launch {
localAnswerRepository.markRevealed(questionId)
}
}
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.auth
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun ForgotPasswordScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Find your way back",
section = "Auth",
description = "A recovery surface for password reset and account access help once auth is enabled.",
route = AppRoute.FORGOT_PASSWORD,
onNavigate = onNavigate,
accent = Color(0xFFF2A65A),
primaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
secondaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
chips = listOf("Recovery", "Low stress", "Return path"),
details = listOf(
"Reset guidance can stay simple and reassuring",
"The user can return to login without friction",
"Recovery copy can stay calm and specific"
)
)
}
@Preview
@Composable
fun ForgotPasswordScreenPreview() {
ForgotPasswordScreen()
}

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Login") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Login — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Return to the room",
section = "Auth",
description = "A calm return point for partners who already have a place in the app.",
route = AppRoute.LOGIN,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
secondaryAction = PlaceholderAction("Reset access", AppRoute.FORGOT_PASSWORD),
chips = listOf("Returning", "Account", "Recovery"),
details = listOf(
"Email and provider choices have a natural home",
"Recovery stays close without feeling alarming",
"Onboarding can hand returning users here"
)
)
}
@Preview

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.auth
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun SignUpScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Make a quiet account",
section = "Auth",
description = "An account creation step with room for email, providers, and consent language.",
route = AppRoute.SIGN_UP,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
chips = listOf("Sign up", "Consent", "Profile next"),
details = listOf(
"Account creation can stay clear and low-pressure",
"Profile setup remains the next step",
"Returning users can move back to sign in"
)
)
}
@Preview
@Composable
fun SignUpScreenPreview() {
SignUpScreen()
}

View File

@ -0,0 +1,354 @@
package com.couplesconnect.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
data class PlaceholderAction(
val label: String,
val route: String
)
@Composable
fun PlaceholderScreen(
title: String,
section: String,
description: String,
route: String,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
accent: Color = Color(0xFFE07A5F),
primaryAction: PlaceholderAction? = null,
secondaryAction: PlaceholderAction? = null,
chips: List<String> = emptyList(),
details: List<String> = emptyList()
) {
val background = Brush.linearGradient(
colors = listOf(
Color(0xFFFFFBFA),
Color(0xFFF3F7F1),
Color(0xFFEAF0F4)
),
start = Offset.Zero,
end = Offset.Infinite
)
Box(
modifier = modifier
.fillMaxSize()
.background(background)
) {
DepthBackdrop(accent = accent)
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 22.dp, vertical = 18.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
PlaceholderHeader(
section = section,
title = title,
description = description,
accent = accent
)
if (chips.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
chips.forEach { chip ->
SignalChip(label = chip, accent = accent)
}
}
}
PreviewPanel(
title = "Screen shape",
accent = accent,
details = details.ifEmpty {
listOf(
"The main moment has a reserved place",
"The first pieces have room to breathe",
"The visual rhythm is ready"
)
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
primaryAction?.let { action ->
Button(
onClick = { onNavigate(action.route) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = accent,
contentColor = Color.White
),
shape = RoundedCornerShape(16.dp)
) {
Text(
text = action.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
secondaryAction?.let { action ->
OutlinedButton(
onClick = { onNavigate(action.route) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(16.dp)
) {
Text(
text = action.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}
}
@Composable
private fun PlaceholderHeader(
section: String,
title: String,
description: String,
accent: Color
) {
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SignalChip(label = section, accent = accent)
Text(
text = "Preview",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.54f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = title,
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.SemiBold
),
color = Color(0xFF27211F)
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
@Composable
private fun SignalChip(
label: String,
accent: Color
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color.White.copy(alpha = 0.72f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
modifier = Modifier.border(
width = 1.dp,
brush = Brush.horizontalGradient(
listOf(accent.copy(alpha = 0.42f), Color.White.copy(alpha = 0.2f))
),
shape = RoundedCornerShape(999.dp)
)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 13.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PreviewPanel(
title: String,
accent: Color,
details: List<String>
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = Color.White.copy(alpha = 0.78f),
tonalElevation = 0.dp,
shadowElevation = 18.dp
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF312B29),
fontWeight = FontWeight.SemiBold
)
Box(
modifier = Modifier
.clip(CircleShape)
.background(accent.copy(alpha = 0.16f))
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(
text = "First pass",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3E3734)
)
}
}
details.take(4).forEachIndexed { index, detail ->
DetailRow(
detail = detail,
accent = accent,
index = index
)
}
}
}
}
@Composable
private fun DetailRow(
detail: String,
accent: Color,
index: Int
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFFF9F6F2).copy(alpha = 0.86f))
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(12.dp))
.background(accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center
) {
Text(
text = (index + 1).toString().padStart(2, '0'),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF433B38),
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun DepthBackdrop(accent: Color) {
Canvas(modifier = Modifier.fillMaxSize()) {
rotate(degrees = -12f, pivot = Offset(size.width * 0.76f, size.height * 0.12f)) {
drawRoundRect(
brush = Brush.linearGradient(
listOf(accent.copy(alpha = 0.28f), Color(0xFF81B29A).copy(alpha = 0.14f))
),
topLeft = Offset(size.width * 0.44f, -size.height * 0.05f),
size = Size(size.width * 0.78f, size.height * 0.24f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(72.dp.toPx())
)
}
rotate(degrees = 9f, pivot = Offset(size.width * 0.18f, size.height * 0.78f)) {
drawRoundRect(
brush = Brush.linearGradient(
listOf(Color(0xFFF2CC8F).copy(alpha = 0.24f), Color.White.copy(alpha = 0.08f))
),
topLeft = Offset(-size.width * 0.22f, size.height * 0.64f),
size = Size(size.width * 0.74f, size.height * 0.22f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(56.dp.toPx())
)
}
}
}

View File

@ -1,42 +1,501 @@
package com.couplesconnect.app.ui.home
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.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedButton
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.ui.answers.revealSummary
import com.couplesconnect.app.ui.questions.displayCategoryName
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: HomeViewModel = hiltViewModel()
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Home") }) }
) { padding ->
Box(
val state by viewModel.uiState.collectAsState()
HomeContent(
state = state,
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onPacks = { onNavigate(AppRoute.QUESTION_PACKS) },
onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) },
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onSettings = { onNavigate(AppRoute.SETTINGS) },
onRefresh = viewModel::loadHome
)
}
@Composable
private fun HomeContent(
state: HomeUiState,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit,
onCategory: (String) -> Unit,
onHistory: () -> Unit,
onSettings: () -> Unit,
onRefresh: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
HomeHeader()
when {
state.isLoading -> LoadingHomeCard()
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
else -> {
DailyQuestionCard(
question = state.dailyQuestion,
onDailyQuestion = onDailyQuestion,
onPacks = onPacks
)
AnswerStatsRow(
stats = state.answerStats,
onHistory = onHistory
)
LatestAnswerCard(
latest = state.answerStats.latest,
onHistory = onHistory
)
CategoryPreviewGrid(
categories = state.categories,
onCategory = onCategory,
onPacks = onPacks
)
SettingsStrip(onSettings = onSettings)
}
}
}
}
}
@Composable
private fun HomeHeader() {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Tonight's connection",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = "A quiet home for todays prompt, saved reflections, and the next conversation worth opening.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
@Composable
private fun DailyQuestionCard(
question: Question?,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("Daily ritual")
question?.let { HomePill(it.category.displayCategoryName()) }
}
Text(
text = question?.text ?: "The local question deck is ready.",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onDailyQuestion,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Open")
}
OutlinedButton(
onClick = onPacks,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Packs")
}
}
}
}
}
@Composable
private fun AnswerStatsRow(
stats: HomeAnswerStats,
onHistory: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
label = "Saved",
value = stats.total.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
StatCard(
label = "Revealed",
value = stats.revealed.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
StatCard(
label = "Private",
value = stats.private.toString(),
modifier = Modifier.weight(1f),
onClick = onHistory
)
}
}
@Composable
private fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Home — Coming Soon",
style = MaterialTheme.typography.headlineSmall
text = value,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFFE07A5F)
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun LatestAnswerCard(
latest: LocalAnswer?,
onHistory: () -> Unit
) {
Card(
onClick = onHistory,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomePill("Latest reflection")
latest?.let { HomePill(if (it.isRevealed) "Revealed" else "Private") }
}
Text(
text = latest?.questionText ?: "Your reflections will appear here after you answer a prompt.",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
latest?.let {
Text(
text = if (it.isRevealed) it.revealSummary() else "Saved privately. Reveal it when the moment feels right.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun CategoryPreviewGrid(
categories: List<HomeCategorySummary>,
onCategory: (String) -> Unit,
onPacks: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Question packs",
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
OutlinedButton(
onClick = onPacks,
shape = RoundedCornerShape(14.dp)
) {
Text("All")
}
}
categories.chunked(2).forEach { rowItems ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
rowItems.forEach { item ->
CategoryMiniCard(
item = item,
modifier = Modifier.weight(1f),
onClick = { onCategory(item.category.id) }
)
}
if (rowItems.size == 1) {
Box(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CategoryMiniCard(
item: HomeCategorySummary,
modifier: Modifier,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleSmall,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${item.questionCount} prompts",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun SettingsStrip(onSettings: () -> Unit) {
OutlinedButton(
onClick = onSettings,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Text("Settings")
}
}
@Composable
private fun LoadingHomeCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
) {
Row(
modifier = Modifier.padding(22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFE07A5F))
Text(
text = "Opening the local dashboard",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun ErrorHomeCard(
message: String,
onRefresh: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Home paused",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
Button(
onClick = onRefresh,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
) {
Text("Retry")
}
}
}
}
@Composable
private fun HomePill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Preview
@Composable
fun HomeScreenPreview() {
HomeScreen()
HomeContent(
state = HomeUiState(
isLoading = false,
dailyQuestion = Question(
id = "preview",
text = "What is one tiny thing that would help us feel close tonight?",
category = "emotional_intimacy",
depthLevel = 2
),
answerStats = HomeAnswerStats(total = 4, revealed = 2, private = 2),
categories = listOf(
HomeCategorySummary(
category = QuestionCategory(
id = "communication",
displayName = "Communication",
description = "",
access = "mixed",
iconName = "chat"
),
questionCount = 250
),
HomeCategorySummary(
category = QuestionCategory(
id = "trust",
displayName = "Trust",
description = "",
access = "mixed",
iconName = "heart"
),
questionCount = 250
)
)
),
onDailyQuestion = {},
onPacks = {},
onCategory = {},
onHistory = {},
onSettings = {},
onRefresh = {}
)
}

View File

@ -0,0 +1,100 @@
package com.couplesconnect.app.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HomeCategorySummary(
val category: QuestionCategory,
val questionCount: Int
)
data class HomeAnswerStats(
val total: Int = 0,
val revealed: Int = 0,
val private: Int = 0,
val latest: LocalAnswer? = null
)
data class HomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
val dailyQuestion: Question? = null,
val categories: List<HomeCategorySummary> = emptyList(),
val answerStats: HomeAnswerStats = HomeAnswerStats()
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val questionRepository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadHome()
observeAnswers()
}
fun loadHome() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val dailyQuestion = questionRepository.getDailyQuestion()
val categories = questionRepository.getCategories()
.take(6)
.map { category ->
HomeCategorySummary(
category = category,
questionCount = questionRepository.getQuestionCountByCategory(category.id)
)
}
_uiState.update {
it.copy(
isLoading = false,
dailyQuestion = dailyQuestion,
categories = categories
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
error = e.message ?: "Could not load the local dashboard."
)
}
}
}
}
private fun observeAnswers() {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
val sorted = answers.sortedByDescending { it.updatedAt }
_uiState.update {
it.copy(
answerStats = HomeAnswerStats(
total = answers.size,
revealed = answers.count { answer -> answer.isRevealed },
private = answers.count { answer -> !answer.isRevealed },
latest = sorted.firstOrNull()
)
)
}
}
}
}
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun PartnerHomeScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "A shared pulse",
section = "Home",
description = "A partner-aware view for pairing status, recent rituals, and the next shared prompt.",
route = AppRoute.PARTNER_HOME,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Invite partner", AppRoute.CREATE_INVITE),
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Partner state", "Pairing bridge", "Shared rhythm"),
details = listOf(
"Pairing status can feel visible without feeling clinical",
"Recent shared activity can stay separate from private drafts",
"Home has a focused couple subspace"
)
)
}
@Preview
@Composable
fun PartnerHomeScreenPreview() {
PartnerHomeScreen()
}

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.onboarding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateProfileScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Create Profile") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Create Profile — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Shape your presence",
section = "Onboarding",
description = "The future profile setup step for names, pronouns, reminders, and relationship context.",
route = AppRoute.CREATE_PROFILE,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Continue home", AppRoute.HOME),
secondaryAction = PlaceholderAction("Pair partner", AppRoute.CREATE_INVITE),
chips = listOf("Profile", "Private by default", "Pairing ready"),
details = listOf(
"Personal details can live here before account persistence",
"Partner invite is one tap away from the setup path",
"Home remains reachable for local preview"
)
)
}
@Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.onboarding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Onboarding") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Onboarding — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Start together",
section = "Onboarding",
description = "A soft first run for setting names, rhythms, and the kind of connection you want to practice.",
route = AppRoute.ONBOARDING,
onNavigate = onNavigate,
accent = Color(0xFFE07A5F),
primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
chips = listOf("Warm entry", "Shared intent", "Slow start"),
details = listOf(
"A welcoming first screen with room for brand motion",
"Profile setup continues without creating an account yet",
"Returning users can move to sign in"
)
)
}
@Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AcceptInviteScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Accept Invite") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Accept Invite — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Join with care",
section = "Pairing",
description = "A future code-entry moment for accepting an invitation and confirming the couple context.",
route = AppRoute.ACCEPT_INVITE,
onNavigate = onNavigate,
accent = Color(0xFFE07A5F),
primaryAction = PlaceholderAction("Confirm sample", AppRoute.inviteConfirm("ABC123")),
secondaryAction = PlaceholderAction("Create invite", AppRoute.CREATE_INVITE),
chips = listOf("Code entry", "Partner consent", "Sample code"),
details = listOf(
"Invite lookup can stay careful and transparent",
"The confirmation screen receives the code",
"Pairing can wait for an explicit confirmation"
)
)
}
@Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateInviteScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Create Invite") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Create Invite — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Invite your person",
section = "Pairing",
description = "The future start of partner pairing, with shareable invite choices and clear privacy framing.",
route = AppRoute.CREATE_INVITE,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Email invite", AppRoute.EMAIL_INVITE),
secondaryAction = PlaceholderAction("Accept code", AppRoute.ACCEPT_INVITE),
chips = listOf("Pairing", "Share", "Consent-first"),
details = listOf(
"Invite creation can sit here after auth is available",
"Manual code and email paths both have room",
"Confirmation can feel explicit and reassuring"
)
)
}
@Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmailInviteScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Email Invite") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Email Invite — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Send the thread",
section = "Pairing",
description = "A draft email invite flow for adding a partner with care and clarity.",
route = AppRoute.EMAIL_INVITE,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Confirm sample", AppRoute.inviteConfirm("ABC123")),
secondaryAction = PlaceholderAction("Create invite", AppRoute.CREATE_INVITE),
chips = listOf("Email", "Code ABC123", "Preview"),
details = listOf(
"Recipient entry and preview can stay focused",
"Delivery copy can be gentle and direct",
"Sample confirmation keeps the invite code visible"
)
)
}
@Preview

View File

@ -1,39 +1,33 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InviteConfirmScreen(
inviteCode: String,
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Invite Confirm") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Invite Confirm — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Confirm the match",
section = "Pairing",
description = "The future confirmation step before two accounts become one couple space.",
route = AppRoute.inviteConfirm(inviteCode),
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Home", AppRoute.HOME),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Invite $inviteCode", "Confirm", "Couple space"),
details = listOf(
"The invite code stays visible",
"Partner identity checks can be layered in later",
"Completing pairing can return home"
)
)
}
@Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.paywall
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaywallScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Paywall") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Paywall — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Deeper practice",
section = "Paywall",
description = "A premium surface for expanded packs, rituals, and advanced couple reflection tools.",
route = AppRoute.PAYWALL,
onNavigate = onNavigate,
accent = Color(0xFFF2A65A),
primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION),
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Premium", "Deeper packs", "Upgrade path"),
details = listOf(
"Plan comparison can stay clear and generous",
"Deeper question packs can be framed with care",
"Subscription management has its own place"
)
)
}
@Preview

View File

@ -1,481 +1,65 @@
package com.couplesconnect.app.ui.questions
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.repository.QuestionRepository
/**
* Daily Question Screen
* Shows today's question, allows answer entry, and displays waiting state after submit.
* Warm, inviting interface with rose/terracotta/cream color palette.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DailyQuestionScreen(
onNavigate: (String) -> Unit = {},
repository: QuestionRepository = object : QuestionRepository {
override fun getDailyQuestion(): Question {
throw NotImplementedError("Repository not provided")
}
override fun getQuestionById(id: String): Question? {
throw NotImplementedError("Repository not provided")
}
}
viewModel: DailyQuestionViewModel = hiltViewModel()
) {
val viewModel = remember { DailyQuestionViewModel(repository) }
val question = viewModel.question
val answerText = viewModel.answerText
val uiState = viewModel.uiState
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = question.category.capitalizeCategory(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
) {
when (uiState) {
QuestionUiState.INPUTTING -> {
InputtingState(
question = question,
answerText = answerText,
onAnswerChanged = { viewModel.updateAnswer(it) },
onSubmit = { viewModel.submitAnswer() },
modifier = Modifier.align(Alignment.Center)
)
}
QuestionUiState.SUBMITTED -> {
SubmittedState(
question = question,
answer = answerText,
modifier = Modifier.align(Alignment.Center)
)
}
QuestionUiState.WAITING_FOR_PARTNER -> {
WaitingForPartnerState(
question = question,
answer = answerText,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
@Composable
private fun InputtingState(
question: Question,
answerText: String,
onAnswerChanged: (String) -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Question Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Question text - hero element
Text(
text = question.text,
style = MaterialTheme.typography.headlineSmall.copy(
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
lineHeight = 32.sp
),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Depth indicator
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Text(
text = "depth: ${getDepthLabel(question.depthLevel)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Answer Input Field
OutlinedTextField(
value = answerText,
onValueChange = onAnswerChanged,
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
placeholder = {
Text(
text = "Type your answer...",
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
)
},
singleLine = false,
minLines = 4,
maxLines = 6,
shape = RoundedCornerShape(16.dp),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
leadingIcon = {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
)
// Submit Button
FilledTonalButton(
onClick = onSubmit,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = answerText.isNotBlank(),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
disabledContainerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f),
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
),
shape = RoundedCornerShape(16.dp)
) {
Text(
text = "Submit Answer",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun SubmittedState(
question: Question,
answer: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Success Animation / Checkmark
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = "Answer submitted",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
}
// Success Text
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Answer submitted!",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Waiting for your partner's answer...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
// Progress indicator for waiting
CircularProgressIndicator(
modifier = Modifier.size(40.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
)
// Submitted Answer Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Your answer:",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.tertiary
)
)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
thickness = 1.dp
)
Text(
text = answer,
style = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 24.sp
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun WaitingForPartnerState(
question: Question,
answer: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Animated waiting indicator
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
)
}
// Waiting Text
Text(
text = "Waiting for your partner...",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Partner Answer Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Your answer:",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.tertiary
)
)
Text(
text = "Waiting...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
thickness = 1.dp
)
Text(
text = answer,
style = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 24.sp
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Future partner's answer (placeholder)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f)
)
Text(
text = "Partner's answer",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f)
)
Text(
text = "Tap to reveal when your partner has answered",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
}
}
}
}
// Helper functions
private fun String.capitalizeCategory(): String {
return this.replaceFirstChar { char ->
if (char.isLowerCase()) char.uppercase() else char.toString()
}.replace("-", " ")
}
private fun getDepthLabel(depthLevel: Int): String {
return when (depthLevel) {
1 -> "light"
2 -> "moderate"
3 -> "deep"
else -> "moderate"
}
}
// Preview
@Preview
@Composable
fun DailyQuestionScreenInputtingPreview() {
DailyQuestionScreen()
LocalQuestionContent(
state = state,
title = "One question, enough space",
subtitle = "A real prompt from the local question deck. Answer privately here, then move into a reveal or discussion path.",
primaryRouteLabel = "Discuss",
onPrimaryRoute = { question ->
onNavigate(AppRoute.questionThread("local-preview", question.id))
},
onSecondaryRoute = state.question?.let {
{ onNavigate(AppRoute.answerReveal(it.id)) }
},
secondaryRouteLabel = "Reveal",
onWrittenTextChanged = viewModel::updateWrittenText,
onOptionToggled = viewModel::toggleOption,
onScaleChanged = viewModel::updateScale,
onSubmit = viewModel::submitAnswer,
canSubmit = viewModel.canSubmit(),
onRefresh = viewModel::loadDailyQuestion
)
}
@Preview
@Composable
fun DailyQuestionScreenSubmittedPreview() {
DailyQuestionScreen()
fun DailyQuestionScreenPreview() {
LocalQuestionContent(
state = LocalQuestionUiState(
isLoading = false,
question = Question(
id = "preview",
text = "What is one small thing that would help us feel close tonight?",
category = "emotional_intimacy",
depthLevel = 2,
type = "written"
)
),
title = "One question, enough space",
subtitle = "A real prompt from the local question deck.",
primaryRouteLabel = "Discuss",
onPrimaryRoute = {},
onSecondaryRoute = {},
secondaryRouteLabel = "Reveal",
onWrittenTextChanged = {},
onOptionToggled = {},
onScaleChanged = {},
onSubmit = {},
canSubmit = false
)
}

View File

@ -1,35 +1,115 @@
package com.couplesconnect.app.ui.questions
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class QuestionUiState {
INPUTTING,
SUBMITTED,
WAITING_FOR_PARTNER
}
data class LocalQuestionUiState(
val isLoading: Boolean = true,
val error: String? = null,
val question: Question? = null,
val submitted: Boolean = false,
val pendingWrittenText: String = "",
val pendingSelectedOptionIds: List<String> = emptyList(),
val pendingScaleValue: Int = 3
)
class DailyQuestionViewModel(private val repository: QuestionRepository) : ViewModel() {
var question: Question = repository.getDailyQuestion()
private set
@HiltViewModel
class DailyQuestionViewModel @Inject constructor(
private val repository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository
) : ViewModel() {
var answerText: String by mutableStateOf("")
private set
private val _uiState = MutableStateFlow(LocalQuestionUiState())
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
var uiState: QuestionUiState by mutableStateOf(QuestionUiState.INPUTTING)
private set
init {
loadDailyQuestion()
}
fun updateAnswer(text: String) {
answerText = text
fun loadDailyQuestion() {
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val question = repository.getDailyQuestion()
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
_uiState.value = LocalQuestionUiState(
isLoading = false,
question = question,
pendingScaleValue = defaultScaleValue(question)
).withLocalAnswer(answer)
} catch (e: Exception) {
_uiState.value = LocalQuestionUiState(
isLoading = false,
error = e.message ?: "Could not load today's question."
)
}
}
}
fun updateWrittenText(text: String) {
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
}
fun toggleOption(optionId: String) {
_uiState.update { state ->
val question = state.question ?: return@update state
val updated = if (question.type == "multi_choice") {
if (optionId in state.pendingSelectedOptionIds) {
state.pendingSelectedOptionIds - optionId
} else {
state.pendingSelectedOptionIds + optionId
}
} else {
listOf(optionId)
}
state.copy(pendingSelectedOptionIds = updated, submitted = false)
}
}
fun updateScale(value: Int) {
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
}
fun submitAnswer() {
if (answerText.isNotBlank()) {
uiState = QuestionUiState.SUBMITTED
val state = _uiState.value
val question = state.question ?: return
if (!canSubmit(state)) return
viewModelScope.launch {
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
_uiState.update { it.copy(submitted = true) }
}
}
fun clearSubmittedState() {
_uiState.update { it.copy(submitted = false) }
}
fun canSubmit(): Boolean = canSubmit(_uiState.value)
private fun canSubmit(state: LocalQuestionUiState): Boolean {
val question = state.question ?: return false
return when (question.type) {
"written" -> state.pendingWrittenText.isNotBlank()
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
"scale" -> true
else -> false
}
}
}
fun defaultScaleValue(question: Question?): Int {
val cfg = question?.answerConfig as? com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl
val min = cfg?.config?.minScale ?: 1
val max = cfg?.config?.maxScale ?: 5
return ((min + max) / 2).coerceIn(min, max)
}

View File

@ -0,0 +1,55 @@
package com.couplesconnect.app.ui.questions
import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl
import com.couplesconnect.app.domain.model.LocalAnswer
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfigImpl
fun LocalQuestionUiState.withLocalAnswer(answer: LocalAnswer?): LocalQuestionUiState {
answer ?: return copy(submitted = false)
return copy(
submitted = true,
pendingWrittenText = answer.writtenText.orEmpty(),
pendingSelectedOptionIds = answer.selectedOptionIds,
pendingScaleValue = answer.scaleValue ?: pendingScaleValue
)
}
fun LocalQuestionUiState.toLocalAnswer(question: Question): LocalAnswer {
return LocalAnswer(
questionId = question.id,
questionText = question.text,
category = question.category,
answerType = question.type,
writtenText = pendingWrittenText.takeIf { question.type == "written" && it.isNotBlank() },
selectedOptionIds = when (question.type) {
"single_choice", "multi_choice", "this_or_that" -> pendingSelectedOptionIds
else -> emptyList()
},
selectedOptionTexts = selectedOptionTexts(question, pendingSelectedOptionIds),
scaleValue = pendingScaleValue.takeIf { question.type == "scale" }
)
}
private fun selectedOptionTexts(
question: Question,
selectedOptionIds: List<String>
): List<String> {
if (selectedOptionIds.isEmpty()) return emptyList()
return when (question.type) {
"this_or_that" -> {
val cfg = question.answerConfig as? ThisOrThatAnswerConfigImpl
listOfNotNull(cfg?.config?.optionA, cfg?.config?.optionB)
.filter { it.id in selectedOptionIds }
.map { it.text }
}
"single_choice", "multi_choice" -> {
val cfg = question.answerConfig as? ChoiceAnswerConfigImpl
cfg?.config?.options
?.filter { it.id in selectedOptionIds }
?.map { it.text }
?: emptyList()
}
else -> emptyList()
}
}

View File

@ -0,0 +1,363 @@
package com.couplesconnect.app.ui.questions
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.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.questions.components.QuestionAnswerInput
import com.couplesconnect.app.ui.questions.components.QuestionHeader
@Composable
fun LocalQuestionContent(
state: LocalQuestionUiState,
title: String,
subtitle: String,
primaryRouteLabel: String,
onPrimaryRoute: (Question) -> Unit,
onSecondaryRoute: (() -> Unit)?,
secondaryRouteLabel: String?,
onWrittenTextChanged: (String) -> Unit,
onOptionToggled: (String) -> Unit,
onScaleChanged: (Int) -> Unit,
onSubmit: () -> Unit,
canSubmit: Boolean,
onRefresh: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val background = Brush.linearGradient(
colors = listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
Box(
modifier = modifier
.fillMaxSize()
.background(background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
LocalQuestionHeader(title = title, subtitle = subtitle)
when {
state.isLoading -> LoadingCard()
state.error != null -> MessageCard(
title = "Question paused",
message = state.error
)
state.question == null -> MessageCard(
title = "No local question found",
message = "The local question database is ready, but this path did not return a prompt."
)
else -> {
val question = state.question
var helpExpanded by remember(question.id) { mutableStateOf(false) }
QuestionMetaRow(question = question)
QuestionHeader(
question = question,
helpExpanded = helpExpanded,
onToggleHelp = { helpExpanded = !helpExpanded },
)
QuestionAnswerInput(
question = question,
pendingWrittenText = state.pendingWrittenText,
pendingSelectedOptionIds = state.pendingSelectedOptionIds,
pendingScaleValue = state.pendingScaleValue,
onWrittenTextChanged = onWrittenTextChanged,
onOptionToggled = onOptionToggled,
onScaleChanged = onScaleChanged,
onSubmit = onSubmit,
canSubmit = canSubmit,
isSubmitting = false
)
if (state.submitted) {
SubmittedAnswerCard(question = question, state = state)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { onPrimaryRoute(question) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFE07A5F),
contentColor = Color.White
)
) {
Text(
text = primaryRouteLabel,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (onSecondaryRoute != null && secondaryRouteLabel != null) {
OutlinedButton(
onClick = onSecondaryRoute,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text(
text = secondaryRouteLabel,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
onRefresh?.let {
FilledTonalButton(
onClick = it,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Text("Try another local question")
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun LocalQuestionHeader(
title: String,
subtitle: String
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
@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) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
MetaPill(label = question.category.displayCategoryName())
MetaPill(label = "Depth ${question.depthLevel}")
MetaPill(label = question.type.displayQuestionType())
}
}
@Composable
private fun MetaPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color.White.copy(alpha = 0.72f),
shadowElevation = 0.dp
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun SubmittedAnswerCard(
question: Question,
state: LocalQuestionUiState
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFFFFF).copy(alpha = 0.86f)),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(Color(0xFF81B29A).copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Text(
text = "OK",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF2F5D45),
fontWeight = FontWeight.Bold
)
}
Text(
text = "Saved locally",
modifier = Modifier.padding(start = 10.dp),
style = MaterialTheme.typography.titleSmall,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold
)
}
Text(
text = answerSummary(question, state),
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
private fun answerSummary(question: Question, state: LocalQuestionUiState): String {
return when (question.type) {
"written" -> state.pendingWrittenText.ifBlank { "Your written answer is ready." }
"scale" -> "You chose ${state.pendingScaleValue}."
"this_or_that" -> {
val cfg = question.answerConfig as? ThisOrThatAnswerConfigImpl
val selected = listOfNotNull(cfg?.config?.optionA, cfg?.config?.optionB)
.firstOrNull { it.id == state.pendingSelectedOptionIds.firstOrNull() }
selected?.text ?: "Your choice is ready."
}
"single_choice", "multi_choice" -> {
val cfg = question.answerConfig as? ChoiceAnswerConfigImpl
val selected = cfg?.config?.options
?.filter { it.id in state.pendingSelectedOptionIds }
?.joinToString { it.text }
selected?.ifBlank { null } ?: "Your choice is ready."
}
else -> "Your answer is ready."
}
}
fun String.displayCategoryName(): String {
return split("_", "-")
.filter { it.isNotBlank() }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
}
fun String.displayQuestionType(): String {
return when (this) {
"single_choice" -> "Single choice"
"multi_choice" -> "Multi choice"
"this_or_that" -> "This or that"
"scale" -> "Scale"
else -> "Written"
}
}

View File

@ -0,0 +1,262 @@
package com.couplesconnect.app.ui.questions
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
@Composable
fun QuestionCategoryScreen(
categoryId: String,
onNavigate: (String) -> Unit = {},
viewModel: QuestionCategoryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
QuestionCategoryContent(
categoryId = categoryId,
state = state,
onQuestionSelected = { question ->
onNavigate(AppRoute.questionThread("local-preview", question.id))
}
)
}
@Composable
private fun QuestionCategoryContent(
categoryId: String,
state: QuestionCategoryUiState,
onQuestionSelected: (Question) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
val title = state.category?.displayName
?: categoryId.displayCategoryName()
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = state.category?.description
?: "Browse real local prompts in this category.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
when {
state.isLoading -> item { CategoryLoadingCard() }
state.error != null -> item {
CategoryMessageCard(
title = "Category paused",
message = state.error
)
}
state.questions.isEmpty() -> item {
CategoryMessageCard(
title = "No prompts found",
message = "The local deck did not return prompts for ${categoryId.displayCategoryName()}."
)
}
else -> {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("${state.questions.size} prompts")
CategoryPill(state.category?.access?.displayCategoryName() ?: "Local")
}
}
items(state.questions, key = { it.id }) { question ->
QuestionListCard(
question = question,
onClick = { onQuestionSelected(question) }
)
}
}
}
}
}
}
@Composable
private fun QuestionListCard(
question: Question,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Column(
modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = question.text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF27211F),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CategoryPill("Depth ${question.depthLevel}")
CategoryPill(question.type.displayQuestionType())
if (question.isPremium) {
CategoryPill("Premium")
} else {
CategoryPill("Free")
}
}
}
}
}
@Composable
private fun CategoryPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
maxLines = 1
)
}
}
@Composable
private fun CategoryLoadingCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
) {
Row(
modifier = Modifier.padding(22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFE07A5F))
Text(
text = "Loading local prompts",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun CategoryMessageCard(title: String, message: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.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)
)
}
}
}
@Preview
@Composable
fun QuestionCategoryScreenPreview() {
QuestionCategoryContent(
categoryId = "emotional_intimacy",
state = QuestionCategoryUiState(
isLoading = false,
category = QuestionCategory(
id = "emotional_intimacy",
displayName = "Emotional Intimacy",
description = "Prompts for closeness, tenderness, and reassurance.",
access = "mixed",
iconName = "heart"
),
questions = listOf(
Question(
id = "preview",
text = "What is one gentle thing I could do this week that would help you feel chosen?",
category = "emotional_intimacy",
depthLevel = 2,
type = "written"
)
)
),
onQuestionSelected = {}
)
}

View File

@ -0,0 +1,57 @@
package com.couplesconnect.app.ui.questions
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class QuestionCategoryUiState(
val isLoading: Boolean = true,
val error: String? = null,
val category: QuestionCategory? = null,
val questions: List<Question> = emptyList()
)
@HiltViewModel
class QuestionCategoryViewModel @Inject constructor(
private val repository: QuestionRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
private val _uiState = MutableStateFlow(QuestionCategoryUiState())
val uiState: StateFlow<QuestionCategoryUiState> = _uiState.asStateFlow()
init {
loadCategory()
}
fun loadCategory() {
viewModelScope.launch {
_uiState.value = QuestionCategoryUiState(isLoading = true)
try {
val category = repository.getCategoryById(categoryId)
val questions = repository.getQuestionsByCategory(categoryId)
_uiState.value = QuestionCategoryUiState(
isLoading = false,
category = category,
questions = questions
)
} catch (e: Exception) {
_uiState.value = QuestionCategoryUiState(
isLoading = false,
error = e.message ?: "Could not load this question category."
)
}
}
}
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.questions
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun QuestionComposerScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Ask it cleanly",
section = "Questions",
description = "A future composer for custom prompts, tone checks, and saving questions for later.",
route = AppRoute.QUESTION_COMPOSER,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Thread sample", AppRoute.questionThread("couple-preview", "custom-preview")),
secondaryAction = PlaceholderAction("Packs", AppRoute.QUESTION_PACKS),
chips = listOf("Custom prompt", "Tone aware", "Save later"),
details = listOf(
"Custom question creation stays separate from daily prompts",
"Tone support can arrive as a focused enhancement",
"Saved custom prompts can become shared threads"
)
)
}
@Preview
@Composable
fun QuestionComposerScreenPreview() {
QuestionComposerScreen()
}

View File

@ -0,0 +1,97 @@
package com.couplesconnect.app.ui.questions
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class QuestionDetailViewModel @Inject constructor(
private val repository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val questionId: String = savedStateHandle["questionId"] ?: ""
private val _uiState = MutableStateFlow(LocalQuestionUiState())
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
init {
loadQuestion()
}
fun loadQuestion() {
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val question = repository.getQuestionById(questionId)
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
_uiState.value = LocalQuestionUiState(
isLoading = false,
question = question,
pendingScaleValue = defaultScaleValue(question)
).withLocalAnswer(answer)
} catch (e: Exception) {
_uiState.value = LocalQuestionUiState(
isLoading = false,
error = e.message ?: "Could not load this question."
)
}
}
}
fun updateWrittenText(text: String) {
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
}
fun toggleOption(optionId: String) {
_uiState.update { state ->
val question = state.question ?: return@update state
val updated = if (question.type == "multi_choice") {
if (optionId in state.pendingSelectedOptionIds) {
state.pendingSelectedOptionIds - optionId
} else {
state.pendingSelectedOptionIds + optionId
}
} else {
listOf(optionId)
}
state.copy(pendingSelectedOptionIds = updated, submitted = false)
}
}
fun updateScale(value: Int) {
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
}
fun submitAnswer() {
val state = _uiState.value
val question = state.question ?: return
if (!canSubmit(state)) return
viewModelScope.launch {
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
_uiState.update { it.copy(submitted = true) }
}
}
fun canSubmit(): Boolean = canSubmit(_uiState.value)
private fun canSubmit(state: LocalQuestionUiState): Boolean {
val question = state.question ?: return false
return when (question.type) {
"written" -> state.pendingWrittenText.isNotBlank()
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
"scale" -> true
else -> false
}
}
}

View File

@ -0,0 +1,276 @@
package com.couplesconnect.app.ui.questions
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.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.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionCategory
@Composable
fun QuestionPackLibraryScreen(
onNavigate: (String) -> Unit = {},
viewModel: QuestionPackLibraryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
QuestionPackLibraryContent(
state = state,
onPackSelected = { onNavigate(AppRoute.questionCategory(it.category.id)) },
onPaywall = { onNavigate(AppRoute.PAYWALL) }
)
}
@Composable
private fun QuestionPackLibraryContent(
state: QuestionPackLibraryUiState,
onPackSelected: (QuestionPackItem) -> Unit,
onPaywall: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFA), Color(0xFFF1F6F3), Color(0xFFEAF0F4)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Pick a doorway",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF27211F)
)
Text(
text = "Real local question packs from the seeded deck, grouped by the kind of conversation you want to open.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF4E4642)
)
}
}
when {
state.isLoading -> item { LoadingPackCard() }
state.error != null -> item {
PackMessageCard(
title = "Packs paused",
message = state.error
)
}
state.packs.isEmpty() -> item {
PackMessageCard(
title = "No packs found",
message = "The local category table did not return any question packs."
)
}
else -> {
items(state.packs, key = { it.category.id }) { item ->
QuestionPackCard(
item = item,
onClick = { onPackSelected(item) }
)
}
item {
Button(
onClick = onPaywall,
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, bottom = 22.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFE07A5F),
contentColor = Color.White
)
) {
Text("Preview premium path")
}
}
}
}
}
}
}
@Composable
private fun QuestionPackCard(
item: QuestionPackItem,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f)),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF27211F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.category.description,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
PackPill("${item.questionCount} prompts")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PackPill(item.category.access.displayCategoryName())
PackPill(item.category.iconName.ifBlank { "question" }.displayCategoryName())
}
}
}
}
@Composable
private fun PackPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFF8F4F1)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3E3734),
maxLines = 1
)
}
}
@Composable
private fun LoadingPackCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
) {
Row(
modifier = Modifier.padding(22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFE07A5F))
Text(
text = "Loading local packs",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4E4642)
)
}
}
}
@Composable
private fun PackMessageCard(title: String, message: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.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)
)
}
}
}
@Preview
@Composable
fun QuestionPackLibraryScreenPreview() {
QuestionPackLibraryContent(
state = QuestionPackLibraryUiState(
isLoading = false,
packs = listOf(
QuestionPackItem(
category = QuestionCategory(
id = "emotional_intimacy",
displayName = "Emotional Intimacy",
description = "Prompts for closeness, reassurance, and being known.",
access = "mixed",
iconName = "heart"
),
questionCount = 250
)
)
),
onPackSelected = {},
onPaywall = {}
)
}

View File

@ -0,0 +1,56 @@
package com.couplesconnect.app.ui.questions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class QuestionPackItem(
val category: QuestionCategory,
val questionCount: Int
)
data class QuestionPackLibraryUiState(
val isLoading: Boolean = true,
val error: String? = null,
val packs: List<QuestionPackItem> = emptyList()
)
@HiltViewModel
class QuestionPackLibraryViewModel @Inject constructor(
private val repository: QuestionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
val uiState: StateFlow<QuestionPackLibraryUiState> = _uiState.asStateFlow()
init {
loadPacks()
}
fun loadPacks() {
viewModelScope.launch {
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
try {
val packs = repository.getCategories().map { category ->
QuestionPackItem(
category = category,
questionCount = repository.getQuestionCountByCategory(category.id)
)
}
_uiState.value = QuestionPackLibraryUiState(isLoading = false, packs = packs)
} catch (e: Exception) {
_uiState.value = QuestionPackLibraryUiState(
isLoading = false,
error = e.message ?: "Could not load question packs."
)
}
}
}
}

View File

@ -1,337 +1,87 @@
package com.couplesconnect.app.ui.questions
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionAnswer
import com.couplesconnect.app.ui.questions.components.AnswerBubble
import com.couplesconnect.app.ui.questions.components.QuestionAnswerInput
import com.couplesconnect.app.ui.questions.components.QuestionDiscussionThread
import com.couplesconnect.app.ui.questions.components.QuestionHeader
import com.couplesconnect.app.ui.questions.components.QuestionNavigationBar
import com.couplesconnect.app.domain.model.Question
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QuestionThreadScreen(
coupleId: String,
questionId: String,
previousQuestionId: String? = null,
nextQuestionId: String? = null,
onNavigate: (String) -> Unit = {},
onBack: () -> Unit = {},
viewModel: QuestionThreadViewModel = hiltViewModel()
viewModel: QuestionDetailViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbarHost = remember { SnackbarHostState() }
LaunchedEffect(state.error) {
val err = state.error ?: return@LaunchedEffect
snackbarHost.showSnackbar(err)
viewModel.dismissError()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHost) },
topBar = {
TopAppBar(
title = {
Text(
text = state.question?.category?.replaceFirstChar { it.uppercaseChar() }
?.replace("-", " ") ?: "",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
LocalQuestionContent(
state = state,
title = "Question thread",
subtitle = "A local version of the answer-and-discuss flow. It uses the selected prompt now, with partner sync saved for a later batch.",
primaryRouteLabel = nextQuestionId?.let { "Next" } ?: "History",
onPrimaryRoute = {
if (nextQuestionId != null) {
onNavigate(
AppRoute.questionThread(
coupleId = coupleId,
questionId = nextQuestionId,
prevId = questionId
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary
)
}
state.question == null -> {
Text(
text = "Question not found.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.Center)
.padding(24.dp),
textAlign = TextAlign.Center
)
}
else -> {
val question = state.question!!
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
QuestionHeader(
question = question,
helpExpanded = state.helpExpanded,
onToggleHelp = viewModel::toggleHelp
)
AnimatedContent(
targetState = state.phase,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "phase"
) { phase ->
when (phase) {
QuestionPhase.INPUT -> {
QuestionAnswerInput(
question = question,
pendingWrittenText = state.pendingWrittenText,
pendingSelectedOptionIds = state.pendingSelectedOptionIds,
pendingScaleValue = state.pendingScaleValue,
onWrittenTextChanged = viewModel::updateWrittenText,
onOptionToggled = viewModel::toggleOption,
onScaleChanged = viewModel::updateScale,
onSubmit = viewModel::submitAnswer,
canSubmit = viewModel.canSubmit(),
isSubmitting = state.isSubmitting
)
}
QuestionPhase.WAITING -> {
WaitingSection(
myAnswer = state.myAnswer,
question = question,
currentUserId = viewModel.currentUserId,
reactions = state.reactions,
onAddReaction = { emoji ->
viewModel.addReaction(viewModel.currentUserId, emoji)
}
)
}
QuestionPhase.REVEALED -> {
RevealedSection(
myAnswer = state.myAnswer,
partnerAnswer = state.partnerAnswer,
question = question,
currentUserId = viewModel.currentUserId,
reactions = state.reactions,
onAddReaction = viewModel::addReaction,
messages = state.messages,
messageInput = state.messageInput,
onMessageInputChanged = viewModel::updateMessageInput,
onSendMessage = viewModel::sendMessage
)
}
}
}
QuestionNavigationBar(
onPrevious = state.previousQuestionId?.let { prevId ->
{
onNavigate(
AppRoute.questionThread(
coupleId = state.question?.category ?: "",
questionId = prevId
)
)
}
},
onNext = state.nextQuestionId?.let { nextId ->
{
onNavigate(
AppRoute.questionThread(
coupleId = state.question?.category ?: "",
questionId = nextId
)
)
}
}
)
Spacer(modifier = Modifier.height(24.dp))
}
}
} else {
onNavigate(AppRoute.ANSWER_HISTORY)
}
}
}
}
// ─── Waiting section ─────────────────────────────────────────────────────────
@Composable
private fun WaitingSection(
myAnswer: QuestionAnswer?,
question: com.couplesconnect.app.domain.model.Question,
currentUserId: String,
reactions: List<com.couplesconnect.app.domain.model.QuestionReaction>,
onAddReaction: (emoji: String) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (myAnswer != null) {
AnswerBubble(
answer = myAnswer,
question = question,
isCurrentUser = true,
partnerDisplayName = null,
reactions = reactions.filter { it.targetUserId == currentUserId },
onAddReaction = onAddReaction
)
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 2.5.dp,
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
)
Text(
text = "Waiting for your partner…",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Their answer will appear here once they've replied.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center
},
onSecondaryRoute = previousQuestionId?.let {
{
onNavigate(
AppRoute.questionThread(
coupleId = coupleId,
questionId = previousQuestionId,
nextId = questionId
)
)
}
}
}
} ?: onBack,
secondaryRouteLabel = previousQuestionId?.let { "Previous" } ?: "Back",
onWrittenTextChanged = viewModel::updateWrittenText,
onOptionToggled = viewModel::toggleOption,
onScaleChanged = viewModel::updateScale,
onSubmit = viewModel::submitAnswer,
canSubmit = viewModel.canSubmit()
)
}
// ─── Revealed section ────────────────────────────────────────────────────────
@Preview
@Composable
private fun RevealedSection(
myAnswer: QuestionAnswer?,
partnerAnswer: QuestionAnswer?,
question: com.couplesconnect.app.domain.model.Question,
currentUserId: String,
reactions: List<com.couplesconnect.app.domain.model.QuestionReaction>,
onAddReaction: (targetUserId: String, emoji: String) -> Unit,
messages: List<com.couplesconnect.app.domain.model.QuestionMessage>,
messageInput: String,
onMessageInputChanged: (String) -> Unit,
onSendMessage: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Both answered — answers revealed",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
fontWeight = FontWeight.SemiBold
fun QuestionThreadScreenPreview() {
LocalQuestionContent(
state = LocalQuestionUiState(
isLoading = false,
question = Question(
id = "preview",
text = "What is one conversation you want us to handle more gently?",
category = "communication",
depthLevel = 3,
type = "written"
)
}
if (partnerAnswer != null) {
AnswerBubble(
answer = partnerAnswer,
question = question,
isCurrentUser = false,
partnerDisplayName = null,
reactions = reactions.filter { it.targetUserId == partnerAnswer.userId },
onAddReaction = { emoji -> onAddReaction(partnerAnswer.userId, emoji) }
)
}
if (myAnswer != null) {
AnswerBubble(
answer = myAnswer,
question = question,
isCurrentUser = true,
partnerDisplayName = null,
reactions = reactions.filter { it.targetUserId == currentUserId },
onAddReaction = { emoji -> onAddReaction(currentUserId, emoji) }
)
}
QuestionDiscussionThread(
messages = messages,
currentUserId = currentUserId,
messageInput = messageInput,
onMessageInputChanged = onMessageInputChanged,
onSendMessage = onSendMessage,
isRevealed = true
)
}
),
title = "Question thread",
subtitle = "A local version of the answer-and-discuss flow.",
primaryRouteLabel = "History",
onPrimaryRoute = {},
onSecondaryRoute = {},
secondaryRouteLabel = "Back",
onWrittenTextChanged = {},
onOptionToggled = {},
onScaleChanged = {},
onSubmit = {},
canSubmit = false
)
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun AccountScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Your account",
section = "Settings",
description = "A focused place for identity, login methods, export, and account lifecycle controls.",
route = AppRoute.ACCOUNT,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Notifications", AppRoute.NOTIFICATIONS),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Identity", "Export later", "Lifecycle"),
details = listOf(
"Account controls can stay separate from relationship data",
"Export and deletion flows can attach here",
"Notification settings remain one route away"
)
)
}
@Preview
@Composable
fun AccountScreenPreview() {
AccountScreen()
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun NotificationSettingsScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Gentle reminders",
section = "Settings",
description = "A notification control surface for ritual timing, quiet hours, and partner-aware nudges.",
route = AppRoute.NOTIFICATIONS,
onNavigate = onNavigate,
accent = Color(0xFFF2A65A),
primaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Quiet hours", "Ritual timing", "Opt-in"),
details = listOf(
"Reminder settings can be added without push integration yet",
"Quiet hours can protect sensitive moments",
"Privacy settings stay adjacent"
)
)
}
@Preview
@Composable
fun NotificationSettingsScreenPreview() {
NotificationSettingsScreen()
}

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@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"
)
)
}
@Preview
@Composable
fun PrivacyScreenPreview() {
PrivacyScreen()
}

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Settings") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Settings — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Tend the edges",
section = "Settings",
description = "The control center for account, privacy, notifications, subscription, and relationship preferences.",
route = AppRoute.SETTINGS,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Account", AppRoute.ACCOUNT),
secondaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
chips = listOf("Preferences", "Boundaries", "Careful controls"),
details = listOf(
"Personal settings stay separate from couple content",
"Privacy and notifications have focused places",
"Subscription management can connect to paywall later"
)
)
}
@Preview

View File

@ -0,0 +1,36 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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
@Composable
fun SubscriptionScreen(
onNavigate: (String) -> Unit = {}
) {
PlaceholderScreen(
title = "Manage the plan",
section = "Settings",
description = "A subscription management place for entitlement status, invoices, and plan changes.",
route = AppRoute.SUBSCRIPTION,
onNavigate = onNavigate,
accent = Color(0xFFE07A5F),
primaryAction = PlaceholderAction("Paywall", AppRoute.PAYWALL),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Entitlement", "Plan", "Restore"),
details = listOf(
"Entitlement display can stay plain-spoken",
"Plan changes and restore purchase can stay together",
"The upgrade path stays nearby"
)
)
}
@Preview
@Composable
fun SubscriptionScreenPreview() {
SubscriptionScreen()
}

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.wheel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryPickerScreen(
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Category Picker") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Category Picker — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Choose the weather",
section = "Wheel",
description = "A category picker for matching the conversation to the couple's energy in the moment.",
route = AppRoute.CATEGORY_PICKER,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Spin trust", AppRoute.spinWheel("trust")),
secondaryAction = PlaceholderAction("Question packs", AppRoute.QUESTION_PACKS),
chips = listOf("Categories", "Mood-aware", "Wheel entry"),
details = listOf(
"Seeded question categories can surface here",
"The selected category stays with the flow",
"The spin flow stays separate from daily questions"
)
)
}
@Preview

View File

@ -1,43 +1,37 @@
package com.couplesconnect.app.ui.wheel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpinWheelScreen(
categoryId: String,
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Spin Wheel") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Spin Wheel — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Let the prompt find you",
section = "Wheel",
description = "A playful selection surface for turning a chosen category into a short question session.",
route = AppRoute.spinWheel(categoryId),
onNavigate = onNavigate,
accent = Color(0xFFF2A65A),
primaryAction = PlaceholderAction("Start session", AppRoute.wheelSession("session-preview")),
secondaryAction = PlaceholderAction("Categories", AppRoute.CATEGORY_PICKER),
chips = listOf("Category $categoryId", "Motion", "Session"),
details = listOf(
"Wheel animation has room to become tactile",
"The chosen category stays visible",
"Session start feels like one continuous step"
)
)
}
@Preview
@Composable
fun SpinWheelScreenPreview() {
SpinWheelScreen(categoryId = "test_category")
SpinWheelScreen(categoryId = "trust")
}

View File

@ -1,43 +1,37 @@
package com.couplesconnect.app.ui.wheel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WheelCompleteScreen(
sessionId: String,
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Wheel Complete") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Wheel Complete — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Close the loop",
section = "Wheel",
description = "A completion surface for celebrating the ritual and offering the next gentle step.",
route = AppRoute.wheelComplete(sessionId),
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Answer history", AppRoute.ANSWER_HISTORY),
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Session $sessionId", "Completion", "Reflect"),
details = listOf(
"The session id survives through the full wheel flow",
"Reflection and history paths are ready",
"Celebration can stay simple and sincere"
)
)
}
@Preview
@Composable
fun WheelCompleteScreenPreview() {
WheelCompleteScreen(sessionId = "test_session")
WheelCompleteScreen(sessionId = "session-preview")
}

View File

@ -1,43 +1,37 @@
package com.couplesconnect.app.ui.wheel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WheelSessionScreen(
sessionId: String,
onNavigate: (String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Wheel Session") }) }
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Wheel Session — Coming Soon",
style = MaterialTheme.typography.headlineSmall
)
}
}
PlaceholderScreen(
title = "Stay with the question",
section = "Wheel",
description = "A lightweight session space for a chosen prompt, timer, partner state, and completion moment.",
route = AppRoute.wheelSession(sessionId),
onNavigate = onNavigate,
accent = Color(0xFFE07A5F),
primaryAction = PlaceholderAction("Complete", AppRoute.wheelComplete(sessionId)),
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
chips = listOf("Session $sessionId", "Prompt flow", "Finish path"),
details = listOf(
"Session state can stay calm and readable",
"Completion keeps continuity with the same moment",
"The flow can return home at any point"
)
)
}
@Preview
@Composable
fun WheelSessionScreenPreview() {
WheelSessionScreen(sessionId = "test_session")
WheelSessionScreen(sessionId = "session-preview")
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4B058F"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#A300FF"
android:fillAlpha="0.62"
android:pathData="M0,0h108v48c-12,4 -28,5 -45,2c-27,-4 -49,-17 -63,-31z" />
<path
android:fillColor="#2A044E"
android:fillAlpha="0.54"
android:pathData="M0,72c18,-9 39,-8 61,-2c20,5 34,8 47,1v37h-108z" />
<path
android:fillColor="#7E00E6"
android:fillAlpha="0.36"
android:pathData="M24,0h84v108h-32c-4,-20 -8,-40 -15,-57c-8,-19 -20,-35 -37,-51z" />
</vector>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2A044E"
android:fillAlpha="0.30"
android:pathData="M54,88C49,82 26,64 21,50C16,36 23,24 36,24C44,24 50,28 54,35C58,28 64,24 72,24C85,24 92,36 87,50C82,64 59,82 54,88Z" />
<path
android:fillColor="#FF141B"
android:pathData="M54,84C49,78 27,61 22,48C17,35 24,24 37,24C45,24 51,28 54,34C57,28 63,24 71,24C84,24 91,35 86,48C81,61 59,78 54,84Z" />
<path
android:fillColor="#FF4247"
android:fillAlpha="0.72"
android:pathData="M26,42C27,32 33,27 42,27C48,27 52,30 54,34C57,30 62,27 69,27C78,27 84,32 85,42C75,36 65,35 55,39C45,35 35,36 26,42Z" />
<path
android:fillColor="#C80614"
android:fillAlpha="0.48"
android:pathData="M22,48C28,60 43,72 54,84C65,72 80,60 86,48C82,63 61,79 54,87C47,79 26,63 22,48Z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>