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:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.CouplesConnect" android:theme="@style/Theme.CouplesConnect"
android:supportsRtl="true"> android:supportsRtl="true">

View File

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

View File

@ -1,16 +1,33 @@
package com.couplesconnect.app.core.navigation 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.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.couplesconnect.app.ui.auth.ForgotPasswordScreen
import com.couplesconnect.app.ui.answers.AnswerHistoryScreen import com.couplesconnect.app.ui.answers.AnswerHistoryScreen
import com.couplesconnect.app.ui.answers.AnswerRevealScreen import com.couplesconnect.app.ui.answers.AnswerRevealScreen
import com.couplesconnect.app.ui.auth.LoginScreen 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.HomeScreen
import com.couplesconnect.app.ui.home.PartnerHomeScreen
import com.couplesconnect.app.ui.onboarding.CreateProfileScreen import com.couplesconnect.app.ui.onboarding.CreateProfileScreen
import com.couplesconnect.app.ui.onboarding.OnboardingScreen import com.couplesconnect.app.ui.onboarding.OnboardingScreen
import com.couplesconnect.app.ui.pairing.AcceptInviteScreen 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.pairing.InviteConfirmScreen
import com.couplesconnect.app.ui.paywall.PaywallScreen import com.couplesconnect.app.ui.paywall.PaywallScreen
import com.couplesconnect.app.ui.questions.DailyQuestionScreen 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.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.SettingsScreen
import com.couplesconnect.app.ui.settings.SubscriptionScreen
import com.couplesconnect.app.ui.wheel.CategoryPickerScreen import com.couplesconnect.app.ui.wheel.CategoryPickerScreen
import com.couplesconnect.app.ui.wheel.SpinWheelScreen import com.couplesconnect.app.ui.wheel.SpinWheelScreen
import com.couplesconnect.app.ui.wheel.WheelCompleteScreen import com.couplesconnect.app.ui.wheel.WheelCompleteScreen
@ -32,132 +56,228 @@ fun AppNavigation(
startDestination: String = AppRoute.ONBOARDING startDestination: String = AppRoute.ONBOARDING
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost( val navBackStackEntry by navController.currentBackStackEntryAsState()
navController = navController, val currentRoute = navBackStackEntry?.destination?.route
startDestination = startDestination, val bottomRoutes = topLevelRoutes.map { it.route }.toSet()
modifier = modifier
) {
// Onboarding
composable(route = AppRoute.ONBOARDING) {
OnboardingScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.CREATE_PROFILE) {
CreateProfileScreen(onNavigate = navController::navigate)
}
// Auth Scaffold(
composable(route = AppRoute.LOGIN) { modifier = modifier,
LoginScreen(onNavigate = navController::navigate) bottomBar = {
if (currentRoute in bottomRoutes) {
AppBottomNavigation(
currentRoute = currentRoute,
onRouteSelected = { route ->
navController.navigate(route) {
launchSingleTop = true
}
}
)
}
} }
) { padding ->
// Home NavHost(
composable(route = AppRoute.HOME) { navController = navController,
HomeScreen(onNavigate = navController::navigate) startDestination = startDestination,
} modifier = Modifier.padding(padding)
// 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
}
)
) { ) {
QuestionThreadScreen( // Onboarding
onNavigate = navController::navigate, composable(route = AppRoute.ONBOARDING) {
onBack = { navController.popBackStack() } OnboardingScreen(onNavigate = navController::navigate)
) }
} composable(route = AppRoute.CREATE_PROFILE) {
CreateProfileScreen(onNavigate = navController::navigate)
}
// Answers // Auth
composable( composable(route = AppRoute.LOGIN) {
route = AppRoute.ANSWER_REVEAL, LoginScreen(onNavigate = navController::navigate)
arguments = listOf(navArgument("questionId") { type = NavType.StringType }) }
) { composable(route = AppRoute.SIGN_UP) {
AnswerRevealScreen( SignUpScreen(onNavigate = navController::navigate)
questionId = it.arguments?.getString("questionId") ?: "", }
onNavigate = navController::navigate composable(route = AppRoute.FORGOT_PASSWORD) {
) ForgotPasswordScreen(onNavigate = navController::navigate)
} }
composable(route = AppRoute.ANSWER_HISTORY) {
AnswerHistoryScreen(onNavigate = navController::navigate)
}
// Pairing // Home
composable(route = AppRoute.CREATE_INVITE) { composable(route = AppRoute.HOME) {
CreateInviteScreen(onNavigate = navController::navigate) HomeScreen(onNavigate = navController::navigate)
} }
composable(route = AppRoute.EMAIL_INVITE) { composable(route = AppRoute.PARTNER_HOME) {
EmailInviteScreen(onNavigate = navController::navigate) PartnerHomeScreen(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 // Daily Question
composable(route = AppRoute.CATEGORY_PICKER) { composable(route = AppRoute.DAILY_QUESTION) {
CategoryPickerScreen(onNavigate = navController::navigate) DailyQuestionScreen(onNavigate = navController::navigate)
} }
composable( composable(route = AppRoute.QUESTION_PACKS) {
route = AppRoute.SPIN_WHEEL, QuestionPackLibraryScreen(onNavigate = navController::navigate)
arguments = listOf(navArgument("categoryId") { type = NavType.StringType }) }
) { composable(
SpinWheelScreen( route = AppRoute.QUESTION_CATEGORY,
categoryId = it.arguments?.getString("categoryId") ?: "", arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
onNavigate = navController::navigate ) {
) QuestionCategoryScreen(
} categoryId = it.arguments?.getString("categoryId") ?: "",
composable( onNavigate = navController::navigate
route = AppRoute.WHEEL_SESSION, )
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }) }
) { composable(route = AppRoute.QUESTION_COMPOSER) {
WheelSessionScreen( QuestionComposerScreen(onNavigate = navController::navigate)
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 // Question Thread
composable(route = AppRoute.PAYWALL) { composable(
PaywallScreen(onNavigate = navController::navigate) 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 // Answers
composable(route = AppRoute.SETTINGS) { composable(
SettingsScreen(onNavigate = navController::navigate) 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 package com.couplesconnect.app.core.navigation
import android.net.Uri
object AppRoute { object AppRoute {
const val ONBOARDING = "onboarding" const val ONBOARDING = "onboarding"
const val LOGIN = "login" const val LOGIN = "login"
const val SIGN_UP = "sign_up"
const val FORGOT_PASSWORD = "forgot_password"
const val CREATE_PROFILE = "create_profile" const val CREATE_PROFILE = "create_profile"
const val HOME = "home" const val HOME = "home"
const val PARTNER_HOME = "partner_home"
const val DAILY_QUESTION = "daily_question" 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_REVEAL = "answer_reveal/{questionId}"
const val ANSWER_HISTORY = "answer_history" const val ANSWER_HISTORY = "answer_history"
const val CREATE_INVITE = "create_invite" const val CREATE_INVITE = "create_invite"
@ -18,23 +26,78 @@ object AppRoute {
const val WHEEL_COMPLETE = "wheel_complete/{sessionId}" const val WHEEL_COMPLETE = "wheel_complete/{sessionId}"
const val PAYWALL = "paywall" const val PAYWALL = "paywall"
const val SETTINGS = "settings" 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. // Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD = const val QUESTION_THREAD =
"question_thread/{coupleId}/{questionId}?prevId={prevId}&nextId={nextId}" "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( fun questionThread(
coupleId: String, coupleId: String,
questionId: String, questionId: String,
prevId: String? = null, prevId: String? = null,
nextId: String? = null nextId: String? = null
): String { ): String {
var route = "question_thread/$coupleId/$questionId" var route = "question_thread/${coupleId.asRouteArg()}/${questionId.asRouteArg()}"
val params = buildList { val params = buildList {
prevId?.let { add("prevId=$it") } prevId?.let { add("prevId=${it.asRouteArg()}") }
nextId?.let { add("nextId=$it") } nextId?.let { add("nextId=${it.asRouteArg()}") }
} }
if (params.isNotEmpty()) route += "?" + params.joinToString("&") if (params.isNotEmpty()) route += "?" + params.joinToString("&")
return route 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 @Dao
interface CategoryDao { interface CategoryDao {
@Query("SELECT * FROM question_category") @Query("SELECT * FROM question_category ORDER BY display_name ASC")
suspend fun getAllCategories(): List<CategoryEntity> suspend fun getAllCategories(): List<CategoryEntity>
@Query("SELECT * FROM question_category WHERE id = :id LIMIT 1") @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.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.couplesconnect.app.data.local.entity.QuestionEntity import com.couplesconnect.app.data.local.entity.QuestionEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface QuestionDao { interface QuestionDao {
@Query("SELECT * FROM question WHERE id = :id LIMIT 1") @Query("SELECT * FROM question WHERE id = :id LIMIT 1")
suspend fun getQuestionById(id: String): QuestionEntity? 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? 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> 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> 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> suspend fun getPremiumQuestions(): List<QuestionEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -1,10 +1,12 @@
package com.couplesconnect.app.data.local.mapper package com.couplesconnect.app.data.local.mapper
import com.couplesconnect.app.data.local.entity.QuestionEntity 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.ChoiceAnswerConfig
import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl
import com.couplesconnect.app.domain.model.ChoiceOption import com.couplesconnect.app.domain.model.ChoiceOption
import com.couplesconnect.app.domain.model.Question 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.ScaleAnswerConfig
import com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl import com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfig 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 { private fun parseTags(raw: String): List<String> = try {
val arr = JSONArray(raw) val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) } (0 until arr.length()).map { arr.getString(it) }

View File

@ -1,16 +1,19 @@
package com.couplesconnect.app.data.repository 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.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository import com.couplesconnect.app.domain.repository.QuestionRepository
class FakeQuestionRepository : QuestionRepository { class FakeQuestionRepository : QuestionRepository {
override fun getDailyQuestion(): Question { override suspend fun getDailyQuestion(): Question? = null
// Return a simple question as fallback - should be replaced with Room query
throw NotImplementedError("Use RoomQuestionRepository instead")
}
override fun getQuestionById(id: String): Question? { override suspend fun getQuestionById(id: String): Question? = null
throw NotImplementedError("Use RoomQuestionRepository instead")
} 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 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.Question
import com.couplesconnect.app.domain.model.QuestionCategory
import com.couplesconnect.app.domain.repository.QuestionRepository import com.couplesconnect.app.domain.repository.QuestionRepository
import javax.inject.Inject
import javax.inject.Singleton
class RoomQuestionRepository : QuestionRepository { @Singleton
override fun getDailyQuestion(): Question { class RoomQuestionRepository @Inject constructor(
throw NotImplementedError("Room queries need to be wrapped in coroutine scope") private val questionDao: QuestionDao,
private val categoryDao: CategoryDao
) : QuestionRepository {
override suspend fun getDailyQuestion(): Question? {
return questionDao.getDailyQuestion()?.toQuestion()
} }
override fun getQuestionById(id: String): Question? { override suspend fun getQuestionById(id: String): Question? {
throw NotImplementedError("Room queries need to be wrapped in coroutine scope") 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 package com.couplesconnect.app.di
import com.couplesconnect.app.data.local.AppDatabase 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.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -31,4 +29,8 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideQuestionDao(db: AppDatabase) = db.questionDao() 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 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.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.QuestionRepository
import com.couplesconnect.app.domain.repository.QuestionThreadRepository import com.couplesconnect.app.domain.repository.QuestionThreadRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@ -21,19 +22,15 @@ abstract class RepositoryModule {
impl: QuestionThreadRepositoryImpl impl: QuestionThreadRepositoryImpl
): QuestionThreadRepository ): QuestionThreadRepository
companion object { @Binds
@Provides @Singleton
@Singleton abstract fun bindQuestionRepository(
fun provideQuestionRepository(): QuestionRepository { impl: RoomQuestionRepository
return object : QuestionRepository { ): QuestionRepository
override fun getDailyQuestion(): Question {
throw NotImplementedError("Use RoomQuestionRepository instead")
}
override fun getQuestionById(id: String): Question? { @Binds
throw NotImplementedError("Use RoomQuestionRepository instead") @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 package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.Question import com.couplesconnect.app.domain.model.Question
import com.couplesconnect.app.domain.model.QuestionCategory
interface QuestionRepository { interface QuestionRepository {
fun getDailyQuestion(): Question suspend fun getDailyQuestion(): Question?
fun getQuestionById(id: String): 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 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.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.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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable 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.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.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 @Composable
fun AnswerHistoryScreen( fun AnswerHistoryScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: AnswerHistoryViewModel = hiltViewModel()
) { ) {
Scaffold( val state by viewModel.uiState.collectAsState()
topBar = { TopAppBar(title = { Text("Answer History") }) }
) { padding -> AnswerHistoryContent(
Box( 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .safeDrawingPadding()
contentAlignment = Alignment.Center .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(
text = "Answer History — Coming Soon", text = "No answers saved yet",
style = MaterialTheme.typography.headlineSmall 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 @Preview
@Composable @Composable
fun AnswerHistoryScreenPreview() { 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 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.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.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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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 @Composable
fun AnswerRevealScreen( fun AnswerRevealScreen(
questionId: String, questionId: String,
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: AnswerRevealViewModel = hiltViewModel()
) { ) {
Scaffold( val state by viewModel.uiState.collectAsState()
topBar = { TopAppBar(title = { Text("Answer Reveal") }) }
) { padding -> AnswerRevealContent(
Box( 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .safeDrawingPadding()
contentAlignment = Alignment.Center .navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) { ) {
Text( Text(
text = "Answer Reveal — Coming Soon", text = "Reveal together",
style = MaterialTheme.typography.headlineSmall 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 @Preview
@Composable @Composable
fun AnswerRevealScreenPreview() { 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 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun LoginScreen( fun LoginScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
Scaffold( PlaceholderScreen(
topBar = { TopAppBar(title = { Text("Login") }) } title = "Return to the room",
) { padding -> section = "Auth",
Box( description = "A calm return point for partners who already have a place in the app.",
modifier = Modifier route = AppRoute.LOGIN,
.fillMaxSize() onNavigate = onNavigate,
.padding(padding), accent = Color(0xFF6C8EA4),
contentAlignment = Alignment.Center primaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
) { secondaryAction = PlaceholderAction("Reset access", AppRoute.FORGOT_PASSWORD),
Text( chips = listOf("Returning", "Account", "Recovery"),
text = "Login — Coming Soon", details = listOf(
style = MaterialTheme.typography.headlineSmall "Email and provider choices have a natural home",
) "Recovery stays close without feeling alarming",
} "Onboarding can hand returning users here"
} )
)
} }
@Preview @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 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.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.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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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 @Composable
fun HomeScreen( fun HomeScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: HomeViewModel = hiltViewModel()
) { ) {
Scaffold( val state by viewModel.uiState.collectAsState()
topBar = { TopAppBar(title = { Text("Home") }) }
) { padding -> HomeContent(
Box( 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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .safeDrawingPadding()
contentAlignment = Alignment.Center .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(
text = "Home — Coming Soon", text = value,
style = MaterialTheme.typography.headlineSmall 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 @Preview
@Composable @Composable
fun HomeScreenPreview() { 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 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun CreateProfileScreen( fun CreateProfileScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
Scaffold( PlaceholderScreen(
topBar = { TopAppBar(title = { Text("Create Profile") }) } title = "Shape your presence",
) { padding -> section = "Onboarding",
Box( description = "The future profile setup step for names, pronouns, reminders, and relationship context.",
modifier = Modifier route = AppRoute.CREATE_PROFILE,
.fillMaxSize() onNavigate = onNavigate,
.padding(padding), accent = Color(0xFF81B29A),
contentAlignment = Alignment.Center primaryAction = PlaceholderAction("Continue home", AppRoute.HOME),
) { secondaryAction = PlaceholderAction("Pair partner", AppRoute.CREATE_INVITE),
Text( chips = listOf("Profile", "Private by default", "Pairing ready"),
text = "Create Profile — Coming Soon", details = listOf(
style = MaterialTheme.typography.headlineSmall "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 @Preview

View File

@ -1,38 +1,32 @@
package com.couplesconnect.app.ui.onboarding 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun OnboardingScreen( fun OnboardingScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
Scaffold( PlaceholderScreen(
topBar = { TopAppBar(title = { Text("Onboarding") }) } title = "Start together",
) { padding -> section = "Onboarding",
Box( description = "A soft first run for setting names, rhythms, and the kind of connection you want to practice.",
modifier = Modifier route = AppRoute.ONBOARDING,
.fillMaxSize() onNavigate = onNavigate,
.padding(padding), accent = Color(0xFFE07A5F),
contentAlignment = Alignment.Center primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
) { secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
Text( chips = listOf("Warm entry", "Shared intent", "Slow start"),
text = "Onboarding — Coming Soon", details = listOf(
style = MaterialTheme.typography.headlineSmall "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 @Preview

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,481 +1,65 @@
package com.couplesconnect.app.ui.questions 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.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.unit.sp import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.Question 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 @Composable
fun DailyQuestionScreen( fun DailyQuestionScreen(
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
repository: QuestionRepository = object : QuestionRepository { viewModel: DailyQuestionViewModel = hiltViewModel()
override fun getDailyQuestion(): Question {
throw NotImplementedError("Repository not provided")
}
override fun getQuestionById(id: String): Question? {
throw NotImplementedError("Repository not provided")
}
}
) { ) {
val viewModel = remember { DailyQuestionViewModel(repository) } val state by viewModel.uiState.collectAsState()
val question = viewModel.question
val answerText = viewModel.answerText
val uiState = viewModel.uiState
Scaffold( LocalQuestionContent(
topBar = { state = state,
TopAppBar( title = "One question, enough space",
title = { subtitle = "A real prompt from the local question deck. Answer privately here, then move into a reveal or discussion path.",
Row( primaryRouteLabel = "Discuss",
verticalAlignment = Alignment.CenterVertically, onPrimaryRoute = { question ->
horizontalArrangement = Arrangement.Center, onNavigate(AppRoute.questionThread("local-preview", question.id))
modifier = Modifier.fillMaxWidth() },
) { onSecondaryRoute = state.question?.let {
Text( { onNavigate(AppRoute.answerReveal(it.id)) }
text = question.category.capitalizeCategory(), },
style = MaterialTheme.typography.titleSmall, secondaryRouteLabel = "Reveal",
color = MaterialTheme.colorScheme.tertiary onWrittenTextChanged = viewModel::updateWrittenText,
) onOptionToggled = viewModel::toggleOption,
} onScaleChanged = viewModel::updateScale,
} onSubmit = viewModel::submitAnswer,
) canSubmit = viewModel.canSubmit(),
} onRefresh = viewModel::loadDailyQuestion
) { 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()
} }
@Preview @Preview
@Composable @Composable
fun DailyQuestionScreenSubmittedPreview() { fun DailyQuestionScreenPreview() {
DailyQuestionScreen() 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 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.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.domain.model.Question 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.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 { data class LocalQuestionUiState(
INPUTTING, val isLoading: Boolean = true,
SUBMITTED, val error: String? = null,
WAITING_FOR_PARTNER 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() { @HiltViewModel
var question: Question = repository.getDailyQuestion() class DailyQuestionViewModel @Inject constructor(
private set private val repository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository
) : ViewModel() {
var answerText: String by mutableStateOf("") private val _uiState = MutableStateFlow(LocalQuestionUiState())
private set val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
var uiState: QuestionUiState by mutableStateOf(QuestionUiState.INPUTTING) init {
private set loadDailyQuestion()
}
fun updateAnswer(text: String) { fun loadDailyQuestion() {
answerText = text 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() { fun submitAnswer() {
if (answerText.isNotBlank()) { val state = _uiState.value
uiState = QuestionUiState.SUBMITTED 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 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview
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.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.QuestionAnswer import com.couplesconnect.app.domain.model.Question
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun QuestionThreadScreen( fun QuestionThreadScreen(
coupleId: String,
questionId: String,
previousQuestionId: String? = null,
nextQuestionId: String? = null,
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
onBack: () -> Unit = {}, onBack: () -> Unit = {},
viewModel: QuestionThreadViewModel = hiltViewModel() viewModel: QuestionDetailViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbarHost = remember { SnackbarHostState() }
LaunchedEffect(state.error) { LocalQuestionContent(
val err = state.error ?: return@LaunchedEffect state = state,
snackbarHost.showSnackbar(err) title = "Question thread",
viewModel.dismissError() 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 = {
Scaffold( if (nextQuestionId != null) {
snackbarHost = { SnackbarHost(snackbarHost) }, onNavigate(
topBar = { AppRoute.questionThread(
TopAppBar( coupleId = coupleId,
title = { questionId = nextQuestionId,
Text( prevId = questionId
text = state.question?.category?.replaceFirstChar { it.uppercaseChar() }
?.replace("-", " ") ?: "",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
) )
) } else {
} onNavigate(AppRoute.ANSWER_HISTORY)
) { 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))
}
}
} }
} },
} onSecondaryRoute = previousQuestionId?.let {
} {
onNavigate(
// ─── Waiting section ───────────────────────────────────────────────────────── AppRoute.questionThread(
coupleId = coupleId,
@Composable questionId = previousQuestionId,
private fun WaitingSection( nextId = questionId
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
) )
} }
} } ?: 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 @Composable
private fun RevealedSection( fun QuestionThreadScreenPreview() {
myAnswer: QuestionAnswer?, LocalQuestionContent(
partnerAnswer: QuestionAnswer?, state = LocalQuestionUiState(
question: com.couplesconnect.app.domain.model.Question, isLoading = false,
currentUserId: String, question = Question(
reactions: List<com.couplesconnect.app.domain.model.QuestionReaction>, id = "preview",
onAddReaction: (targetUserId: String, emoji: String) -> Unit, text = "What is one conversation you want us to handle more gently?",
messages: List<com.couplesconnect.app.domain.model.QuestionMessage>, category = "communication",
messageInput: String, depthLevel = 3,
onMessageInputChanged: (String) -> Unit, type = "written"
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
) )
} ),
title = "Question thread",
if (partnerAnswer != null) { subtitle = "A local version of the answer-and-discuss flow.",
AnswerBubble( primaryRouteLabel = "History",
answer = partnerAnswer, onPrimaryRoute = {},
question = question, onSecondaryRoute = {},
isCurrentUser = false, secondaryRouteLabel = "Back",
partnerDisplayName = null, onWrittenTextChanged = {},
reactions = reactions.filter { it.targetUserId == partnerAnswer.userId }, onOptionToggled = {},
onAddReaction = { emoji -> onAddReaction(partnerAnswer.userId, emoji) } onScaleChanged = {},
) onSubmit = {},
} canSubmit = false
)
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
)
}
} }

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 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun SettingsScreen( fun SettingsScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
Scaffold( PlaceholderScreen(
topBar = { TopAppBar(title = { Text("Settings") }) } title = "Tend the edges",
) { padding -> section = "Settings",
Box( description = "The control center for account, privacy, notifications, subscription, and relationship preferences.",
modifier = Modifier route = AppRoute.SETTINGS,
.fillMaxSize() onNavigate = onNavigate,
.padding(padding), accent = Color(0xFF6C8EA4),
contentAlignment = Alignment.Center primaryAction = PlaceholderAction("Account", AppRoute.ACCOUNT),
) { secondaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
Text( chips = listOf("Preferences", "Boundaries", "Careful controls"),
text = "Settings — Coming Soon", details = listOf(
style = MaterialTheme.typography.headlineSmall "Personal settings stay separate from couple content",
) "Privacy and notifications have focused places",
} "Subscription management can connect to paywall later"
} )
)
} }
@Preview @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 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun CategoryPickerScreen( fun CategoryPickerScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {}
) { ) {
Scaffold( PlaceholderScreen(
topBar = { TopAppBar(title = { Text("Category Picker") }) } title = "Choose the weather",
) { padding -> section = "Wheel",
Box( description = "A category picker for matching the conversation to the couple's energy in the moment.",
modifier = Modifier route = AppRoute.CATEGORY_PICKER,
.fillMaxSize() onNavigate = onNavigate,
.padding(padding), accent = Color(0xFF6C8EA4),
contentAlignment = Alignment.Center primaryAction = PlaceholderAction("Spin trust", AppRoute.spinWheel("trust")),
) { secondaryAction = PlaceholderAction("Question packs", AppRoute.QUESTION_PACKS),
Text( chips = listOf("Categories", "Mood-aware", "Wheel entry"),
text = "Category Picker — Coming Soon", details = listOf(
style = MaterialTheme.typography.headlineSmall "Seeded question categories can surface here",
) "The selected category stays with the flow",
} "The spin flow stays separate from daily questions"
} )
)
} }
@Preview @Preview

View File

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

View File

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

View File

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